zod-paginate 1.0.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/dist/main.js ADDED
@@ -0,0 +1,1130 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.paginate = paginate;
4
+ const zod_1 = require("zod");
5
+ /* ---------------------------------- */
6
+ /* Common input normalizers */
7
+ /* ---------------------------------- */
8
+ /**
9
+ * We often want to allow both single values and arrays in the querystring, e.g. "select=field1,field2" or "select[]=field1&select[]=field2".
10
+ * This function normalizes both cases to a string array.
11
+ */
12
+ function toStringArrayFromQueryString(v) {
13
+ if (v === undefined)
14
+ return [];
15
+ if (Array.isArray(v))
16
+ return v;
17
+ return [v];
18
+ }
19
+ /**
20
+ * Zod schema for a querystring parameter that can be either a single string or an array of strings.
21
+ * It normalizes the output to always be an array of strings.
22
+ */
23
+ const StringOrStringArraySchema = zod_1.z
24
+ .union([zod_1.z.string(), zod_1.z.array(zod_1.z.string())])
25
+ .transform((v) => (typeof v === 'string' ? [v] : v));
26
+ /**
27
+ * Zod schema for the "select" parameter, which can be a comma-separated string or an array of strings.
28
+ * It normalizes the output to an array of strings.
29
+ */
30
+ const SelectCsvSchema = zod_1.z
31
+ .string()
32
+ .transform((s) => s
33
+ .split(',')
34
+ .map((x) => x.trim())
35
+ .filter(Boolean))
36
+ .refine((arr) => arr.length > 0, { message: 'select cannot be empty' });
37
+ /* ---------------------------------- */
38
+ /* Sort */
39
+ /* ---------------------------------- */
40
+ const SortDirectionSchema = zod_1.z.enum(['ASC', 'DESC']);
41
+ const SortItemSchema = zod_1.z.object({
42
+ property: zod_1.z.string().min(1),
43
+ direction: SortDirectionSchema,
44
+ });
45
+ /**
46
+ * Parse "field:ASC" into a SortItem.
47
+ * The input must have a colon separating the field and direction, and the direction must be either "ASC" or "DESC" (case-insensitive).
48
+ * */
49
+ function parseSortItem(raw) {
50
+ const [propertyRaw, dirRaw] = raw.split(':');
51
+ const property = (propertyRaw ?? '').trim();
52
+ const direction = SortDirectionSchema.parse((dirRaw ?? '').trim());
53
+ return SortItemSchema.parse({ property, direction });
54
+ }
55
+ /* ---------------------------------- */
56
+ /* Conditions + grouping */
57
+ /* ---------------------------------- */
58
+ /**
59
+ * Supported operators.
60
+ * $eq: equality (for strings, numbers, dates)
61
+ * $null: checks for null (ignores the value)
62
+ * $in: checks if the field value is in the provided array (for strings, numbers, dates)
63
+ * $gt, $gte, $lt, $lte: comparison operators (for numbers and dates)
64
+ * $btw: checks if the field value is between two values (for numbers and dates)
65
+ * $ilike: case-insensitive substring match (for strings)
66
+ * $sw: case-insensitive starts-with match (for strings)
67
+ * $contains: checks if the field value contains the provided value (for strings)
68
+ */
69
+ const OperatorSchema = zod_1.z.enum([
70
+ '$eq',
71
+ '$null',
72
+ '$in',
73
+ '$gt',
74
+ '$gte',
75
+ '$lt',
76
+ '$lte',
77
+ '$btw',
78
+ '$ilike',
79
+ '$sw',
80
+ '$contains',
81
+ ]);
82
+ /**
83
+ * Logical combinators for grouping conditions. $and and $or can be used to combine multiple conditions within the same group.
84
+ */
85
+ const CombinatorSchema = zod_1.z.enum(['$and', '$or']);
86
+ const ROOT_GROUP_ID = '0';
87
+ const IntegerStringSchema = zod_1.z.string().regex(/^\d+$/, 'Must be an integer string');
88
+ /**
89
+ * Regex for validating ISO date strings (YYYY-MM-DD).
90
+ */
91
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
92
+ /**
93
+ * Regex for validating ISO datetime strings (YYYY-MM-DDTHH:mm:ss.sssZ or with timezone offset).
94
+ */
95
+ const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z|[+-]\d{2}:\d{2})$/;
96
+ const NumericStringSchema = zod_1.z
97
+ .string()
98
+ .trim()
99
+ .regex(/^\d+$/, 'Must be a numeric string')
100
+ .transform((s) => Number(s));
101
+ const NumOrDateSchema = zod_1.z.union([zod_1.z.number(), zod_1.z.string()]);
102
+ const ConditionSchema = zod_1.z.discriminatedUnion('op', [
103
+ zod_1.z.object({
104
+ group: IntegerStringSchema,
105
+ combinator: CombinatorSchema.optional(),
106
+ op: zod_1.z.literal('$null'),
107
+ not: zod_1.z.literal(true).optional(),
108
+ }),
109
+ zod_1.z.object({
110
+ group: IntegerStringSchema,
111
+ combinator: CombinatorSchema.optional(),
112
+ op: zod_1.z.literal('$eq'),
113
+ not: zod_1.z.literal(true).optional(),
114
+ value: NumOrDateSchema,
115
+ }),
116
+ zod_1.z.object({
117
+ group: IntegerStringSchema,
118
+ combinator: CombinatorSchema.optional(),
119
+ op: zod_1.z.enum(['$ilike', '$sw']),
120
+ not: zod_1.z.literal(true).optional(),
121
+ value: zod_1.z.string(),
122
+ }),
123
+ zod_1.z.object({
124
+ group: IntegerStringSchema,
125
+ combinator: CombinatorSchema.optional(),
126
+ op: zod_1.z.enum(['$in', '$contains']),
127
+ not: zod_1.z.literal(true).optional(),
128
+ value: zod_1.z.array(zod_1.z.string()),
129
+ }),
130
+ zod_1.z.object({
131
+ group: IntegerStringSchema,
132
+ combinator: CombinatorSchema.optional(),
133
+ op: zod_1.z.enum(['$gt', '$gte', '$lt', '$lte']),
134
+ not: zod_1.z.literal(true).optional(),
135
+ value: NumOrDateSchema,
136
+ }),
137
+ zod_1.z.object({
138
+ group: IntegerStringSchema,
139
+ combinator: CombinatorSchema.optional(),
140
+ op: zod_1.z.literal('$btw'),
141
+ not: zod_1.z.literal(true).optional(),
142
+ value: zod_1.z.tuple([NumOrDateSchema, NumOrDateSchema]),
143
+ }),
144
+ ]);
145
+ function and(items) {
146
+ if (items.length === 1 && items[0])
147
+ return items[0];
148
+ return { type: 'and', items };
149
+ }
150
+ function or(items) {
151
+ if (items.length === 1 && items[0])
152
+ return items[0];
153
+ return { type: 'or', items };
154
+ }
155
+ function fold(op, left, right) {
156
+ if (op === '$or') {
157
+ if (left.type === 'or')
158
+ return or([...left.items, right]);
159
+ return or([left, right]);
160
+ }
161
+ if (left.type === 'and')
162
+ return and([...left.items, right]);
163
+ return and([left, right]);
164
+ }
165
+ const WhereNodeSchema = zod_1.z.lazy(() => zod_1.z.union([
166
+ zod_1.z.object({ type: zod_1.z.literal('filter'), field: zod_1.z.string(), condition: ConditionSchema }),
167
+ zod_1.z.object({ type: zod_1.z.literal('and'), items: zod_1.z.array(WhereNodeSchema) }),
168
+ zod_1.z.object({ type: zod_1.z.literal('or'), items: zod_1.z.array(WhereNodeSchema) }),
169
+ ]));
170
+ function extractGroupDefs(q) {
171
+ const defs = {};
172
+ for (const [k, v] of Object.entries(q)) {
173
+ if (!k.startsWith('group.'))
174
+ continue;
175
+ const rest = k.slice('group.'.length);
176
+ const dotIdx = rest.indexOf('.');
177
+ if (dotIdx === -1)
178
+ continue;
179
+ const groupIdRaw = rest.slice(0, dotIdx).trim();
180
+ const prop = rest.slice(dotIdx + 1).trim();
181
+ const parsedId = IntegerStringSchema.safeParse(groupIdRaw);
182
+ if (!parsedId.success)
183
+ continue;
184
+ const id = parsedId.data;
185
+ const first = Array.isArray(v) ? v[0] : v;
186
+ const valueStr = typeof first === 'string' ? first : '';
187
+ const current = defs[id] ?? {};
188
+ if (prop === 'parent') {
189
+ defs[id] = { ...current, parent: IntegerStringSchema.parse(valueStr.trim()) };
190
+ continue;
191
+ }
192
+ if (prop === 'join') {
193
+ defs[id] = { ...current, join: CombinatorSchema.parse(valueStr.trim()) };
194
+ continue;
195
+ }
196
+ if (prop === 'op') {
197
+ defs[id] = { ...current, op: CombinatorSchema.parse(valueStr.trim()) };
198
+ continue;
199
+ }
200
+ }
201
+ return defs;
202
+ }
203
+ function validateGroupDefs(defs) {
204
+ const root = defs[ROOT_GROUP_ID];
205
+ if (root && (root.parent !== undefined || root.join !== undefined)) {
206
+ throw new Error(`group.0 can only define "op". "parent" and "join" are not allowed on root group "0".`);
207
+ }
208
+ for (const [id, def] of Object.entries(defs)) {
209
+ if (id === ROOT_GROUP_ID)
210
+ continue;
211
+ if (def.parent !== undefined)
212
+ IntegerStringSchema.parse(def.parent);
213
+ }
214
+ const visiting = new Set();
215
+ const visited = new Set();
216
+ const visit = (id) => {
217
+ if (visited.has(id))
218
+ return;
219
+ if (visiting.has(id))
220
+ throw new Error(`Group cycle detected at group "${id}".`);
221
+ visiting.add(id);
222
+ const parent = defs[id]?.parent;
223
+ if (parent && parent !== ROOT_GROUP_ID)
224
+ visit(parent);
225
+ visiting.delete(id);
226
+ visited.add(id);
227
+ };
228
+ for (const id of Object.keys(defs)) {
229
+ if (id === ROOT_GROUP_ID)
230
+ continue;
231
+ visit(id);
232
+ }
233
+ }
234
+ function buildGroupConditionExprs(rawFilters) {
235
+ const groupNodes = new Map();
236
+ for (const [field, conditions] of Object.entries(rawFilters)) {
237
+ for (const cond of conditions) {
238
+ const groupId = cond.group;
239
+ const list = groupNodes.get(groupId) ?? [];
240
+ const isFirst = list.length === 0;
241
+ if (isFirst && cond.combinator !== undefined) {
242
+ throw new Error(`Invalid combinator "${cond.combinator}" on first condition of group "${groupId}". ` +
243
+ `First condition in a group cannot define "$and" or "$or".`);
244
+ }
245
+ list.push({ type: 'filter', field, condition: cond });
246
+ groupNodes.set(groupId, list);
247
+ }
248
+ }
249
+ const exprs = new Map();
250
+ for (const [groupId, nodes] of groupNodes.entries()) {
251
+ if (nodes.length === 0)
252
+ continue;
253
+ if (!nodes[0])
254
+ continue;
255
+ let current = nodes[0];
256
+ for (let i = 1; i < nodes.length; i += 1) {
257
+ const next = nodes[i];
258
+ if (!next)
259
+ break;
260
+ current = fold(next.condition.combinator, current, next);
261
+ }
262
+ exprs.set(groupId, current);
263
+ }
264
+ return exprs;
265
+ }
266
+ function buildWhereAstWithGroups(rawFilters, groupDefs) {
267
+ const groupExprs = buildGroupConditionExprs(rawFilters);
268
+ const allGroupIds = new Set();
269
+ for (const id of groupExprs.keys())
270
+ allGroupIds.add(id);
271
+ for (const id of Object.keys(groupDefs))
272
+ allGroupIds.add(id);
273
+ allGroupIds.add(ROOT_GROUP_ID);
274
+ const effectiveParent = (id) => {
275
+ if (id === ROOT_GROUP_ID)
276
+ return ROOT_GROUP_ID;
277
+ return groupDefs[id]?.parent ?? ROOT_GROUP_ID;
278
+ };
279
+ const childrenByParent = new Map();
280
+ for (const id of allGroupIds) {
281
+ if (id === ROOT_GROUP_ID)
282
+ continue;
283
+ const parentId = effectiveParent(id);
284
+ const arr = childrenByParent.get(parentId) ?? [];
285
+ arr.push(id);
286
+ childrenByParent.set(parentId, arr);
287
+ }
288
+ const sortNumericIds = (ids) => {
289
+ const pairs = ids.map((s) => ({ s, n: Number(s) }));
290
+ pairs.sort((a, b) => a.n - b.n);
291
+ return pairs.map((p) => p.s);
292
+ };
293
+ const visiting = new Set();
294
+ const resolved = new Map();
295
+ const resolveGroup = (id) => {
296
+ const cached = resolved.get(id);
297
+ if (cached)
298
+ return cached;
299
+ if (visiting.has(id))
300
+ throw new Error(`Group cycle detected while resolving group "${id}".`);
301
+ visiting.add(id);
302
+ const items = [];
303
+ const own = groupExprs.get(id);
304
+ if (own)
305
+ items.push({ expr: own });
306
+ const children = sortNumericIds(childrenByParent.get(id) ?? []);
307
+ const parentOp = groupDefs[id]?.op;
308
+ for (const childId of children) {
309
+ const childExpr = resolveGroup(childId);
310
+ const childJoin = groupDefs[childId]?.join;
311
+ items.push({ expr: childExpr, join: childJoin ?? parentOp });
312
+ }
313
+ if (items.length === 0 || !items[0]) {
314
+ const empty = { type: 'and', items: [] };
315
+ resolved.set(id, empty);
316
+ visiting.delete(id);
317
+ return empty;
318
+ }
319
+ if (items[0].join !== undefined) {
320
+ throw new Error(`Invalid group join "${items[0].join}" for the first item inside group "${id}". ` +
321
+ `A group cannot start with "$and" or "$or" because there is nothing to join with.`);
322
+ }
323
+ let current = items[0].expr;
324
+ for (let i = 1; i < items.length; i += 1) {
325
+ const next = items[i];
326
+ if (!next)
327
+ break;
328
+ current = fold(next.join, current, next.expr);
329
+ }
330
+ resolved.set(id, current);
331
+ visiting.delete(id);
332
+ return current;
333
+ };
334
+ validateGroupDefs(groupDefs);
335
+ return resolveGroup(ROOT_GROUP_ID);
336
+ }
337
+ /* ---------------------------------- */
338
+ /* DSL parsing */
339
+ /* ---------------------------------- */
340
+ /** Parse a string as either a finite number or an ISO date string. */
341
+ function parseNumOrDateStrict(raw, ctx) {
342
+ const s = raw.trim();
343
+ if (/^[+-]?\d+(\.\d+)?$/.test(s)) {
344
+ const n = Number(s);
345
+ if (!Number.isFinite(n))
346
+ throw new Error(`Invalid number for ${ctx}: "${raw}"`);
347
+ return n;
348
+ }
349
+ if (ISO_DATE_RE.test(s) || ISO_DATETIME_RE.test(s)) {
350
+ const t = Date.parse(s);
351
+ if (Number.isNaN(t))
352
+ throw new Error(`Invalid ISO date for ${ctx}: "${raw}"`);
353
+ return s;
354
+ }
355
+ throw new Error(`Expected number or ISO date for ${ctx}, got "${raw}"`);
356
+ }
357
+ /** Ensure $btw bounds are both numbers or both dates. */
358
+ function assertSameKind(a, b, ctx) {
359
+ const ka = typeof a === 'number' ? 'number' : 'date';
360
+ const kb = typeof b === 'number' ? 'number' : 'date';
361
+ if (ka !== kb) {
362
+ throw new Error(`$btw bounds must be same type (both number or both date) for ${ctx}`);
363
+ }
364
+ }
365
+ /** Parse a single "filter.<field>" DSL string into a Condition. */
366
+ function parseSingleCondition(raw) {
367
+ const parts = raw.split(':');
368
+ let group = ROOT_GROUP_ID;
369
+ let cursor = parts;
370
+ if (cursor[0] === '$g') {
371
+ group = IntegerStringSchema.parse((cursor[1] ?? '').trim());
372
+ cursor = cursor.slice(2);
373
+ if (cursor.length === 0) {
374
+ throw new Error(`Invalid group prefix in "${raw}" (missing condition after "$g:<id>")`);
375
+ }
376
+ }
377
+ let combinator;
378
+ if (cursor[0] === '$and' || cursor[0] === '$or') {
379
+ combinator = CombinatorSchema.parse(cursor[0]);
380
+ cursor = cursor.slice(1);
381
+ if (cursor.length === 0) {
382
+ throw new Error(`Invalid combinator in "${raw}" (missing condition after "${combinator}")`);
383
+ }
384
+ }
385
+ const hasNot = cursor[0] === '$not';
386
+ if (hasNot && !cursor[1]) {
387
+ throw new Error(`Invalid "$not" usage in "${raw}" (missing operator after "$not")`);
388
+ }
389
+ const head = hasNot ? cursor[1] : cursor[0];
390
+ const rest = hasNot ? cursor.slice(2).join(':') : cursor.slice(1).join(':');
391
+ const not = hasNot ? true : undefined;
392
+ if (!head?.startsWith('$')) {
393
+ return ConditionSchema.parse({
394
+ group,
395
+ combinator,
396
+ op: '$eq',
397
+ value: cursor.join(':'),
398
+ });
399
+ }
400
+ const op = OperatorSchema.parse(head);
401
+ if (op === '$null')
402
+ return ConditionSchema.parse({ group, combinator, op: '$null', not });
403
+ if (op === '$eq') {
404
+ let value;
405
+ try {
406
+ value = parseNumOrDateStrict(rest, '$eq');
407
+ }
408
+ catch {
409
+ value = rest;
410
+ }
411
+ return ConditionSchema.parse({ group, combinator, op: '$eq', not, value });
412
+ }
413
+ if (op === '$btw') {
414
+ const [aRaw, bRaw] = rest.split(',');
415
+ if (!aRaw || !bRaw)
416
+ throw new Error(`Invalid $btw "${raw}" (expected "$btw:a,b")`);
417
+ const a = parseNumOrDateStrict(aRaw, '$btw');
418
+ const b = parseNumOrDateStrict(bRaw, '$btw');
419
+ assertSameKind(a, b, '$btw');
420
+ return ConditionSchema.parse({ group, combinator, op: '$btw', not, value: [a, b] });
421
+ }
422
+ if (op === '$in' || op === '$contains') {
423
+ const arr = rest
424
+ .split(',')
425
+ .map((s) => s.trim())
426
+ .filter(Boolean);
427
+ return ConditionSchema.parse({ group, combinator, op, not, value: arr });
428
+ }
429
+ if (op === '$gt' || op === '$gte' || op === '$lt' || op === '$lte') {
430
+ const v = parseNumOrDateStrict(rest, op);
431
+ return ConditionSchema.parse({ group, combinator, op, not, value: v });
432
+ }
433
+ // $ilike | $sw
434
+ return ConditionSchema.parse({ group, combinator, op, not, value: rest });
435
+ }
436
+ /* ---------------------------------- */
437
+ /* Extract raw filters */
438
+ /* ---------------------------------- */
439
+ function extractAndNormalizeRawFilters(q) {
440
+ const result = {};
441
+ for (const [k, v] of Object.entries(q)) {
442
+ if (!k.startsWith('filter.'))
443
+ continue;
444
+ const field = k.slice('filter.'.length).trim();
445
+ if (!field)
446
+ continue;
447
+ const rawList = toStringArrayFromQueryString(v);
448
+ result[field] = rawList.filter(Boolean).map(parseSingleCondition);
449
+ }
450
+ return result;
451
+ }
452
+ function toQueryStringRecord(q) {
453
+ const out = {};
454
+ for (const [k, v] of Object.entries(q)) {
455
+ if (typeof v === 'string') {
456
+ out[k] = v;
457
+ continue;
458
+ }
459
+ if (Array.isArray(v) && v.every((x) => typeof x === 'string')) {
460
+ out[k] = v;
461
+ continue;
462
+ }
463
+ out[k] = undefined;
464
+ }
465
+ return out;
466
+ }
467
+ function toFilterableRuntime(filterable) {
468
+ const out = {};
469
+ if (!filterable)
470
+ return out;
471
+ for (const [k, v] of Object.entries(filterable)) {
472
+ if (!v)
473
+ continue;
474
+ out[k] = { type: v.type, ops: [...v.ops] };
475
+ }
476
+ return out;
477
+ }
478
+ /* ---------------------------------- */
479
+ /* Defaults helpers (typed, no "as") */
480
+ /* ---------------------------------- */
481
+ /**
482
+ * Find a typed AllowedPath value from a string, by matching against a typed allowlist.
483
+ * This avoids `as`: we return the existing typed value.
484
+ */
485
+ function pickFromAllowlist(allowlist, value) {
486
+ if (!allowlist)
487
+ return undefined;
488
+ for (const item of allowlist) {
489
+ if (`${item}` === value)
490
+ return item;
491
+ }
492
+ return undefined;
493
+ }
494
+ /** Expand "*" to selectable; otherwise return the same list. */
495
+ function expandSelect(select, config) {
496
+ if (!select)
497
+ return undefined;
498
+ if (!select.includes('*')) {
499
+ if (!config.selectable || config.selectable.length === 0)
500
+ return undefined;
501
+ const out = [];
502
+ for (const field of select) {
503
+ const picked = pickFromAllowlist(config.selectable, field);
504
+ if (picked)
505
+ out.push(picked);
506
+ }
507
+ return out;
508
+ }
509
+ if (config.selectable && config.selectable.length > 0)
510
+ return [...config.selectable];
511
+ return undefined;
512
+ }
513
+ function computeLimit(limit, config) {
514
+ if (typeof limit === 'number')
515
+ return limit;
516
+ if (typeof config.defaultLimit === 'number')
517
+ return config.defaultLimit;
518
+ return undefined;
519
+ }
520
+ function computeSelect(select, config) {
521
+ if (select) {
522
+ const expanded = expandSelect(select, config);
523
+ if (!expanded)
524
+ return undefined;
525
+ return [...expanded];
526
+ }
527
+ if (config.defaultSelect) {
528
+ const expanded = expandSelect(config.defaultSelect.map((x) => `${x}`), config);
529
+ if (!expanded)
530
+ return undefined;
531
+ return [...expanded];
532
+ }
533
+ return undefined;
534
+ }
535
+ /* ---------------------------------- */
536
+ /* Runtime value/type validation */
537
+ /* ---------------------------------- */
538
+ function isISODateString(v) {
539
+ if (typeof v !== 'string')
540
+ return false;
541
+ if (!(ISO_DATE_RE.test(v) || ISO_DATETIME_RE.test(v)))
542
+ return false;
543
+ return !Number.isNaN(Date.parse(v));
544
+ }
545
+ function isFiniteNumber(v) {
546
+ return typeof v === 'number' && Number.isFinite(v);
547
+ }
548
+ function validateConditionType(expected, cond, field) {
549
+ if (expected === 'any')
550
+ return null;
551
+ if (cond.op === '$null')
552
+ return null;
553
+ if (cond.op === '$eq') {
554
+ if (expected === 'number' && !isFiniteNumber(cond.value))
555
+ return `Field "${field}" expects a number for "$eq"`;
556
+ if (expected === 'date' && !isISODateString(cond.value))
557
+ return `Field "${field}" expects an ISO date for "$eq"`;
558
+ if (expected === 'string' && typeof cond.value !== 'string')
559
+ return `Field "${field}" expects a string for "$eq"`;
560
+ return null;
561
+ }
562
+ if (cond.op === '$ilike' || cond.op === '$sw') {
563
+ if (expected !== 'string')
564
+ return `Field "${field}" does not support "${cond.op}" (configured as ${expected})`;
565
+ return null;
566
+ }
567
+ if (cond.op === '$in' || cond.op === '$contains')
568
+ return null;
569
+ if (cond.op === '$gt' || cond.op === '$gte' || cond.op === '$lt' || cond.op === '$lte') {
570
+ if (expected === 'string')
571
+ return `Field "${field}" does not support "${cond.op}" (configured as string)`;
572
+ if (expected === 'number' && !isFiniteNumber(cond.value))
573
+ return `Field "${field}" expects number for "${cond.op}"`;
574
+ if (expected === 'date' && !isISODateString(cond.value))
575
+ return `Field "${field}" expects ISO date for "${cond.op}"`;
576
+ return null;
577
+ }
578
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
579
+ if (cond.op === '$btw') {
580
+ const [a, b] = cond.value;
581
+ if (expected === 'string')
582
+ return `Field "${field}" does not support "$btw" (configured as string)`;
583
+ if (expected === 'number' && (!isFiniteNumber(a) || !isFiniteNumber(b)))
584
+ return `Field "${field}" expects numbers for "$btw"`;
585
+ if (expected === 'date' && (!isISODateString(a) || !isISODateString(b)))
586
+ return `Field "${field}" expects ISO dates for "$btw"`;
587
+ return null;
588
+ }
589
+ return null;
590
+ }
591
+ function computeSortBy(sortByRaw, config) {
592
+ if (sortByRaw) {
593
+ const cleaned = sortByRaw.map((s) => s.trim()).filter(Boolean);
594
+ if (cleaned.length > 0) {
595
+ const out = [];
596
+ for (const raw of cleaned) {
597
+ const parsed = parseSortItem(raw);
598
+ const picked = pickFromAllowlist(config.sortable, parsed.property);
599
+ if (!picked)
600
+ continue;
601
+ out.push({ property: picked, direction: parsed.direction });
602
+ }
603
+ return out.length > 0 ? out : undefined;
604
+ }
605
+ }
606
+ if (config.defaultSortBy && config.defaultSortBy.length > 0) {
607
+ return config.defaultSortBy.map((x) => ({ property: x.property, direction: x.direction }));
608
+ }
609
+ return undefined;
610
+ }
611
+ function isPlainObject(v) {
612
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
613
+ }
614
+ function getOwnProp(obj, key) {
615
+ if (!Object.prototype.hasOwnProperty.call(obj, key))
616
+ return undefined;
617
+ return obj[key];
618
+ }
619
+ /** Duck-typed Zod schema check. */
620
+ function isZodSchema(v) {
621
+ if (!isPlainObject(v))
622
+ return false;
623
+ const parseFn = getOwnProp(v, 'parse');
624
+ return typeof parseFn === 'function';
625
+ }
626
+ /** Duck-typed ZodObject check. */
627
+ function isZodObjectSchema(v) {
628
+ if (!isPlainObject(v))
629
+ return false;
630
+ const parseFn = getOwnProp(v, 'parse');
631
+ if (typeof parseFn !== 'function')
632
+ return false;
633
+ const shape = getOwnProp(v, 'shape');
634
+ return isPlainObject(shape);
635
+ }
636
+ function getObjectShape(obj) {
637
+ return obj.shape;
638
+ }
639
+ function getZodAtPath(obj, path) {
640
+ const parts = path.split('.').filter(Boolean);
641
+ let current = obj;
642
+ for (const p of parts) {
643
+ if (!isZodObjectSchema(current)) {
644
+ throw new Error(`dataSchema path "${path}" is invalid: "${p}" is not inside a ZodObject`);
645
+ }
646
+ const shape = getObjectShape(current);
647
+ const next = shape[p];
648
+ if (!next)
649
+ throw new Error(`dataSchema path "${path}" is invalid: missing key "${p}"`);
650
+ if (!isZodSchema(next)) {
651
+ throw new Error(`dataSchema path "${path}" is invalid: "${p}" is not a Zod schema`);
652
+ }
653
+ current = next;
654
+ }
655
+ if (!isZodSchema(current)) {
656
+ throw new Error(`dataSchema path "${path}" is invalid: resolved value is not a Zod schema`);
657
+ }
658
+ return current;
659
+ }
660
+ function projectDataSchema(dataSchema, selectedPaths) {
661
+ const tree = {};
662
+ const ensureTreeNode = (node, key) => {
663
+ const existing = node[key];
664
+ if (existing === undefined) {
665
+ const child = {};
666
+ node[key] = child;
667
+ return child;
668
+ }
669
+ if (isPlainObject(existing))
670
+ return existing;
671
+ if (isZodSchema(existing)) {
672
+ throw new Error(`Cannot project "${key}": "${key}" is selected as a leaf and as an object`);
673
+ }
674
+ throw new Error(`Cannot project "${key}": conflicting selection`);
675
+ };
676
+ for (const fullPath of selectedPaths) {
677
+ const parts = fullPath.split('.').filter(Boolean);
678
+ if (parts.length === 0)
679
+ continue;
680
+ let cursor = tree;
681
+ for (let i = 0; i < parts.length; i += 1) {
682
+ const key = parts[i];
683
+ if (!key)
684
+ continue;
685
+ const isLeaf = i === parts.length - 1;
686
+ if (isLeaf) {
687
+ cursor[key] = getZodAtPath(dataSchema, fullPath);
688
+ }
689
+ else {
690
+ cursor = ensureTreeNode(cursor, key);
691
+ }
692
+ }
693
+ }
694
+ const buildObjectFromTree = (node) => {
695
+ const shape = {};
696
+ for (const [k, v] of Object.entries(node)) {
697
+ if (isZodSchema(v)) {
698
+ shape[k] = v;
699
+ continue;
700
+ }
701
+ if (isPlainObject(v)) {
702
+ shape[k] = buildObjectFromTree(v);
703
+ continue;
704
+ }
705
+ throw new Error(`Invalid projection tree at "${k}"`);
706
+ }
707
+ return zod_1.z.object(shape);
708
+ };
709
+ return buildObjectFromTree(tree);
710
+ }
711
+ function callMethodIfReturnsZod(obj, methodName) {
712
+ if (!isPlainObject(obj))
713
+ return undefined;
714
+ const maybeFn = getOwnProp(obj, methodName);
715
+ if (typeof maybeFn !== 'function')
716
+ return undefined;
717
+ const result = maybeFn.call(obj);
718
+ if (isZodSchema(result))
719
+ return result;
720
+ return undefined;
721
+ }
722
+ function getInnerSchemaFromDef(obj) {
723
+ if (!isPlainObject(obj))
724
+ return undefined;
725
+ const def = getOwnProp(obj, 'def') ?? getOwnProp(obj, '_def');
726
+ if (!isPlainObject(def))
727
+ return undefined;
728
+ const candidates = ['innerType', 'schema', 'type', 'in', 'out'];
729
+ for (const key of candidates) {
730
+ const v = getOwnProp(def, key);
731
+ if (isZodSchema(v))
732
+ return v;
733
+ }
734
+ return undefined;
735
+ }
736
+ function unwrapSchema(schema) {
737
+ let current = schema;
738
+ for (let i = 0; i < 30; i += 1) {
739
+ const unwrapped = callMethodIfReturnsZod(current, 'unwrap');
740
+ if (unwrapped) {
741
+ current = unwrapped;
742
+ continue;
743
+ }
744
+ const removedDefault = callMethodIfReturnsZod(current, 'removeDefault');
745
+ if (removedDefault) {
746
+ current = removedDefault;
747
+ continue;
748
+ }
749
+ const innerType = callMethodIfReturnsZod(current, 'innerType');
750
+ if (innerType) {
751
+ current = innerType;
752
+ continue;
753
+ }
754
+ const sourceType = callMethodIfReturnsZod(current, 'sourceType');
755
+ if (sourceType) {
756
+ current = sourceType;
757
+ continue;
758
+ }
759
+ const innerFromDef = getInnerSchemaFromDef(current);
760
+ if (innerFromDef) {
761
+ current = innerFromDef;
762
+ continue;
763
+ }
764
+ break;
765
+ }
766
+ if (isZodSchema(current))
767
+ return current;
768
+ return schema;
769
+ }
770
+ /**
771
+ * Robust constructor name getter that works with Zod objects (constructor is on the prototype).
772
+ * No `as`, no unsafe casts.
773
+ */
774
+ function getConstructorName(v) {
775
+ if (typeof v !== 'object' || v === null)
776
+ return undefined;
777
+ const proto = Object.getPrototypeOf(v);
778
+ if (typeof proto !== 'object' || proto === null)
779
+ return undefined;
780
+ const ctorUnknown = Reflect.get(proto, 'constructor');
781
+ if (!(ctorUnknown instanceof Function))
782
+ return undefined;
783
+ return ctorUnknown.name;
784
+ }
785
+ /* ---------------------------------- */
786
+ /* Cursor: schema inference + coercion */
787
+ /* ---------------------------------- */
788
+ /**
789
+ * Return the expected cursor schema for API responses:
790
+ * - number field => cursor: number
791
+ * - string field => cursor: string
792
+ * - date field => cursor: ISO string OR Date (optional support)
793
+ */
794
+ function cursorSchemaFromProperty(dataSchema, cursorProperty) {
795
+ const raw = getZodAtPath(dataSchema, `${cursorProperty}`);
796
+ const s = unwrapSchema(raw);
797
+ const ctorName = getConstructorName(s);
798
+ if (ctorName === 'ZodNumber')
799
+ return zod_1.z.number();
800
+ if (ctorName === 'ZodString')
801
+ return zod_1.z.string();
802
+ if (ctorName === 'ZodDate')
803
+ return zod_1.z.union([zod_1.z.string().refine(isISODateString), zod_1.z.date()]);
804
+ // Unsupported cursor field type
805
+ return zod_1.z.never();
806
+ }
807
+ /**
808
+ * Coerce the query input cursor (always string) into the right type based on cursorProperty.
809
+ * - number field => "123" -> 123
810
+ * - string field => "abc" -> "abc"
811
+ * - date field => "2022-01-01" -> "2022-01-01" (validated as ISO)
812
+ */
813
+ function coerceCursorFromProperty(dataSchema, cursorProperty, rawCursor) {
814
+ const schemaAtPath = unwrapSchema(getZodAtPath(dataSchema, `${cursorProperty}`));
815
+ const ctorName = getConstructorName(schemaAtPath);
816
+ if (ctorName === 'ZodNumber') {
817
+ const s = rawCursor.trim();
818
+ if (!/^[+-]?\d+$/.test(s))
819
+ throw new Error(`cursor must be an integer string`);
820
+ const n = Number(s);
821
+ if (!Number.isFinite(n))
822
+ throw new Error(`cursor must be a finite number`);
823
+ return n;
824
+ }
825
+ if (ctorName === 'ZodString') {
826
+ return rawCursor;
827
+ }
828
+ if (ctorName === 'ZodDate') {
829
+ const s = rawCursor.trim();
830
+ if (!isISODateString(s))
831
+ throw new Error(`cursor must be an ISO date string`);
832
+ return s;
833
+ }
834
+ throw new Error(`cursorProperty "${cursorProperty}" must be a string|number|date`);
835
+ }
836
+ /* ---------------------------------- */
837
+ /* Factory */
838
+ /* ---------------------------------- */
839
+ /**
840
+ * Generate Zod schemas and runtime validators for pagination query parameters, based on a config object.
841
+ * @param config The configuration object defining the pagination behavior and allowed fields.
842
+ * @returns An object containing:
843
+ * - `queryParamsSchema`: A Zod schema for validating and parsing the raw query parameters.
844
+ * - `validatorSchema`: A function that takes the already-parsed query parameters and returns a Zod schema for further validation (e.g. filters).
845
+ */
846
+ function paginate(config) {
847
+ const allowedSelectable = new Set();
848
+ for (const f of config.selectable ?? [])
849
+ allowedSelectable.add(`${f}`);
850
+ const allowedSortable = new Set();
851
+ for (const f of config.sortable ?? [])
852
+ allowedSortable.add(`${f}`);
853
+ const filterable = toFilterableRuntime(config.filterable);
854
+ const baseSchema = zod_1.z.object({
855
+ limit: NumericStringSchema.optional(),
856
+ page: NumericStringSchema.optional(),
857
+ /**
858
+ * Query input is always a string if present.
859
+ * We will coerce it later in the final transform (CURSOR mode only).
860
+ */
861
+ cursor: zod_1.z.string().min(1).optional(),
862
+ sortBy: StringOrStringArraySchema.optional(),
863
+ select: SelectCsvSchema.optional(),
864
+ rawFilters: zod_1.z.record(zod_1.z.string(), zod_1.z.array(ConditionSchema)),
865
+ groupDefs: zod_1.z.record(zod_1.z.string(), zod_1.z.object({
866
+ parent: IntegerStringSchema.optional(),
867
+ join: CombinatorSchema.optional(),
868
+ op: CombinatorSchema.optional(),
869
+ })),
870
+ });
871
+ const queryParamsSchema = zod_1.z
872
+ .record(zod_1.z.string(), zod_1.z.unknown())
873
+ .transform((q) => {
874
+ const qs = toQueryStringRecord(q);
875
+ return {
876
+ ...q,
877
+ rawFilters: extractAndNormalizeRawFilters(qs),
878
+ groupDefs: extractGroupDefs(q),
879
+ };
880
+ })
881
+ .pipe(baseSchema
882
+ .superRefine((val, ctx) => {
883
+ // Pagination mode constraints
884
+ if (config.paginationType === 'LIMIT_OFFSET') {
885
+ if (val.cursor !== undefined) {
886
+ ctx.addIssue({
887
+ code: 'custom',
888
+ path: ['cursor'],
889
+ message: `cursor is not allowed when paginationType is LIMIT_OFFSET`,
890
+ });
891
+ }
892
+ }
893
+ if (config.paginationType === 'CURSOR') {
894
+ if (val.page !== undefined) {
895
+ ctx.addIssue({
896
+ code: 'custom',
897
+ path: ['page'],
898
+ message: `page is not allowed when paginationType is CURSOR`,
899
+ });
900
+ }
901
+ if (`${config.cursorProperty}`.trim().length === 0) {
902
+ ctx.addIssue({
903
+ code: 'custom',
904
+ path: [],
905
+ message: `cursorProperty must be a non-empty string when paginationType is CURSOR`,
906
+ });
907
+ }
908
+ // Validate that cursor (if provided) can be coerced for that cursorProperty
909
+ if (val.cursor !== undefined) {
910
+ try {
911
+ void coerceCursorFromProperty(config.dataSchema, config.cursorProperty, val.cursor);
912
+ }
913
+ catch (e) {
914
+ const message = e instanceof Error ? e.message : 'Invalid cursor';
915
+ ctx.addIssue({
916
+ code: 'custom',
917
+ path: ['cursor'],
918
+ message,
919
+ });
920
+ }
921
+ }
922
+ }
923
+ // limit / maxLimit
924
+ if (typeof val.limit === 'number' &&
925
+ typeof config.maxLimit === 'number' &&
926
+ val.limit > config.maxLimit) {
927
+ ctx.addIssue({
928
+ code: 'custom',
929
+ path: ['limit'],
930
+ message: `limit must be <= ${config.maxLimit}`,
931
+ });
932
+ }
933
+ // select forbidden if no selectable configured
934
+ if (val.select && (!config.selectable || config.selectable.length === 0)) {
935
+ ctx.addIssue({
936
+ code: 'custom',
937
+ path: ['select'],
938
+ message: `select is not allowed (no selectable fields configured)`,
939
+ });
940
+ }
941
+ // select allowlist + "*" expandability
942
+ const selectForValidation = val.select ??
943
+ (config.defaultSelect ? config.defaultSelect.map((x) => `${x}`) : undefined);
944
+ if (selectForValidation) {
945
+ let index = 0;
946
+ for (const field of selectForValidation) {
947
+ if (field === '*') {
948
+ index += 1;
949
+ continue;
950
+ }
951
+ if (allowedSelectable.size > 0 && !allowedSelectable.has(field)) {
952
+ ctx.addIssue({
953
+ code: 'custom',
954
+ path: ['select', index],
955
+ message: `select field "${field}" is not allowed`,
956
+ });
957
+ }
958
+ index += 1;
959
+ }
960
+ if (selectForValidation.includes('*')) {
961
+ const expanded = expandSelect(selectForValidation, config);
962
+ if (!expanded || expanded.length === 0) {
963
+ ctx.addIssue({
964
+ code: 'custom',
965
+ path: ['select'],
966
+ message: `select "*" cannot be expanded (missing selectable in config)`,
967
+ });
968
+ }
969
+ }
970
+ }
971
+ // sort allowlist
972
+ const sortItems = computeSortBy(val.sortBy, config);
973
+ if (val.sortBy) {
974
+ if (!config.sortable || config.sortable.length === 0) {
975
+ ctx.addIssue({
976
+ code: 'custom',
977
+ path: ['sortBy'],
978
+ message: `sortBy is not allowed (no sortable fields configured)`,
979
+ });
980
+ }
981
+ else if (sortItems) {
982
+ let index = 0;
983
+ for (const item of sortItems) {
984
+ if (!allowedSortable.has(`${item.property}`)) {
985
+ ctx.addIssue({
986
+ code: 'custom',
987
+ path: ['sortBy', index],
988
+ message: `sort property "${item.property}" is not allowed`,
989
+ });
990
+ }
991
+ index += 1;
992
+ }
993
+ }
994
+ }
995
+ // filter allowlist + operator/type validation
996
+ for (const [field, conditions] of Object.entries(val.rawFilters)) {
997
+ const cfg = filterable[field];
998
+ if (!cfg) {
999
+ ctx.addIssue({
1000
+ code: 'custom',
1001
+ path: ['rawFilters', field],
1002
+ message: `filter field "${field}" is not allowed`,
1003
+ });
1004
+ continue;
1005
+ }
1006
+ const allowedOps = new Set(cfg.ops);
1007
+ let index = 0;
1008
+ for (const cond of conditions) {
1009
+ if (!allowedOps.has(cond.op)) {
1010
+ ctx.addIssue({
1011
+ code: 'custom',
1012
+ path: ['rawFilters', field, index, 'op'],
1013
+ message: `operator "${cond.op}" is not allowed for "${field}"`,
1014
+ });
1015
+ }
1016
+ const typeError = validateConditionType(cfg.type, cond, field);
1017
+ if (typeError) {
1018
+ ctx.addIssue({
1019
+ code: 'custom',
1020
+ path: ['rawFilters', field, index],
1021
+ message: typeError,
1022
+ });
1023
+ }
1024
+ index += 1;
1025
+ }
1026
+ }
1027
+ // group consistency
1028
+ const hasAnyFilter = Object.keys(val.rawFilters).length > 0;
1029
+ const hasAnyGroupDef = Object.keys(val.groupDefs).length > 0;
1030
+ if (hasAnyGroupDef && !hasAnyFilter) {
1031
+ ctx.addIssue({
1032
+ code: 'custom',
1033
+ path: ['groupDefs'],
1034
+ message: `group.* is not allowed without any filter.*`,
1035
+ });
1036
+ }
1037
+ else if (hasAnyFilter) {
1038
+ try {
1039
+ void buildWhereAstWithGroups(val.rawFilters, val.groupDefs);
1040
+ }
1041
+ catch (e) {
1042
+ const message = e instanceof Error ? e.message : 'Invalid group configuration';
1043
+ ctx.addIssue({
1044
+ code: 'custom',
1045
+ path: ['groupDefs'],
1046
+ message,
1047
+ });
1048
+ }
1049
+ }
1050
+ })
1051
+ .transform((val) => {
1052
+ const limit = computeLimit(val.limit, config);
1053
+ const sortBy = computeSortBy(val.sortBy, config);
1054
+ const select = computeSelect(val.select, config);
1055
+ const hasAnyFilter = Object.keys(val.rawFilters).length > 0;
1056
+ const maybeFilters = hasAnyFilter
1057
+ ? { filters: buildWhereAstWithGroups(val.rawFilters, val.groupDefs) }
1058
+ : {};
1059
+ if (config.paginationType === 'LIMIT_OFFSET') {
1060
+ return {
1061
+ pagination: {
1062
+ type: 'LIMIT_OFFSET',
1063
+ limit,
1064
+ page: val.page,
1065
+ sortBy,
1066
+ select,
1067
+ ...maybeFilters,
1068
+ },
1069
+ };
1070
+ }
1071
+ // CURSOR: coerce string cursor into number/string based on cursorProperty
1072
+ let cursor = undefined;
1073
+ if (val.cursor !== undefined) {
1074
+ cursor = coerceCursorFromProperty(config.dataSchema, config.cursorProperty, val.cursor);
1075
+ }
1076
+ return {
1077
+ pagination: {
1078
+ type: 'CURSOR',
1079
+ limit,
1080
+ cursor,
1081
+ cursorProperty: config.cursorProperty,
1082
+ sortBy,
1083
+ select,
1084
+ ...maybeFilters,
1085
+ },
1086
+ };
1087
+ }));
1088
+ const validatorSchema = (parsed) => {
1089
+ const effectiveSelect = parsed?.pagination.select ?? computeSelect(undefined, config) ?? undefined;
1090
+ const dataItemSchema = effectiveSelect && effectiveSelect.length > 0
1091
+ ? projectDataSchema(config.dataSchema, effectiveSelect.map((x) => `${x}`))
1092
+ : config.dataSchema;
1093
+ const dataArraySchema = zod_1.z.array(dataItemSchema);
1094
+ if (config.paginationType === 'LIMIT_OFFSET') {
1095
+ return zod_1.z.object({
1096
+ data: dataArraySchema,
1097
+ pagination: zod_1.z.object({
1098
+ itemsPerPage: zod_1.z.number(),
1099
+ totalItems: zod_1.z.number(),
1100
+ currentPage: zod_1.z.number(),
1101
+ totalPages: zod_1.z.number(),
1102
+ sortBy: zod_1.z
1103
+ .array(zod_1.z.object({
1104
+ property: zod_1.z.string(),
1105
+ direction: SortDirectionSchema,
1106
+ }))
1107
+ .optional(),
1108
+ filter: WhereNodeSchema.optional(),
1109
+ }),
1110
+ });
1111
+ }
1112
+ const cursorType = cursorSchemaFromProperty(config.dataSchema, config.cursorProperty);
1113
+ return zod_1.z.object({
1114
+ data: dataArraySchema,
1115
+ pagination: zod_1.z.object({
1116
+ itemsPerPage: zod_1.z.number(),
1117
+ cursor: cursorType,
1118
+ sortBy: zod_1.z
1119
+ .array(zod_1.z.object({
1120
+ property: zod_1.z.string(),
1121
+ direction: SortDirectionSchema,
1122
+ }))
1123
+ .optional(),
1124
+ filter: WhereNodeSchema.optional(),
1125
+ }),
1126
+ });
1127
+ };
1128
+ return { queryParamsSchema, validatorSchema };
1129
+ }
1130
+ //# sourceMappingURL=main.js.map