orchid-pagination 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,6 +41,14 @@ defineEventHandler(async (ctx) => {
41
41
 
42
42
  The page has `{ items, page, limit, offset, prevPage?, nextPage? }`.
43
43
 
44
+ ### No total count by default
45
+
46
+ By default, this library does not run `COUNT(*)` queries, keeping pagination fast and lightweight.
47
+ If a requested page is beyond the last page, the result contains an empty `items` array and keeps the requested page number unchanged.
48
+
49
+ Set `total: true` to include `totalItems` and `totalPages` in the response.
50
+ Pages beyond the last page are still left unchanged; to clamp them to the last available page, set `clampPage: true` together with `total: true`.
51
+
44
52
  ## Cursor pagination
45
53
 
46
54
  ```ts
@@ -75,7 +83,9 @@ Cursor queries must be ordered.
75
83
  Include a deterministic tie-breaker, usually `id`.
76
84
  Treat cursors as opaque strings and pass them back unchanged.
77
85
 
78
- You can order by selected relation fields and aliases as long as the ordered value is present in the query result:
86
+ ### Aliases and relations
87
+
88
+ You can order by selected aliases or by relation paths:
79
89
 
80
90
  ```ts
81
91
  const page = await paginateByCursor(
@@ -88,10 +98,26 @@ const page = await paginateByCursor(
88
98
  )
89
99
  ```
90
100
 
101
+ ```ts
102
+ const page = await paginateByCursor(
103
+ db.post
104
+ .select("id", "text", {
105
+ author: q => q.author.select("id", "name"),
106
+ })
107
+ .order("author.name", { id: "DESC" }),
108
+ { limit: 10 },
109
+ )
110
+ ```
111
+
91
112
  ## Pagination config
92
113
 
93
- - `limit`: default limit.
94
- - `maxLimit`: max accepted client `limit`.
95
- - Client `limit` is ignored unless `maxLimit` is set.
96
- - With only `maxLimit`, client `limit` is required.
97
- - Without config, query `.limit(...)` is required.
114
+ - `limit`: default page size.
115
+ - `maxLimit`: maximum accepted client-provided `limit`.
116
+ - Client-provided `limit` is only used when `maxLimit` is set.
117
+ - If `maxLimit` is set without `limit`, client-provided `limit` is required.
118
+ - If no config is provided, the query must already have `.limit(...)`.
119
+
120
+ ### Page number pagination
121
+
122
+ - `total`: run a `COUNT(*)` query and include `totalItems` / `totalPages` in the response.
123
+ - `clampPage`: clamp pages beyond the last page to the last available page. Requires `total: true`.
package/dist/index.cjs CHANGED
@@ -122,7 +122,11 @@ async function paginateByCursor(query, config, params) {
122
122
  }
123
123
  function createItemCursor(item, reverse2) {
124
124
  return createDirectedCursor(orderFields.map(([field]) => {
125
- return String(getItemValue(item, field));
125
+ const value = getItemValue(item, field);
126
+ if (value === void 0) {
127
+ throw new Error(`Order field "${field}" is missing from the result \u2014 cursor pagination requires every order field to be selected.`);
128
+ }
129
+ return String(value);
126
130
  }), reverse2);
127
131
  }
128
132
  const prevCursor = parsedCursor && (parsedCursor.reverse === false || hasContinuation) ? createItemCursor(items[0], true) : void 0;
@@ -148,19 +152,32 @@ function createCursorPaginator(config) {
148
152
  // src/paginators/page/paginator.ts
149
153
  async function paginateByPage(query, config, params) {
150
154
  const limit = getLimit(query, config, params);
151
- const page = Math.max(1, params?.page ?? 1);
155
+ let page = Math.max(1, params?.page ?? 1);
156
+ let total;
157
+ if (config?.total) {
158
+ const totalItems = await query.clear("select", "order").count();
159
+ const totalPages = Math.max(1, Math.ceil(totalItems / limit));
160
+ total = { totalItems, totalPages };
161
+ if (config.clampPage) {
162
+ page = Math.min(page, totalPages);
163
+ }
164
+ }
152
165
  const offset = (page - 1) * limit;
153
- const items = await query.offset(offset).limit(limit + 1);
166
+ const items = await query.offset(offset).limit(total ? limit : limit + 1);
154
167
  if (!Array.isArray(items)) {
155
168
  throw new TypeError("Query must return an array.");
156
169
  }
157
- const hasContinuation = items.length > limit;
158
- if (hasContinuation) {
170
+ const hasContinuation = total ? page < total.totalPages : items.length > limit;
171
+ if (items.length > limit) {
159
172
  items.splice(limit);
160
173
  }
161
174
  const prevPage = page > 1 ? page - 1 : void 0;
162
175
  const nextPage = hasContinuation ? page + 1 : void 0;
163
- return { items, page, limit, offset, prevPage, nextPage };
176
+ const result = { items, page, limit, offset, prevPage, nextPage };
177
+ if (total) {
178
+ return { ...result, ...total };
179
+ }
180
+ return result;
164
181
  }
165
182
 
166
183
  // src/paginators/page/factory.ts
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/limit.ts","../src/paginators/cursor/cursor.ts","../src/paginators/cursor/order.ts","../src/paginators/cursor/paginator.ts","../src/paginators/cursor/factory.ts","../src/paginators/page/paginator.ts","../src/paginators/page/factory.ts"],"sourcesContent":["export * from \"./limit\"\nexport * from \"./paginators/cursor\"\nexport * from \"./paginators/page\"\nexport * from \"./types\"\n","import type { ListQuery } from \"./types\"\n\nexport interface PaginationConfig {\n /** Default limit. If `maxLimit` is not set, client params limit is ignored. */\n limit?: number\n /** Max limit allowed to be passed in params. */\n maxLimit?: number\n}\n\nexport interface PaginationParams {\n /** Requested limit. */\n limit?: number\n}\n\n/** getLimit returns the effective limit for a query and pagination parameters. */\nexport function getLimit(query: ListQuery, config?: PaginationConfig, params?: PaginationParams): number {\n if (config) {\n const limit = config.maxLimit !== undefined ? (params?.limit ?? config.limit) : config.limit\n if (limit === undefined) {\n throw new Error(\"Set config.limit or params.limit with config.maxLimit.\")\n }\n\n return Math.max(1, Math.min(limit, config.maxLimit ?? limit))\n }\n\n const queryLimit = query.q.limit\n if (!queryLimit) {\n throw new Error(\"Set query limit or config.limit.\")\n }\n\n return Math.max(1, queryLimit)\n}\n","import { Buffer } from \"node:buffer\"\n\n/** createCursor encodes cursor parts into an opaque cursor string. */\nexport function createCursor(parts: string[]) {\n return Buffer.from(parts.map(String).join(String.fromCharCode(0))).toString(\"base64url\")\n}\n\n/** parseCursor decodes an opaque cursor string into cursor parts. */\nexport function parseCursor(cursor: string) {\n return Buffer.from(cursor, \"base64url\").toString().split(String.fromCharCode(0))\n}\n\n/** createDirectedCursor encodes cursor parts and pagination direction into an opaque cursor string. */\nexport function createDirectedCursor(parts: string[], reverse: boolean) {\n const cursor = createCursor(parts)\n return reverse ? \"-\" + cursor : cursor\n}\n\n/** parseDirectedCursor decodes an opaque directed cursor string into cursor parts and pagination direction. */\nexport function parseDirectedCursor(directedCursor: string) {\n const [cursor, reverse] = directedCursor.startsWith(\"-\") ? [directedCursor.slice(1), true] : [directedCursor, false]\n return { cursor, parts: parseCursor(cursor), reverse }\n}\n","import { Buffer } from \"node:buffer\"\n\nimport type { ListQuery } from \"../../types\"\n\ntype OrderField = [field: string, asc: boolean]\n\n/** getQueryOrderFields returns ordered query fields as field and ascending-direction pairs. */\nexport function getQueryOrderFields(query: ListQuery): OrderField[] {\n const orderFields = query.q.order?.flatMap<[field: string, asc: boolean]>((orderItem) => {\n if (typeof orderItem === \"string\") {\n return [[orderItem, true]]\n } else if (typeof orderItem === \"object\") {\n return Object.entries(orderItem).map<[string, boolean]>(([field, order]) => {\n if (order === \"ASC\" || order === \"DESC\") {\n return [field, order === \"ASC\"]\n } else {\n throw new Error(\"Unsupported order: \" + order)\n }\n })\n } else {\n throw new TypeError(\"Unsupported order type: \" + orderItem)\n }\n })\n if (!orderFields?.length) {\n throw new Error(\"Query must be ordered.\")\n }\n return orderFields\n}\n\n/** createCursor encodes cursor parts into an opaque cursor string. */\nexport function createCursor(parts: string[]) {\n return Buffer.from(parts.map(String).join(String.fromCharCode(0))).toString(\"base64url\")\n}\n\n/** parseCursor decodes an opaque cursor string into cursor parts. */\nexport function parseCursor(cursor: string) {\n return Buffer.from(cursor, \"base64url\").toString().split(String.fromCharCode(0))\n}\n\n/** createDirectedCursor encodes cursor parts and pagination direction into an opaque cursor string. */\nexport function createDirectedCursor(parts: string[], reverse: boolean) {\n const cursor = createCursor(parts)\n return reverse ? \"-\" + cursor : cursor\n}\n\n/** parseDirectedCursor decodes an opaque directed cursor string into cursor parts and pagination direction. */\nexport function parseDirectedCursor(directedCursor: string) {\n const [cursor, reverse] = directedCursor.startsWith(\"-\") ? [directedCursor.slice(1), true] : [directedCursor, false]\n return { cursor, parts: parseCursor(cursor), reverse }\n}\n","import { getLimit, type PaginationConfig } from \"../../limit\"\nimport type { ListQuery, SortDir } from \"../../types\"\n\nimport { createDirectedCursor, parseDirectedCursor } from \"./cursor\"\nimport { getQueryOrderFields } from \"./order\"\n\nexport interface CursorPaginationParams {\n /** Page cursor, as returned by previous call in prevCursor / nextCursor. */\n cursor?: string\n /** Limit. */\n limit?: number\n}\n\nexport type CursorPaginationPage<T extends ListQuery = ListQuery> = {\n items: Awaited<T>\n /** Effective limit. Number of items is guaranteed to be less or equal. */\n limit: number\n /** Cursor pointing to previous page. */\n prevCursor?: string\n /** Cursor pointing to next page. */\n nextCursor?: string\n}\n\n/** paginateByCursor returns one page of results using cursor-based pagination. */\nexport async function paginateByCursor<T extends ListQuery>(query: T, config?: PaginationConfig, params?: CursorPaginationParams): Promise<CursorPaginationPage<T>> {\n const limit = getLimit(query, config, params)\n\n const orderFields = getQueryOrderFields(query)\n\n // poor man validation, TODO improve\n const parsedCursorMaybeValid = params?.cursor ? parseDirectedCursor(params.cursor) : undefined\n const parsedCursor = parsedCursorMaybeValid && parsedCursorMaybeValid.parts.length >= orderFields.length ? parsedCursorMaybeValid : undefined\n\n const reverse = parsedCursor?.reverse ?? false\n\n if (reverse) {\n // Reverse parsed order fields + reverse query ordering.\n orderFields.forEach((of) => {\n of[1] = !of[1]\n })\n const orderArg = Object.fromEntries(orderFields.map<[string, SortDir]>(([field, asc]) => [field, asc ? \"ASC\" : \"DESC\"]))\n query = query.clear(\"order\").order(orderArg as any)\n }\n\n if (parsedCursor) {\n // Prepare raw SQL.\n // For example, for (amount ASC, id DESC) ordering, that would be:\n // (amount, $id) >= ($amount, id)\n const leftRawSql = orderFields.map(([field, asc], i) => asc ? query.ref(field).toSQL() : `$value${i}`).join(\",\")\n const rightRawSql = orderFields.map(([field, asc], i) => !asc ? query.ref(field).toSQL() : `$value${i}`).join(\",\")\n const rawSql = `(${leftRawSql}) > (${rightRawSql})`\n const rawSqlValues = Object.fromEntries(\n orderFields.map((_field, i) => [`value${i}`, parsedCursor.parts[i]]),\n )\n const sqlExpr = query.qb.sql({ raw: rawSql, values: rawSqlValues })\n // query.where doesn't like low-level RawSql objects, cast to any to silence\n query = query.where(sqlExpr as any)\n }\n\n // Query 1 extra item to see if we can paginate farther in current direction.\n const items = await (query as ListQuery).limit(limit + 1) as Awaited<T>\n if (!Array.isArray(items)) {\n throw new TypeError(\"Query must return an array.\")\n }\n const hasContinuation = items.length > limit\n if (hasContinuation) {\n items.splice(limit)\n }\n if (reverse) {\n items.reverse()\n }\n\n function createItemCursor(item: any, reverse: boolean) {\n return createDirectedCursor(orderFields.map(([field]) => {\n // Can add custom serializer here if needed.\n return String(getItemValue(item, field))\n }), reverse)\n }\n\n // Prev cursor:\n // - for initial pagination, there is no prev page\n // - for forward pagination, prev page exists always\n // - for reverse pagination, prev page exists if we have a continuation\n const prevCursor = (parsedCursor && (parsedCursor.reverse === false || hasContinuation))\n ? createItemCursor(items[0], true)\n : undefined\n\n // Next cursor:\n // - for reverse pagination, next page exists always\n // - for initial or forward pagination, next page exists if we have a continuation\n const nextCursor = (parsedCursor?.reverse === true || hasContinuation)\n ? createItemCursor(items.at(-1), false)\n : undefined\n\n return { items, limit, prevCursor, nextCursor }\n}\n\n/** getItemValue returns an item's field value, resolving dot-notation paths against nested objects. */\nfunction getItemValue(item: unknown, field: string): unknown {\n if (!field.includes(\".\")) {\n return (item as Record<string, unknown>)[field]\n }\n\n return field.split(\".\").reduce<unknown>((obj, key) => {\n return obj == null ? undefined : (obj as Record<string, unknown>)[key]\n }, item)\n}\n","import type { PaginationConfig } from \"../../limit\"\nimport type { ListQuery } from \"../../types\"\n\nimport { type CursorPaginationParams, paginateByCursor } from \"./paginator\"\n\n/** createCursorPaginator creates a reusable cursor paginator with the given config. */\nexport function createCursorPaginator(config?: PaginationConfig) {\n return function paginate<T extends ListQuery>(query: T, params?: CursorPaginationParams) {\n return paginateByCursor(query, config, params)\n }\n}\n","import { getLimit, type PaginationConfig } from \"../../limit\"\nimport type { ListQuery } from \"../../types\"\n\nexport interface PagePaginationParams {\n /** Page, 1-based. */\n page?: number\n /** Limit. */\n limit?: number\n}\n\nexport type PagePaginationPage<T extends ListQuery = ListQuery> = {\n items: Awaited<T>\n /** Effective page number. */\n page: number\n /** Effective limit. Number of items is guaranteed to be less or equal. */\n limit: number\n /** Offset of the first item, 1-based. */\n offset: number\n /** Prev page number (if exists). */\n prevPage?: number\n /** Next page number (if exists). */\n nextPage?: number\n}\n\n/** paginateByPage returns one page of results using offset-based pagination. */\nexport async function paginateByPage<T extends ListQuery>(query: T, config?: PaginationConfig, params?: PagePaginationParams): Promise<PagePaginationPage<T>> {\n const limit = getLimit(query, config, params)\n\n const page = Math.max(1, params?.page ?? 1)\n const offset = (page - 1) * limit\n\n const items = await (query as ListQuery).offset(offset).limit(limit + 1) as Awaited<T>\n if (!Array.isArray(items)) {\n throw new TypeError(\"Query must return an array.\")\n }\n const hasContinuation = items.length > limit\n if (hasContinuation) {\n items.splice(limit)\n }\n\n const prevPage = page > 1 ? page - 1 : undefined\n const nextPage = hasContinuation ? page + 1 : undefined\n\n return { items, page, limit, offset, prevPage, nextPage }\n}\n","import type { PaginationConfig } from \"../../limit\"\nimport type { ListQuery } from \"../../types\"\n\nimport { type PagePaginationParams, paginateByPage } from \"./paginator\"\n\n/** createPagePaginator creates a reusable page paginator with the given config. */\nexport function createPagePaginator(config?: PaginationConfig) {\n return function paginate<T extends ListQuery>(query: T, params?: PagePaginationParams) {\n return paginateByPage(query, config, params)\n }\n}\n"],"mappings":"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","names":["import_node_buffer","reverse"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/limit.ts","../src/paginators/cursor/cursor.ts","../src/paginators/cursor/order.ts","../src/paginators/cursor/paginator.ts","../src/paginators/cursor/factory.ts","../src/paginators/page/paginator.ts","../src/paginators/page/factory.ts"],"sourcesContent":["export * from \"./limit\"\nexport * from \"./paginators/cursor\"\nexport * from \"./paginators/page\"\nexport * from \"./types\"\n","import type { ListQuery } from \"./types\"\n\nexport interface PaginationConfig {\n /** Default limit. If `maxLimit` is not set, client params limit is ignored. */\n limit?: number\n /** Max limit allowed to be passed in params. */\n maxLimit?: number\n}\n\nexport interface PaginationParams {\n /** Requested limit. */\n limit?: number\n}\n\n/** getLimit returns the effective limit for a query and pagination parameters. */\nexport function getLimit(query: ListQuery, config?: PaginationConfig, params?: PaginationParams): number {\n if (config) {\n const limit = config.maxLimit !== undefined ? (params?.limit ?? config.limit) : config.limit\n if (limit === undefined) {\n throw new Error(\"Set config.limit or params.limit with config.maxLimit.\")\n }\n\n return Math.max(1, Math.min(limit, config.maxLimit ?? limit))\n }\n\n const queryLimit = query.q.limit\n if (!queryLimit) {\n throw new Error(\"Set query limit or config.limit.\")\n }\n\n return Math.max(1, queryLimit)\n}\n","import { Buffer } from \"node:buffer\"\n\n/** createCursor encodes cursor parts into an opaque cursor string. */\nexport function createCursor(parts: string[]) {\n return Buffer.from(parts.map(String).join(String.fromCharCode(0))).toString(\"base64url\")\n}\n\n/** parseCursor decodes an opaque cursor string into cursor parts. */\nexport function parseCursor(cursor: string) {\n return Buffer.from(cursor, \"base64url\").toString().split(String.fromCharCode(0))\n}\n\n/** createDirectedCursor encodes cursor parts and pagination direction into an opaque cursor string. */\nexport function createDirectedCursor(parts: string[], reverse: boolean) {\n const cursor = createCursor(parts)\n return reverse ? \"-\" + cursor : cursor\n}\n\n/** parseDirectedCursor decodes an opaque directed cursor string into cursor parts and pagination direction. */\nexport function parseDirectedCursor(directedCursor: string) {\n const [cursor, reverse] = directedCursor.startsWith(\"-\") ? [directedCursor.slice(1), true] : [directedCursor, false]\n return { cursor, parts: parseCursor(cursor), reverse }\n}\n","import { Buffer } from \"node:buffer\"\n\nimport type { ListQuery } from \"../../types\"\n\ntype OrderField = [field: string, asc: boolean]\n\n/** getQueryOrderFields returns ordered query fields as field and ascending-direction pairs. */\nexport function getQueryOrderFields(query: ListQuery): OrderField[] {\n const orderFields = query.q.order?.flatMap<[field: string, asc: boolean]>((orderItem) => {\n if (typeof orderItem === \"string\") {\n return [[orderItem, true]]\n } else if (typeof orderItem === \"object\") {\n return Object.entries(orderItem).map<[string, boolean]>(([field, order]) => {\n if (order === \"ASC\" || order === \"DESC\") {\n return [field, order === \"ASC\"]\n } else {\n throw new Error(\"Unsupported order: \" + order)\n }\n })\n } else {\n throw new TypeError(\"Unsupported order type: \" + orderItem)\n }\n })\n if (!orderFields?.length) {\n throw new Error(\"Query must be ordered.\")\n }\n return orderFields\n}\n\n/** createCursor encodes cursor parts into an opaque cursor string. */\nexport function createCursor(parts: string[]) {\n return Buffer.from(parts.map(String).join(String.fromCharCode(0))).toString(\"base64url\")\n}\n\n/** parseCursor decodes an opaque cursor string into cursor parts. */\nexport function parseCursor(cursor: string) {\n return Buffer.from(cursor, \"base64url\").toString().split(String.fromCharCode(0))\n}\n\n/** createDirectedCursor encodes cursor parts and pagination direction into an opaque cursor string. */\nexport function createDirectedCursor(parts: string[], reverse: boolean) {\n const cursor = createCursor(parts)\n return reverse ? \"-\" + cursor : cursor\n}\n\n/** parseDirectedCursor decodes an opaque directed cursor string into cursor parts and pagination direction. */\nexport function parseDirectedCursor(directedCursor: string) {\n const [cursor, reverse] = directedCursor.startsWith(\"-\") ? [directedCursor.slice(1), true] : [directedCursor, false]\n return { cursor, parts: parseCursor(cursor), reverse }\n}\n","import { getLimit, type PaginationConfig } from \"../../limit\"\nimport type { ListQuery, SortDir } from \"../../types\"\n\nimport { createDirectedCursor, parseDirectedCursor } from \"./cursor\"\nimport { getQueryOrderFields } from \"./order\"\n\nexport interface CursorPaginationParams {\n /** Cursor returned as prevCursor or nextCursor by a previous call. */\n cursor?: string\n /** Page size. */\n limit?: number\n}\n\nexport type CursorPaginationPage<T extends ListQuery = ListQuery> = {\n items: Awaited<T>\n /** Effective page size. Number of items is guaranteed to be less than or equal to this value. */\n limit: number\n /** Cursor for fetching the previous page, if it exists. */\n prevCursor?: string\n /** Cursor for fetching the next page, if it exists. */\n nextCursor?: string\n}\n\n/** paginateByCursor returns one page of results using cursor-based pagination. */\nexport async function paginateByCursor<T extends ListQuery>(query: T, config?: PaginationConfig, params?: CursorPaginationParams): Promise<CursorPaginationPage<T>> {\n const limit = getLimit(query, config, params)\n\n const orderFields = getQueryOrderFields(query)\n\n // poor man validation, TODO improve\n const parsedCursorMaybeValid = params?.cursor ? parseDirectedCursor(params.cursor) : undefined\n const parsedCursor = parsedCursorMaybeValid && parsedCursorMaybeValid.parts.length >= orderFields.length ? parsedCursorMaybeValid : undefined\n\n const reverse = parsedCursor?.reverse ?? false\n\n if (reverse) {\n // Reverse parsed order fields + reverse query ordering.\n orderFields.forEach((of) => {\n of[1] = !of[1]\n })\n const orderArg = Object.fromEntries(orderFields.map<[string, SortDir]>(([field, asc]) => [field, asc ? \"ASC\" : \"DESC\"]))\n query = query.clear(\"order\").order(orderArg as any)\n }\n\n if (parsedCursor) {\n // Prepare raw SQL.\n // For example, for (amount ASC, id DESC) ordering, that would be:\n // (amount, $id) >= ($amount, id)\n const leftRawSql = orderFields.map(([field, asc], i) => asc ? query.ref(field).toSQL() : `$value${i}`).join(\",\")\n const rightRawSql = orderFields.map(([field, asc], i) => !asc ? query.ref(field).toSQL() : `$value${i}`).join(\",\")\n const rawSql = `(${leftRawSql}) > (${rightRawSql})`\n const rawSqlValues = Object.fromEntries(\n orderFields.map((_field, i) => [`value${i}`, parsedCursor.parts[i]]),\n )\n const sqlExpr = query.qb.sql({ raw: rawSql, values: rawSqlValues })\n // query.where doesn't like low-level RawSql objects, cast to any to silence\n query = query.where(sqlExpr as any)\n }\n\n // Query 1 extra item to see if we can paginate farther in current direction.\n const items = await (query as ListQuery).limit(limit + 1) as Awaited<T>\n if (!Array.isArray(items)) {\n throw new TypeError(\"Query must return an array.\")\n }\n const hasContinuation = items.length > limit\n if (hasContinuation) {\n items.splice(limit)\n }\n if (reverse) {\n items.reverse()\n }\n\n function createItemCursor(item: any, reverse: boolean) {\n return createDirectedCursor(orderFields.map(([field]) => {\n const value = getItemValue(item, field)\n // A missing order field (undefined) means it wasn't selected; encoding it would\n // produce a broken cursor that later fails deep inside the database. A legitimate\n // NULL value is fine and gets encoded as usual.\n if (value === undefined) {\n throw new Error(`Order field \"${field}\" is missing from the result — cursor pagination requires every order field to be selected.`)\n }\n // Can add custom serializer here if needed.\n return String(value)\n }), reverse)\n }\n\n // Prev cursor:\n // - for initial pagination, there is no prev page\n // - for forward pagination, prev page exists always\n // - for reverse pagination, prev page exists if we have a continuation\n const prevCursor = (parsedCursor && (parsedCursor.reverse === false || hasContinuation))\n ? createItemCursor(items[0], true)\n : undefined\n\n // Next cursor:\n // - for reverse pagination, next page exists always\n // - for initial or forward pagination, next page exists if we have a continuation\n const nextCursor = (parsedCursor?.reverse === true || hasContinuation)\n ? createItemCursor(items.at(-1), false)\n : undefined\n\n return { items, limit, prevCursor, nextCursor }\n}\n\n/** getItemValue returns an item's field value, resolving dot-notation paths against nested objects. */\nfunction getItemValue(item: unknown, field: string): unknown {\n if (!field.includes(\".\")) {\n return (item as Record<string, unknown>)[field]\n }\n\n return field.split(\".\").reduce<unknown>((obj, key) => {\n return obj == null ? undefined : (obj as Record<string, unknown>)[key]\n }, item)\n}\n","import type { PaginationConfig } from \"../../limit\"\nimport type { ListQuery } from \"../../types\"\n\nimport { type CursorPaginationParams, paginateByCursor } from \"./paginator\"\n\n/** createCursorPaginator creates a reusable cursor paginator with the given config. */\nexport function createCursorPaginator(config?: PaginationConfig) {\n return function paginate<T extends ListQuery>(query: T, params?: CursorPaginationParams) {\n return paginateByCursor(query, config, params)\n }\n}\n","import { getLimit, type PaginationConfig } from \"../../limit\"\nimport type { ListQuery } from \"../../types\"\n\nexport type PagePaginationConfig = PaginationConfig & {\n /**\n * When true, runs a COUNT(*) query and returns `totalItems` / `totalPages` in the result.\n * Requested pages beyond the last are not clamped unless `clampPage` is true.\n */\n total?: boolean\n}\n\nexport type PagePaginationConfigWithTotal = PagePaginationConfig & {\n total: true\n /** When true, requested pages beyond the last are clamped to the last page. */\n clampPage?: boolean\n}\n\nexport interface PagePaginationParams {\n /** Page number, 1-based. */\n page?: number\n /** Page size. */\n limit?: number\n}\n\nexport type PagePaginationPage<T extends ListQuery = ListQuery> = {\n items: Awaited<T>\n /** Effective page number, 1-based. */\n page: number\n /** Effective page size. Number of items is guaranteed to be less than or equal to this value. */\n limit: number\n /** Offset passed to the query, 0-based. */\n offset: number\n /** Previous page number, if it exists. */\n prevPage?: number\n /** Next page number, if it exists. */\n nextPage?: number\n}\n\nexport type PagePaginationTotal = {\n /** Total number of items across all pages. */\n totalItems: number\n /** Total number of pages. */\n totalPages: number\n}\n\nexport type PagePaginationPageWithTotal<T extends ListQuery = ListQuery> = PagePaginationPage<T> & PagePaginationTotal\n\n/** paginateByPage returns one page of results using offset-based pagination. */\nexport async function paginateByPage<T extends ListQuery>(\n query: T,\n config: PagePaginationConfigWithTotal,\n params?: PagePaginationParams,\n): Promise<PagePaginationPageWithTotal<T>>\nexport async function paginateByPage<T extends ListQuery>(\n query: T,\n config?: PagePaginationConfig,\n params?: PagePaginationParams,\n): Promise<PagePaginationPage<T>>\nexport async function paginateByPage<T extends ListQuery>(\n query: T,\n config?: PagePaginationConfig | PagePaginationConfigWithTotal,\n params?: PagePaginationParams,\n) {\n const limit = getLimit(query, config, params)\n\n let page = Math.max(1, params?.page ?? 1)\n\n let total: PagePaginationTotal | undefined\n if (config?.total) {\n const totalItems = await (query as ListQuery).clear(\"select\", \"order\").count() as number\n const totalPages = Math.max(1, Math.ceil(totalItems / limit))\n total = { totalItems, totalPages }\n if ((config as PagePaginationConfigWithTotal).clampPage) {\n page = Math.min(page, totalPages)\n }\n }\n\n const offset = (page - 1) * limit\n\n // When total is known, we can request exactly `limit` items;\n // otherwise, request limit + 1 to detect continuation.\n const items = await (query as ListQuery)\n .offset(offset)\n .limit(total ? limit : limit + 1) as Awaited<T>\n if (!Array.isArray(items)) {\n throw new TypeError(\"Query must return an array.\")\n }\n\n const hasContinuation = total ? page < total.totalPages : items.length > limit\n if (items.length > limit) {\n items.splice(limit)\n }\n\n const prevPage = page > 1 ? page - 1 : undefined\n const nextPage = hasContinuation ? page + 1 : undefined\n\n const result: PagePaginationPage<T> = { items, page, limit, offset, prevPage, nextPage }\n\n if (total) {\n return { ...result, ...total } as PagePaginationPageWithTotal<T>\n }\n\n return result\n}\n","import type { ListQuery } from \"../../types\"\n\nimport type {\n PagePaginationConfig,\n PagePaginationConfigWithTotal,\n PagePaginationPage,\n PagePaginationPageWithTotal,\n PagePaginationParams,\n} from \"./paginator\"\nimport { paginateByPage } from \"./paginator\"\n\n/** createPagePaginator creates a reusable page paginator with the given config. */\nexport function createPagePaginator(config: PagePaginationConfigWithTotal): <T extends ListQuery>(\n query: T,\n params?: PagePaginationParams,\n) => Promise<PagePaginationPageWithTotal<T>>\nexport function createPagePaginator(config?: PagePaginationConfig): <T extends ListQuery>(\n query: T,\n params?: PagePaginationParams,\n) => Promise<PagePaginationPage<T>>\nexport function createPagePaginator(config?: PagePaginationConfig | PagePaginationConfigWithTotal) {\n return function paginate<T extends ListQuery>(query: T, params?: PagePaginationParams) {\n return paginateByPage(query, config, params)\n }\n}\n"],"mappings":"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","names":["import_node_buffer","reverse"]}
package/dist/index.d.cts CHANGED
@@ -19,18 +19,18 @@ interface PaginationParams {
19
19
  declare function getLimit(query: ListQuery, config?: PaginationConfig, params?: PaginationParams): number;
20
20
 
21
21
  interface CursorPaginationParams {
22
- /** Page cursor, as returned by previous call in prevCursor / nextCursor. */
22
+ /** Cursor returned as prevCursor or nextCursor by a previous call. */
23
23
  cursor?: string;
24
- /** Limit. */
24
+ /** Page size. */
25
25
  limit?: number;
26
26
  }
27
27
  type CursorPaginationPage<T extends ListQuery = ListQuery> = {
28
28
  items: Awaited<T>;
29
- /** Effective limit. Number of items is guaranteed to be less or equal. */
29
+ /** Effective page size. Number of items is guaranteed to be less than or equal to this value. */
30
30
  limit: number;
31
- /** Cursor pointing to previous page. */
31
+ /** Cursor for fetching the previous page, if it exists. */
32
32
  prevCursor?: string;
33
- /** Cursor pointing to next page. */
33
+ /** Cursor for fetching the next page, if it exists. */
34
34
  nextCursor?: string;
35
35
  };
36
36
  /** paginateByCursor returns one page of results using cursor-based pagination. */
@@ -39,29 +39,50 @@ declare function paginateByCursor<T extends ListQuery>(query: T, config?: Pagina
39
39
  /** createCursorPaginator creates a reusable cursor paginator with the given config. */
40
40
  declare function createCursorPaginator(config?: PaginationConfig): <T extends ListQuery>(query: T, params?: CursorPaginationParams) => Promise<CursorPaginationPage<T>>;
41
41
 
42
+ type PagePaginationConfig = PaginationConfig & {
43
+ /**
44
+ * When true, runs a COUNT(*) query and returns `totalItems` / `totalPages` in the result.
45
+ * Requested pages beyond the last are not clamped unless `clampPage` is true.
46
+ */
47
+ total?: boolean;
48
+ };
49
+ type PagePaginationConfigWithTotal = PagePaginationConfig & {
50
+ total: true;
51
+ /** When true, requested pages beyond the last are clamped to the last page. */
52
+ clampPage?: boolean;
53
+ };
42
54
  interface PagePaginationParams {
43
- /** Page, 1-based. */
55
+ /** Page number, 1-based. */
44
56
  page?: number;
45
- /** Limit. */
57
+ /** Page size. */
46
58
  limit?: number;
47
59
  }
48
60
  type PagePaginationPage<T extends ListQuery = ListQuery> = {
49
61
  items: Awaited<T>;
50
- /** Effective page number. */
62
+ /** Effective page number, 1-based. */
51
63
  page: number;
52
- /** Effective limit. Number of items is guaranteed to be less or equal. */
64
+ /** Effective page size. Number of items is guaranteed to be less than or equal to this value. */
53
65
  limit: number;
54
- /** Offset of the first item, 1-based. */
66
+ /** Offset passed to the query, 0-based. */
55
67
  offset: number;
56
- /** Prev page number (if exists). */
68
+ /** Previous page number, if it exists. */
57
69
  prevPage?: number;
58
- /** Next page number (if exists). */
70
+ /** Next page number, if it exists. */
59
71
  nextPage?: number;
60
72
  };
73
+ type PagePaginationTotal = {
74
+ /** Total number of items across all pages. */
75
+ totalItems: number;
76
+ /** Total number of pages. */
77
+ totalPages: number;
78
+ };
79
+ type PagePaginationPageWithTotal<T extends ListQuery = ListQuery> = PagePaginationPage<T> & PagePaginationTotal;
61
80
  /** paginateByPage returns one page of results using offset-based pagination. */
62
- declare function paginateByPage<T extends ListQuery>(query: T, config?: PaginationConfig, params?: PagePaginationParams): Promise<PagePaginationPage<T>>;
81
+ declare function paginateByPage<T extends ListQuery>(query: T, config: PagePaginationConfigWithTotal, params?: PagePaginationParams): Promise<PagePaginationPageWithTotal<T>>;
82
+ declare function paginateByPage<T extends ListQuery>(query: T, config?: PagePaginationConfig, params?: PagePaginationParams): Promise<PagePaginationPage<T>>;
63
83
 
64
84
  /** createPagePaginator creates a reusable page paginator with the given config. */
65
- declare function createPagePaginator(config?: PaginationConfig): <T extends ListQuery>(query: T, params?: PagePaginationParams) => Promise<PagePaginationPage<T>>;
85
+ declare function createPagePaginator(config: PagePaginationConfigWithTotal): <T extends ListQuery>(query: T, params?: PagePaginationParams) => Promise<PagePaginationPageWithTotal<T>>;
86
+ declare function createPagePaginator(config?: PagePaginationConfig): <T extends ListQuery>(query: T, params?: PagePaginationParams) => Promise<PagePaginationPage<T>>;
66
87
 
67
- export { type CursorPaginationPage, type CursorPaginationParams, type ListQuery, type PagePaginationPage, type PagePaginationParams, type PaginationConfig, type PaginationParams, type SortDir, createCursorPaginator, createPagePaginator, getLimit, paginateByCursor, paginateByPage };
88
+ export { type CursorPaginationPage, type CursorPaginationParams, type ListQuery, type PagePaginationConfig, type PagePaginationConfigWithTotal, type PagePaginationPage, type PagePaginationPageWithTotal, type PagePaginationParams, type PagePaginationTotal, type PaginationConfig, type PaginationParams, type SortDir, createCursorPaginator, createPagePaginator, getLimit, paginateByCursor, paginateByPage };
package/dist/index.d.ts CHANGED
@@ -19,18 +19,18 @@ interface PaginationParams {
19
19
  declare function getLimit(query: ListQuery, config?: PaginationConfig, params?: PaginationParams): number;
20
20
 
21
21
  interface CursorPaginationParams {
22
- /** Page cursor, as returned by previous call in prevCursor / nextCursor. */
22
+ /** Cursor returned as prevCursor or nextCursor by a previous call. */
23
23
  cursor?: string;
24
- /** Limit. */
24
+ /** Page size. */
25
25
  limit?: number;
26
26
  }
27
27
  type CursorPaginationPage<T extends ListQuery = ListQuery> = {
28
28
  items: Awaited<T>;
29
- /** Effective limit. Number of items is guaranteed to be less or equal. */
29
+ /** Effective page size. Number of items is guaranteed to be less than or equal to this value. */
30
30
  limit: number;
31
- /** Cursor pointing to previous page. */
31
+ /** Cursor for fetching the previous page, if it exists. */
32
32
  prevCursor?: string;
33
- /** Cursor pointing to next page. */
33
+ /** Cursor for fetching the next page, if it exists. */
34
34
  nextCursor?: string;
35
35
  };
36
36
  /** paginateByCursor returns one page of results using cursor-based pagination. */
@@ -39,29 +39,50 @@ declare function paginateByCursor<T extends ListQuery>(query: T, config?: Pagina
39
39
  /** createCursorPaginator creates a reusable cursor paginator with the given config. */
40
40
  declare function createCursorPaginator(config?: PaginationConfig): <T extends ListQuery>(query: T, params?: CursorPaginationParams) => Promise<CursorPaginationPage<T>>;
41
41
 
42
+ type PagePaginationConfig = PaginationConfig & {
43
+ /**
44
+ * When true, runs a COUNT(*) query and returns `totalItems` / `totalPages` in the result.
45
+ * Requested pages beyond the last are not clamped unless `clampPage` is true.
46
+ */
47
+ total?: boolean;
48
+ };
49
+ type PagePaginationConfigWithTotal = PagePaginationConfig & {
50
+ total: true;
51
+ /** When true, requested pages beyond the last are clamped to the last page. */
52
+ clampPage?: boolean;
53
+ };
42
54
  interface PagePaginationParams {
43
- /** Page, 1-based. */
55
+ /** Page number, 1-based. */
44
56
  page?: number;
45
- /** Limit. */
57
+ /** Page size. */
46
58
  limit?: number;
47
59
  }
48
60
  type PagePaginationPage<T extends ListQuery = ListQuery> = {
49
61
  items: Awaited<T>;
50
- /** Effective page number. */
62
+ /** Effective page number, 1-based. */
51
63
  page: number;
52
- /** Effective limit. Number of items is guaranteed to be less or equal. */
64
+ /** Effective page size. Number of items is guaranteed to be less than or equal to this value. */
53
65
  limit: number;
54
- /** Offset of the first item, 1-based. */
66
+ /** Offset passed to the query, 0-based. */
55
67
  offset: number;
56
- /** Prev page number (if exists). */
68
+ /** Previous page number, if it exists. */
57
69
  prevPage?: number;
58
- /** Next page number (if exists). */
70
+ /** Next page number, if it exists. */
59
71
  nextPage?: number;
60
72
  };
73
+ type PagePaginationTotal = {
74
+ /** Total number of items across all pages. */
75
+ totalItems: number;
76
+ /** Total number of pages. */
77
+ totalPages: number;
78
+ };
79
+ type PagePaginationPageWithTotal<T extends ListQuery = ListQuery> = PagePaginationPage<T> & PagePaginationTotal;
61
80
  /** paginateByPage returns one page of results using offset-based pagination. */
62
- declare function paginateByPage<T extends ListQuery>(query: T, config?: PaginationConfig, params?: PagePaginationParams): Promise<PagePaginationPage<T>>;
81
+ declare function paginateByPage<T extends ListQuery>(query: T, config: PagePaginationConfigWithTotal, params?: PagePaginationParams): Promise<PagePaginationPageWithTotal<T>>;
82
+ declare function paginateByPage<T extends ListQuery>(query: T, config?: PagePaginationConfig, params?: PagePaginationParams): Promise<PagePaginationPage<T>>;
63
83
 
64
84
  /** createPagePaginator creates a reusable page paginator with the given config. */
65
- declare function createPagePaginator(config?: PaginationConfig): <T extends ListQuery>(query: T, params?: PagePaginationParams) => Promise<PagePaginationPage<T>>;
85
+ declare function createPagePaginator(config: PagePaginationConfigWithTotal): <T extends ListQuery>(query: T, params?: PagePaginationParams) => Promise<PagePaginationPageWithTotal<T>>;
86
+ declare function createPagePaginator(config?: PagePaginationConfig): <T extends ListQuery>(query: T, params?: PagePaginationParams) => Promise<PagePaginationPage<T>>;
66
87
 
67
- export { type CursorPaginationPage, type CursorPaginationParams, type ListQuery, type PagePaginationPage, type PagePaginationParams, type PaginationConfig, type PaginationParams, type SortDir, createCursorPaginator, createPagePaginator, getLimit, paginateByCursor, paginateByPage };
88
+ export { type CursorPaginationPage, type CursorPaginationParams, type ListQuery, type PagePaginationConfig, type PagePaginationConfigWithTotal, type PagePaginationPage, type PagePaginationPageWithTotal, type PagePaginationParams, type PagePaginationTotal, type PaginationConfig, type PaginationParams, type SortDir, createCursorPaginator, createPagePaginator, getLimit, paginateByCursor, paginateByPage };
package/dist/index.js CHANGED
@@ -91,7 +91,11 @@ async function paginateByCursor(query, config, params) {
91
91
  }
92
92
  function createItemCursor(item, reverse2) {
93
93
  return createDirectedCursor(orderFields.map(([field]) => {
94
- return String(getItemValue(item, field));
94
+ const value = getItemValue(item, field);
95
+ if (value === void 0) {
96
+ throw new Error(`Order field "${field}" is missing from the result \u2014 cursor pagination requires every order field to be selected.`);
97
+ }
98
+ return String(value);
95
99
  }), reverse2);
96
100
  }
97
101
  const prevCursor = parsedCursor && (parsedCursor.reverse === false || hasContinuation) ? createItemCursor(items[0], true) : void 0;
@@ -117,19 +121,32 @@ function createCursorPaginator(config) {
117
121
  // src/paginators/page/paginator.ts
118
122
  async function paginateByPage(query, config, params) {
119
123
  const limit = getLimit(query, config, params);
120
- const page = Math.max(1, params?.page ?? 1);
124
+ let page = Math.max(1, params?.page ?? 1);
125
+ let total;
126
+ if (config?.total) {
127
+ const totalItems = await query.clear("select", "order").count();
128
+ const totalPages = Math.max(1, Math.ceil(totalItems / limit));
129
+ total = { totalItems, totalPages };
130
+ if (config.clampPage) {
131
+ page = Math.min(page, totalPages);
132
+ }
133
+ }
121
134
  const offset = (page - 1) * limit;
122
- const items = await query.offset(offset).limit(limit + 1);
135
+ const items = await query.offset(offset).limit(total ? limit : limit + 1);
123
136
  if (!Array.isArray(items)) {
124
137
  throw new TypeError("Query must return an array.");
125
138
  }
126
- const hasContinuation = items.length > limit;
127
- if (hasContinuation) {
139
+ const hasContinuation = total ? page < total.totalPages : items.length > limit;
140
+ if (items.length > limit) {
128
141
  items.splice(limit);
129
142
  }
130
143
  const prevPage = page > 1 ? page - 1 : void 0;
131
144
  const nextPage = hasContinuation ? page + 1 : void 0;
132
- return { items, page, limit, offset, prevPage, nextPage };
145
+ const result = { items, page, limit, offset, prevPage, nextPage };
146
+ if (total) {
147
+ return { ...result, ...total };
148
+ }
149
+ return result;
133
150
  }
134
151
 
135
152
  // src/paginators/page/factory.ts
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/limit.ts","../src/paginators/cursor/cursor.ts","../src/paginators/cursor/order.ts","../src/paginators/cursor/paginator.ts","../src/paginators/cursor/factory.ts","../src/paginators/page/paginator.ts","../src/paginators/page/factory.ts"],"sourcesContent":["import type { ListQuery } from \"./types\"\n\nexport interface PaginationConfig {\n /** Default limit. If `maxLimit` is not set, client params limit is ignored. */\n limit?: number\n /** Max limit allowed to be passed in params. */\n maxLimit?: number\n}\n\nexport interface PaginationParams {\n /** Requested limit. */\n limit?: number\n}\n\n/** getLimit returns the effective limit for a query and pagination parameters. */\nexport function getLimit(query: ListQuery, config?: PaginationConfig, params?: PaginationParams): number {\n if (config) {\n const limit = config.maxLimit !== undefined ? (params?.limit ?? config.limit) : config.limit\n if (limit === undefined) {\n throw new Error(\"Set config.limit or params.limit with config.maxLimit.\")\n }\n\n return Math.max(1, Math.min(limit, config.maxLimit ?? limit))\n }\n\n const queryLimit = query.q.limit\n if (!queryLimit) {\n throw new Error(\"Set query limit or config.limit.\")\n }\n\n return Math.max(1, queryLimit)\n}\n","import { Buffer } from \"node:buffer\"\n\n/** createCursor encodes cursor parts into an opaque cursor string. */\nexport function createCursor(parts: string[]) {\n return Buffer.from(parts.map(String).join(String.fromCharCode(0))).toString(\"base64url\")\n}\n\n/** parseCursor decodes an opaque cursor string into cursor parts. */\nexport function parseCursor(cursor: string) {\n return Buffer.from(cursor, \"base64url\").toString().split(String.fromCharCode(0))\n}\n\n/** createDirectedCursor encodes cursor parts and pagination direction into an opaque cursor string. */\nexport function createDirectedCursor(parts: string[], reverse: boolean) {\n const cursor = createCursor(parts)\n return reverse ? \"-\" + cursor : cursor\n}\n\n/** parseDirectedCursor decodes an opaque directed cursor string into cursor parts and pagination direction. */\nexport function parseDirectedCursor(directedCursor: string) {\n const [cursor, reverse] = directedCursor.startsWith(\"-\") ? [directedCursor.slice(1), true] : [directedCursor, false]\n return { cursor, parts: parseCursor(cursor), reverse }\n}\n","import { Buffer } from \"node:buffer\"\n\nimport type { ListQuery } from \"../../types\"\n\ntype OrderField = [field: string, asc: boolean]\n\n/** getQueryOrderFields returns ordered query fields as field and ascending-direction pairs. */\nexport function getQueryOrderFields(query: ListQuery): OrderField[] {\n const orderFields = query.q.order?.flatMap<[field: string, asc: boolean]>((orderItem) => {\n if (typeof orderItem === \"string\") {\n return [[orderItem, true]]\n } else if (typeof orderItem === \"object\") {\n return Object.entries(orderItem).map<[string, boolean]>(([field, order]) => {\n if (order === \"ASC\" || order === \"DESC\") {\n return [field, order === \"ASC\"]\n } else {\n throw new Error(\"Unsupported order: \" + order)\n }\n })\n } else {\n throw new TypeError(\"Unsupported order type: \" + orderItem)\n }\n })\n if (!orderFields?.length) {\n throw new Error(\"Query must be ordered.\")\n }\n return orderFields\n}\n\n/** createCursor encodes cursor parts into an opaque cursor string. */\nexport function createCursor(parts: string[]) {\n return Buffer.from(parts.map(String).join(String.fromCharCode(0))).toString(\"base64url\")\n}\n\n/** parseCursor decodes an opaque cursor string into cursor parts. */\nexport function parseCursor(cursor: string) {\n return Buffer.from(cursor, \"base64url\").toString().split(String.fromCharCode(0))\n}\n\n/** createDirectedCursor encodes cursor parts and pagination direction into an opaque cursor string. */\nexport function createDirectedCursor(parts: string[], reverse: boolean) {\n const cursor = createCursor(parts)\n return reverse ? \"-\" + cursor : cursor\n}\n\n/** parseDirectedCursor decodes an opaque directed cursor string into cursor parts and pagination direction. */\nexport function parseDirectedCursor(directedCursor: string) {\n const [cursor, reverse] = directedCursor.startsWith(\"-\") ? [directedCursor.slice(1), true] : [directedCursor, false]\n return { cursor, parts: parseCursor(cursor), reverse }\n}\n","import { getLimit, type PaginationConfig } from \"../../limit\"\nimport type { ListQuery, SortDir } from \"../../types\"\n\nimport { createDirectedCursor, parseDirectedCursor } from \"./cursor\"\nimport { getQueryOrderFields } from \"./order\"\n\nexport interface CursorPaginationParams {\n /** Page cursor, as returned by previous call in prevCursor / nextCursor. */\n cursor?: string\n /** Limit. */\n limit?: number\n}\n\nexport type CursorPaginationPage<T extends ListQuery = ListQuery> = {\n items: Awaited<T>\n /** Effective limit. Number of items is guaranteed to be less or equal. */\n limit: number\n /** Cursor pointing to previous page. */\n prevCursor?: string\n /** Cursor pointing to next page. */\n nextCursor?: string\n}\n\n/** paginateByCursor returns one page of results using cursor-based pagination. */\nexport async function paginateByCursor<T extends ListQuery>(query: T, config?: PaginationConfig, params?: CursorPaginationParams): Promise<CursorPaginationPage<T>> {\n const limit = getLimit(query, config, params)\n\n const orderFields = getQueryOrderFields(query)\n\n // poor man validation, TODO improve\n const parsedCursorMaybeValid = params?.cursor ? parseDirectedCursor(params.cursor) : undefined\n const parsedCursor = parsedCursorMaybeValid && parsedCursorMaybeValid.parts.length >= orderFields.length ? parsedCursorMaybeValid : undefined\n\n const reverse = parsedCursor?.reverse ?? false\n\n if (reverse) {\n // Reverse parsed order fields + reverse query ordering.\n orderFields.forEach((of) => {\n of[1] = !of[1]\n })\n const orderArg = Object.fromEntries(orderFields.map<[string, SortDir]>(([field, asc]) => [field, asc ? \"ASC\" : \"DESC\"]))\n query = query.clear(\"order\").order(orderArg as any)\n }\n\n if (parsedCursor) {\n // Prepare raw SQL.\n // For example, for (amount ASC, id DESC) ordering, that would be:\n // (amount, $id) >= ($amount, id)\n const leftRawSql = orderFields.map(([field, asc], i) => asc ? query.ref(field).toSQL() : `$value${i}`).join(\",\")\n const rightRawSql = orderFields.map(([field, asc], i) => !asc ? query.ref(field).toSQL() : `$value${i}`).join(\",\")\n const rawSql = `(${leftRawSql}) > (${rightRawSql})`\n const rawSqlValues = Object.fromEntries(\n orderFields.map((_field, i) => [`value${i}`, parsedCursor.parts[i]]),\n )\n const sqlExpr = query.qb.sql({ raw: rawSql, values: rawSqlValues })\n // query.where doesn't like low-level RawSql objects, cast to any to silence\n query = query.where(sqlExpr as any)\n }\n\n // Query 1 extra item to see if we can paginate farther in current direction.\n const items = await (query as ListQuery).limit(limit + 1) as Awaited<T>\n if (!Array.isArray(items)) {\n throw new TypeError(\"Query must return an array.\")\n }\n const hasContinuation = items.length > limit\n if (hasContinuation) {\n items.splice(limit)\n }\n if (reverse) {\n items.reverse()\n }\n\n function createItemCursor(item: any, reverse: boolean) {\n return createDirectedCursor(orderFields.map(([field]) => {\n // Can add custom serializer here if needed.\n return String(getItemValue(item, field))\n }), reverse)\n }\n\n // Prev cursor:\n // - for initial pagination, there is no prev page\n // - for forward pagination, prev page exists always\n // - for reverse pagination, prev page exists if we have a continuation\n const prevCursor = (parsedCursor && (parsedCursor.reverse === false || hasContinuation))\n ? createItemCursor(items[0], true)\n : undefined\n\n // Next cursor:\n // - for reverse pagination, next page exists always\n // - for initial or forward pagination, next page exists if we have a continuation\n const nextCursor = (parsedCursor?.reverse === true || hasContinuation)\n ? createItemCursor(items.at(-1), false)\n : undefined\n\n return { items, limit, prevCursor, nextCursor }\n}\n\n/** getItemValue returns an item's field value, resolving dot-notation paths against nested objects. */\nfunction getItemValue(item: unknown, field: string): unknown {\n if (!field.includes(\".\")) {\n return (item as Record<string, unknown>)[field]\n }\n\n return field.split(\".\").reduce<unknown>((obj, key) => {\n return obj == null ? undefined : (obj as Record<string, unknown>)[key]\n }, item)\n}\n","import type { PaginationConfig } from \"../../limit\"\nimport type { ListQuery } from \"../../types\"\n\nimport { type CursorPaginationParams, paginateByCursor } from \"./paginator\"\n\n/** createCursorPaginator creates a reusable cursor paginator with the given config. */\nexport function createCursorPaginator(config?: PaginationConfig) {\n return function paginate<T extends ListQuery>(query: T, params?: CursorPaginationParams) {\n return paginateByCursor(query, config, params)\n }\n}\n","import { getLimit, type PaginationConfig } from \"../../limit\"\nimport type { ListQuery } from \"../../types\"\n\nexport interface PagePaginationParams {\n /** Page, 1-based. */\n page?: number\n /** Limit. */\n limit?: number\n}\n\nexport type PagePaginationPage<T extends ListQuery = ListQuery> = {\n items: Awaited<T>\n /** Effective page number. */\n page: number\n /** Effective limit. Number of items is guaranteed to be less or equal. */\n limit: number\n /** Offset of the first item, 1-based. */\n offset: number\n /** Prev page number (if exists). */\n prevPage?: number\n /** Next page number (if exists). */\n nextPage?: number\n}\n\n/** paginateByPage returns one page of results using offset-based pagination. */\nexport async function paginateByPage<T extends ListQuery>(query: T, config?: PaginationConfig, params?: PagePaginationParams): Promise<PagePaginationPage<T>> {\n const limit = getLimit(query, config, params)\n\n const page = Math.max(1, params?.page ?? 1)\n const offset = (page - 1) * limit\n\n const items = await (query as ListQuery).offset(offset).limit(limit + 1) as Awaited<T>\n if (!Array.isArray(items)) {\n throw new TypeError(\"Query must return an array.\")\n }\n const hasContinuation = items.length > limit\n if (hasContinuation) {\n items.splice(limit)\n }\n\n const prevPage = page > 1 ? page - 1 : undefined\n const nextPage = hasContinuation ? page + 1 : undefined\n\n return { items, page, limit, offset, prevPage, nextPage }\n}\n","import type { PaginationConfig } from \"../../limit\"\nimport type { ListQuery } from \"../../types\"\n\nimport { type PagePaginationParams, paginateByPage } from \"./paginator\"\n\n/** createPagePaginator creates a reusable page paginator with the given config. */\nexport function createPagePaginator(config?: PaginationConfig) {\n return function paginate<T extends ListQuery>(query: T, params?: PagePaginationParams) {\n return paginateByPage(query, config, params)\n }\n}\n"],"mappings":"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","names":["reverse"]}
1
+ {"version":3,"sources":["../src/limit.ts","../src/paginators/cursor/cursor.ts","../src/paginators/cursor/order.ts","../src/paginators/cursor/paginator.ts","../src/paginators/cursor/factory.ts","../src/paginators/page/paginator.ts","../src/paginators/page/factory.ts"],"sourcesContent":["import type { ListQuery } from \"./types\"\n\nexport interface PaginationConfig {\n /** Default limit. If `maxLimit` is not set, client params limit is ignored. */\n limit?: number\n /** Max limit allowed to be passed in params. */\n maxLimit?: number\n}\n\nexport interface PaginationParams {\n /** Requested limit. */\n limit?: number\n}\n\n/** getLimit returns the effective limit for a query and pagination parameters. */\nexport function getLimit(query: ListQuery, config?: PaginationConfig, params?: PaginationParams): number {\n if (config) {\n const limit = config.maxLimit !== undefined ? (params?.limit ?? config.limit) : config.limit\n if (limit === undefined) {\n throw new Error(\"Set config.limit or params.limit with config.maxLimit.\")\n }\n\n return Math.max(1, Math.min(limit, config.maxLimit ?? limit))\n }\n\n const queryLimit = query.q.limit\n if (!queryLimit) {\n throw new Error(\"Set query limit or config.limit.\")\n }\n\n return Math.max(1, queryLimit)\n}\n","import { Buffer } from \"node:buffer\"\n\n/** createCursor encodes cursor parts into an opaque cursor string. */\nexport function createCursor(parts: string[]) {\n return Buffer.from(parts.map(String).join(String.fromCharCode(0))).toString(\"base64url\")\n}\n\n/** parseCursor decodes an opaque cursor string into cursor parts. */\nexport function parseCursor(cursor: string) {\n return Buffer.from(cursor, \"base64url\").toString().split(String.fromCharCode(0))\n}\n\n/** createDirectedCursor encodes cursor parts and pagination direction into an opaque cursor string. */\nexport function createDirectedCursor(parts: string[], reverse: boolean) {\n const cursor = createCursor(parts)\n return reverse ? \"-\" + cursor : cursor\n}\n\n/** parseDirectedCursor decodes an opaque directed cursor string into cursor parts and pagination direction. */\nexport function parseDirectedCursor(directedCursor: string) {\n const [cursor, reverse] = directedCursor.startsWith(\"-\") ? [directedCursor.slice(1), true] : [directedCursor, false]\n return { cursor, parts: parseCursor(cursor), reverse }\n}\n","import { Buffer } from \"node:buffer\"\n\nimport type { ListQuery } from \"../../types\"\n\ntype OrderField = [field: string, asc: boolean]\n\n/** getQueryOrderFields returns ordered query fields as field and ascending-direction pairs. */\nexport function getQueryOrderFields(query: ListQuery): OrderField[] {\n const orderFields = query.q.order?.flatMap<[field: string, asc: boolean]>((orderItem) => {\n if (typeof orderItem === \"string\") {\n return [[orderItem, true]]\n } else if (typeof orderItem === \"object\") {\n return Object.entries(orderItem).map<[string, boolean]>(([field, order]) => {\n if (order === \"ASC\" || order === \"DESC\") {\n return [field, order === \"ASC\"]\n } else {\n throw new Error(\"Unsupported order: \" + order)\n }\n })\n } else {\n throw new TypeError(\"Unsupported order type: \" + orderItem)\n }\n })\n if (!orderFields?.length) {\n throw new Error(\"Query must be ordered.\")\n }\n return orderFields\n}\n\n/** createCursor encodes cursor parts into an opaque cursor string. */\nexport function createCursor(parts: string[]) {\n return Buffer.from(parts.map(String).join(String.fromCharCode(0))).toString(\"base64url\")\n}\n\n/** parseCursor decodes an opaque cursor string into cursor parts. */\nexport function parseCursor(cursor: string) {\n return Buffer.from(cursor, \"base64url\").toString().split(String.fromCharCode(0))\n}\n\n/** createDirectedCursor encodes cursor parts and pagination direction into an opaque cursor string. */\nexport function createDirectedCursor(parts: string[], reverse: boolean) {\n const cursor = createCursor(parts)\n return reverse ? \"-\" + cursor : cursor\n}\n\n/** parseDirectedCursor decodes an opaque directed cursor string into cursor parts and pagination direction. */\nexport function parseDirectedCursor(directedCursor: string) {\n const [cursor, reverse] = directedCursor.startsWith(\"-\") ? [directedCursor.slice(1), true] : [directedCursor, false]\n return { cursor, parts: parseCursor(cursor), reverse }\n}\n","import { getLimit, type PaginationConfig } from \"../../limit\"\nimport type { ListQuery, SortDir } from \"../../types\"\n\nimport { createDirectedCursor, parseDirectedCursor } from \"./cursor\"\nimport { getQueryOrderFields } from \"./order\"\n\nexport interface CursorPaginationParams {\n /** Cursor returned as prevCursor or nextCursor by a previous call. */\n cursor?: string\n /** Page size. */\n limit?: number\n}\n\nexport type CursorPaginationPage<T extends ListQuery = ListQuery> = {\n items: Awaited<T>\n /** Effective page size. Number of items is guaranteed to be less than or equal to this value. */\n limit: number\n /** Cursor for fetching the previous page, if it exists. */\n prevCursor?: string\n /** Cursor for fetching the next page, if it exists. */\n nextCursor?: string\n}\n\n/** paginateByCursor returns one page of results using cursor-based pagination. */\nexport async function paginateByCursor<T extends ListQuery>(query: T, config?: PaginationConfig, params?: CursorPaginationParams): Promise<CursorPaginationPage<T>> {\n const limit = getLimit(query, config, params)\n\n const orderFields = getQueryOrderFields(query)\n\n // poor man validation, TODO improve\n const parsedCursorMaybeValid = params?.cursor ? parseDirectedCursor(params.cursor) : undefined\n const parsedCursor = parsedCursorMaybeValid && parsedCursorMaybeValid.parts.length >= orderFields.length ? parsedCursorMaybeValid : undefined\n\n const reverse = parsedCursor?.reverse ?? false\n\n if (reverse) {\n // Reverse parsed order fields + reverse query ordering.\n orderFields.forEach((of) => {\n of[1] = !of[1]\n })\n const orderArg = Object.fromEntries(orderFields.map<[string, SortDir]>(([field, asc]) => [field, asc ? \"ASC\" : \"DESC\"]))\n query = query.clear(\"order\").order(orderArg as any)\n }\n\n if (parsedCursor) {\n // Prepare raw SQL.\n // For example, for (amount ASC, id DESC) ordering, that would be:\n // (amount, $id) >= ($amount, id)\n const leftRawSql = orderFields.map(([field, asc], i) => asc ? query.ref(field).toSQL() : `$value${i}`).join(\",\")\n const rightRawSql = orderFields.map(([field, asc], i) => !asc ? query.ref(field).toSQL() : `$value${i}`).join(\",\")\n const rawSql = `(${leftRawSql}) > (${rightRawSql})`\n const rawSqlValues = Object.fromEntries(\n orderFields.map((_field, i) => [`value${i}`, parsedCursor.parts[i]]),\n )\n const sqlExpr = query.qb.sql({ raw: rawSql, values: rawSqlValues })\n // query.where doesn't like low-level RawSql objects, cast to any to silence\n query = query.where(sqlExpr as any)\n }\n\n // Query 1 extra item to see if we can paginate farther in current direction.\n const items = await (query as ListQuery).limit(limit + 1) as Awaited<T>\n if (!Array.isArray(items)) {\n throw new TypeError(\"Query must return an array.\")\n }\n const hasContinuation = items.length > limit\n if (hasContinuation) {\n items.splice(limit)\n }\n if (reverse) {\n items.reverse()\n }\n\n function createItemCursor(item: any, reverse: boolean) {\n return createDirectedCursor(orderFields.map(([field]) => {\n const value = getItemValue(item, field)\n // A missing order field (undefined) means it wasn't selected; encoding it would\n // produce a broken cursor that later fails deep inside the database. A legitimate\n // NULL value is fine and gets encoded as usual.\n if (value === undefined) {\n throw new Error(`Order field \"${field}\" is missing from the result — cursor pagination requires every order field to be selected.`)\n }\n // Can add custom serializer here if needed.\n return String(value)\n }), reverse)\n }\n\n // Prev cursor:\n // - for initial pagination, there is no prev page\n // - for forward pagination, prev page exists always\n // - for reverse pagination, prev page exists if we have a continuation\n const prevCursor = (parsedCursor && (parsedCursor.reverse === false || hasContinuation))\n ? createItemCursor(items[0], true)\n : undefined\n\n // Next cursor:\n // - for reverse pagination, next page exists always\n // - for initial or forward pagination, next page exists if we have a continuation\n const nextCursor = (parsedCursor?.reverse === true || hasContinuation)\n ? createItemCursor(items.at(-1), false)\n : undefined\n\n return { items, limit, prevCursor, nextCursor }\n}\n\n/** getItemValue returns an item's field value, resolving dot-notation paths against nested objects. */\nfunction getItemValue(item: unknown, field: string): unknown {\n if (!field.includes(\".\")) {\n return (item as Record<string, unknown>)[field]\n }\n\n return field.split(\".\").reduce<unknown>((obj, key) => {\n return obj == null ? undefined : (obj as Record<string, unknown>)[key]\n }, item)\n}\n","import type { PaginationConfig } from \"../../limit\"\nimport type { ListQuery } from \"../../types\"\n\nimport { type CursorPaginationParams, paginateByCursor } from \"./paginator\"\n\n/** createCursorPaginator creates a reusable cursor paginator with the given config. */\nexport function createCursorPaginator(config?: PaginationConfig) {\n return function paginate<T extends ListQuery>(query: T, params?: CursorPaginationParams) {\n return paginateByCursor(query, config, params)\n }\n}\n","import { getLimit, type PaginationConfig } from \"../../limit\"\nimport type { ListQuery } from \"../../types\"\n\nexport type PagePaginationConfig = PaginationConfig & {\n /**\n * When true, runs a COUNT(*) query and returns `totalItems` / `totalPages` in the result.\n * Requested pages beyond the last are not clamped unless `clampPage` is true.\n */\n total?: boolean\n}\n\nexport type PagePaginationConfigWithTotal = PagePaginationConfig & {\n total: true\n /** When true, requested pages beyond the last are clamped to the last page. */\n clampPage?: boolean\n}\n\nexport interface PagePaginationParams {\n /** Page number, 1-based. */\n page?: number\n /** Page size. */\n limit?: number\n}\n\nexport type PagePaginationPage<T extends ListQuery = ListQuery> = {\n items: Awaited<T>\n /** Effective page number, 1-based. */\n page: number\n /** Effective page size. Number of items is guaranteed to be less than or equal to this value. */\n limit: number\n /** Offset passed to the query, 0-based. */\n offset: number\n /** Previous page number, if it exists. */\n prevPage?: number\n /** Next page number, if it exists. */\n nextPage?: number\n}\n\nexport type PagePaginationTotal = {\n /** Total number of items across all pages. */\n totalItems: number\n /** Total number of pages. */\n totalPages: number\n}\n\nexport type PagePaginationPageWithTotal<T extends ListQuery = ListQuery> = PagePaginationPage<T> & PagePaginationTotal\n\n/** paginateByPage returns one page of results using offset-based pagination. */\nexport async function paginateByPage<T extends ListQuery>(\n query: T,\n config: PagePaginationConfigWithTotal,\n params?: PagePaginationParams,\n): Promise<PagePaginationPageWithTotal<T>>\nexport async function paginateByPage<T extends ListQuery>(\n query: T,\n config?: PagePaginationConfig,\n params?: PagePaginationParams,\n): Promise<PagePaginationPage<T>>\nexport async function paginateByPage<T extends ListQuery>(\n query: T,\n config?: PagePaginationConfig | PagePaginationConfigWithTotal,\n params?: PagePaginationParams,\n) {\n const limit = getLimit(query, config, params)\n\n let page = Math.max(1, params?.page ?? 1)\n\n let total: PagePaginationTotal | undefined\n if (config?.total) {\n const totalItems = await (query as ListQuery).clear(\"select\", \"order\").count() as number\n const totalPages = Math.max(1, Math.ceil(totalItems / limit))\n total = { totalItems, totalPages }\n if ((config as PagePaginationConfigWithTotal).clampPage) {\n page = Math.min(page, totalPages)\n }\n }\n\n const offset = (page - 1) * limit\n\n // When total is known, we can request exactly `limit` items;\n // otherwise, request limit + 1 to detect continuation.\n const items = await (query as ListQuery)\n .offset(offset)\n .limit(total ? limit : limit + 1) as Awaited<T>\n if (!Array.isArray(items)) {\n throw new TypeError(\"Query must return an array.\")\n }\n\n const hasContinuation = total ? page < total.totalPages : items.length > limit\n if (items.length > limit) {\n items.splice(limit)\n }\n\n const prevPage = page > 1 ? page - 1 : undefined\n const nextPage = hasContinuation ? page + 1 : undefined\n\n const result: PagePaginationPage<T> = { items, page, limit, offset, prevPage, nextPage }\n\n if (total) {\n return { ...result, ...total } as PagePaginationPageWithTotal<T>\n }\n\n return result\n}\n","import type { ListQuery } from \"../../types\"\n\nimport type {\n PagePaginationConfig,\n PagePaginationConfigWithTotal,\n PagePaginationPage,\n PagePaginationPageWithTotal,\n PagePaginationParams,\n} from \"./paginator\"\nimport { paginateByPage } from \"./paginator\"\n\n/** createPagePaginator creates a reusable page paginator with the given config. */\nexport function createPagePaginator(config: PagePaginationConfigWithTotal): <T extends ListQuery>(\n query: T,\n params?: PagePaginationParams,\n) => Promise<PagePaginationPageWithTotal<T>>\nexport function createPagePaginator(config?: PagePaginationConfig): <T extends ListQuery>(\n query: T,\n params?: PagePaginationParams,\n) => Promise<PagePaginationPage<T>>\nexport function createPagePaginator(config?: PagePaginationConfig | PagePaginationConfigWithTotal) {\n return function paginate<T extends ListQuery>(query: T, params?: PagePaginationParams) {\n return paginateByPage(query, config, params)\n }\n}\n"],"mappings":"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","names":["reverse"]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "orchid-pagination",
3
3
  "type": "module",
4
- "version": "2.0.0",
4
+ "version": "2.1.1",
5
5
  "packageManager": "bun@1.3.14",
6
6
  "description": "Orchid ORM query pagination helpers",
7
7
  "author": "Ilya Semenov",
@@ -9,6 +9,20 @@ describe("paginateByCursor", () => {
9
9
  await expect(paginateByCursor(db.user.all(), { limit: 2 })).rejects.toThrow("Query must be ordered.")
10
10
  })
11
11
 
12
+ test("throws when an order field is not selected", async () => {
13
+ await seedUsers([
14
+ { id: 1, name: "a", score: 10, group: "one" },
15
+ { id: 2, name: "b", score: 20, group: "one" },
16
+ ])
17
+
18
+ // score is ordered by but not selected, so it's missing from the result rows.
19
+ const query = db.user.select("id", "name").order({ score: "DESC", id: "DESC" })
20
+
21
+ await expect(paginateByCursor(query, { limit: 1 })).rejects.toThrow(
22
+ "Order field \"score\" is missing from the result — cursor pagination requires every order field to be selected.",
23
+ )
24
+ })
25
+
12
26
  test("paginates forward with single-field ascending order", async () => {
13
27
  await seedUsers([
14
28
  { id: 1, name: "a", score: 10, group: "one" },
@@ -5,19 +5,19 @@ import { createDirectedCursor, parseDirectedCursor } from "./cursor"
5
5
  import { getQueryOrderFields } from "./order"
6
6
 
7
7
  export interface CursorPaginationParams {
8
- /** Page cursor, as returned by previous call in prevCursor / nextCursor. */
8
+ /** Cursor returned as prevCursor or nextCursor by a previous call. */
9
9
  cursor?: string
10
- /** Limit. */
10
+ /** Page size. */
11
11
  limit?: number
12
12
  }
13
13
 
14
14
  export type CursorPaginationPage<T extends ListQuery = ListQuery> = {
15
15
  items: Awaited<T>
16
- /** Effective limit. Number of items is guaranteed to be less or equal. */
16
+ /** Effective page size. Number of items is guaranteed to be less than or equal to this value. */
17
17
  limit: number
18
- /** Cursor pointing to previous page. */
18
+ /** Cursor for fetching the previous page, if it exists. */
19
19
  prevCursor?: string
20
- /** Cursor pointing to next page. */
20
+ /** Cursor for fetching the next page, if it exists. */
21
21
  nextCursor?: string
22
22
  }
23
23
 
@@ -72,8 +72,15 @@ export async function paginateByCursor<T extends ListQuery>(query: T, config?: P
72
72
 
73
73
  function createItemCursor(item: any, reverse: boolean) {
74
74
  return createDirectedCursor(orderFields.map(([field]) => {
75
+ const value = getItemValue(item, field)
76
+ // A missing order field (undefined) means it wasn't selected; encoding it would
77
+ // produce a broken cursor that later fails deep inside the database. A legitimate
78
+ // NULL value is fine and gets encoded as usual.
79
+ if (value === undefined) {
80
+ throw new Error(`Order field "${field}" is missing from the result — cursor pagination requires every order field to be selected.`)
81
+ }
75
82
  // Can add custom serializer here if needed.
76
- return String(getItemValue(item, field))
83
+ return String(value)
77
84
  }), reverse)
78
85
  }
79
86
 
@@ -1,8 +1,9 @@
1
- import { describe, expect, test } from "bun:test"
1
+ import { describe, expect, expectTypeOf, test } from "bun:test"
2
2
 
3
3
  import { db, getIds, seedUsers } from "#testing"
4
4
 
5
5
  import { createPagePaginator } from "./factory"
6
+ import type { PagePaginationPage, PagePaginationPageWithTotal } from "./paginator"
6
7
 
7
8
  describe("createPagePaginator", () => {
8
9
  test("creates a reusable paginator with config", async () => {
@@ -18,4 +19,34 @@ describe("createPagePaginator", () => {
18
19
  expect(getIds(page.items)).toEqual([1, 2])
19
20
  expect(page.nextPage).toBe(2)
20
21
  })
22
+
23
+ test("creates a reusable paginator with total: true", async () => {
24
+ await seedUsers([
25
+ { id: 1, name: "a", score: 10, group: "one" },
26
+ { id: 2, name: "b", score: 20, group: "one" },
27
+ { id: 3, name: "c", score: 30, group: "one" },
28
+ ])
29
+
30
+ const paginate = createPagePaginator({ limit: 2, total: true })
31
+ const page = await paginate(db.user.order({ id: "ASC" }))
32
+
33
+ expect(getIds(page.items)).toEqual([1, 2])
34
+ expect(page).toMatchObject({ page: 1, limit: 2, offset: 0, nextPage: 2, totalItems: 3, totalPages: 2 })
35
+ expect(page.prevPage).toBeUndefined()
36
+ })
37
+
38
+ test("without total returns PagePaginationPage type", () => {
39
+ const paginate = createPagePaginator({ limit: 10 })
40
+ const result = paginate(db.user)
41
+ expectTypeOf(result).resolves.toExtend<PagePaginationPage>()
42
+ expectTypeOf(result).resolves.not.toExtend<PagePaginationPageWithTotal>()
43
+ })
44
+
45
+ test("with total: true returns PagePaginationPageWithTotal type", () => {
46
+ const paginate = createPagePaginator({ limit: 10, total: true })
47
+ const result = paginate(db.user)
48
+ expectTypeOf(result).resolves.toExtend<PagePaginationPageWithTotal>()
49
+ expectTypeOf(result).resolves.toHaveProperty("totalItems")
50
+ expectTypeOf(result).resolves.toHaveProperty("totalPages")
51
+ })
21
52
  })
@@ -1,10 +1,24 @@
1
- import type { PaginationConfig } from "../../limit"
2
1
  import type { ListQuery } from "../../types"
3
2
 
4
- import { type PagePaginationParams, paginateByPage } from "./paginator"
3
+ import type {
4
+ PagePaginationConfig,
5
+ PagePaginationConfigWithTotal,
6
+ PagePaginationPage,
7
+ PagePaginationPageWithTotal,
8
+ PagePaginationParams,
9
+ } from "./paginator"
10
+ import { paginateByPage } from "./paginator"
5
11
 
6
12
  /** createPagePaginator creates a reusable page paginator with the given config. */
7
- export function createPagePaginator(config?: PaginationConfig) {
13
+ export function createPagePaginator(config: PagePaginationConfigWithTotal): <T extends ListQuery>(
14
+ query: T,
15
+ params?: PagePaginationParams,
16
+ ) => Promise<PagePaginationPageWithTotal<T>>
17
+ export function createPagePaginator(config?: PagePaginationConfig): <T extends ListQuery>(
18
+ query: T,
19
+ params?: PagePaginationParams,
20
+ ) => Promise<PagePaginationPage<T>>
21
+ export function createPagePaginator(config?: PagePaginationConfig | PagePaginationConfigWithTotal) {
8
22
  return function paginate<T extends ListQuery>(query: T, params?: PagePaginationParams) {
9
23
  return paginateByPage(query, config, params)
10
24
  }
@@ -1,8 +1,8 @@
1
- import { describe, expect, test } from "bun:test"
1
+ import { describe, expect, expectTypeOf, test } from "bun:test"
2
2
 
3
3
  import { db, getIds, seedUsers } from "#testing"
4
4
 
5
- import { paginateByPage } from "./paginator"
5
+ import { type PagePaginationPage, type PagePaginationPageWithTotal, paginateByPage } from "./paginator"
6
6
 
7
7
  describe("paginateByPage", () => {
8
8
  test("returns the first page with a next page", async () => {
@@ -61,6 +61,108 @@ describe("paginateByPage", () => {
61
61
  expect(page.prevPage).toBeUndefined()
62
62
  })
63
63
 
64
+ test("returns empty items for a page beyond available data", async () => {
65
+ await seedUsers([
66
+ { id: 1, name: "a", score: 10, group: "one" },
67
+ { id: 2, name: "b", score: 20, group: "one" },
68
+ { id: 3, name: "c", score: 30, group: "one" },
69
+ ])
70
+
71
+ // 3 items with limit 2 = 2 pages; requesting page 5
72
+ const page = await paginateByPage(db.user.order({ id: "ASC" }), { limit: 2 }, { page: 5 })
73
+
74
+ expect(page.items).toEqual([])
75
+ expect(page).toMatchObject({ page: 5, limit: 2, offset: 8, prevPage: 4 })
76
+ expect(page.nextPage).toBeUndefined()
77
+ })
78
+
79
+ test("with total: true, includes total and totalPages", async () => {
80
+ await seedUsers([
81
+ { id: 1, name: "a", score: 10, group: "one" },
82
+ { id: 2, name: "b", score: 20, group: "one" },
83
+ { id: 3, name: "c", score: 30, group: "one" },
84
+ { id: 4, name: "d", score: 40, group: "one" },
85
+ { id: 5, name: "e", score: 50, group: "one" },
86
+ ])
87
+
88
+ const page = await paginateByPage(db.user.order({ id: "ASC" }), { limit: 2, total: true }, { page: 2 })
89
+
90
+ expect(getIds(page.items)).toEqual([3, 4])
91
+ expect(page).toMatchObject({ page: 2, limit: 2, offset: 2, prevPage: 1, nextPage: 3, totalItems: 5, totalPages: 3 })
92
+ })
93
+
94
+ test("with total: true without clampPage, does not clamp page beyond last", async () => {
95
+ await seedUsers([
96
+ { id: 1, name: "a", score: 10, group: "one" },
97
+ { id: 2, name: "b", score: 20, group: "one" },
98
+ { id: 3, name: "c", score: 30, group: "one" },
99
+ ])
100
+
101
+ // 3 items with limit 2 = 2 pages; requesting page 10
102
+ const page = await paginateByPage(db.user.order({ id: "ASC" }), { limit: 2, total: true }, { page: 10 })
103
+
104
+ expect(page.items).toEqual([])
105
+ expect(page).toMatchObject({ page: 10, limit: 2, offset: 18, prevPage: 9, totalItems: 3, totalPages: 2 })
106
+ expect(page.nextPage).toBeUndefined()
107
+ })
108
+
109
+ test("with total: true and clampPage: true, clamps page to last when requested beyond", async () => {
110
+ await seedUsers([
111
+ { id: 1, name: "a", score: 10, group: "one" },
112
+ { id: 2, name: "b", score: 20, group: "one" },
113
+ { id: 3, name: "c", score: 30, group: "one" },
114
+ ])
115
+
116
+ // 3 items with limit 2 = 2 pages; requesting page 10
117
+ const page = await paginateByPage(db.user.order({ id: "ASC" }), { limit: 2, total: true, clampPage: true }, { page: 10 })
118
+
119
+ expect(getIds(page.items)).toEqual([3])
120
+ expect(page).toMatchObject({ page: 2, limit: 2, offset: 2, prevPage: 1, totalItems: 3, totalPages: 2 })
121
+ expect(page.nextPage).toBeUndefined()
122
+ })
123
+
124
+ test("with total: true, handles empty table", async () => {
125
+ const page = await paginateByPage(db.user.order({ id: "ASC" }), { limit: 2, total: true })
126
+
127
+ expect(page.items).toEqual([])
128
+ expect(page).toMatchObject({ page: 1, limit: 2, offset: 0, totalItems: 0, totalPages: 1 })
129
+ expect(page.prevPage).toBeUndefined()
130
+ expect(page.nextPage).toBeUndefined()
131
+ })
132
+
133
+ test("with total: true, single-page result", async () => {
134
+ await seedUsers([
135
+ { id: 1, name: "a", score: 10, group: "one" },
136
+ { id: 2, name: "b", score: 20, group: "one" },
137
+ ])
138
+
139
+ const page = await paginateByPage(db.user.order({ id: "ASC" }), { limit: 5, total: true })
140
+
141
+ expect(getIds(page.items)).toEqual([1, 2])
142
+ expect(page).toMatchObject({ page: 1, limit: 5, offset: 0, totalItems: 2, totalPages: 1 })
143
+ expect(page.prevPage).toBeUndefined()
144
+ expect(page.nextPage).toBeUndefined()
145
+ })
146
+
147
+ test("without total returns PagePaginationPage type", () => {
148
+ const result = paginateByPage(db.user, { limit: 10 })
149
+ expectTypeOf(result).resolves.toExtend<PagePaginationPage>()
150
+ expectTypeOf(result).resolves.not.toExtend<PagePaginationPageWithTotal>()
151
+ })
152
+
153
+ test("with total: true returns PagePaginationPageWithTotal type", () => {
154
+ const result = paginateByPage(db.user, { limit: 10, total: true })
155
+ expectTypeOf(result).resolves.toExtend<PagePaginationPageWithTotal>()
156
+ expectTypeOf(result).resolves.toHaveProperty("totalItems")
157
+ expectTypeOf(result).resolves.toHaveProperty("totalPages")
158
+ })
159
+
160
+ test("clampPage requires total: true", () => {
161
+ // @ts-expect-error — clampPage is not valid without total: true
162
+ paginateByPage(db.user, { limit: 10, clampPage: true })
163
+ expectTypeOf(paginateByPage<typeof db.user>).toBeCallableWith(db.user, { limit: 10, total: true, clampPage: true })
164
+ })
165
+
64
166
  test("clamps requested limit by config", async () => {
65
167
  await seedUsers([
66
168
  { id: 1, name: "a", score: 10, group: "one" },
@@ -1,45 +1,104 @@
1
1
  import { getLimit, type PaginationConfig } from "../../limit"
2
2
  import type { ListQuery } from "../../types"
3
3
 
4
+ export type PagePaginationConfig = PaginationConfig & {
5
+ /**
6
+ * When true, runs a COUNT(*) query and returns `totalItems` / `totalPages` in the result.
7
+ * Requested pages beyond the last are not clamped unless `clampPage` is true.
8
+ */
9
+ total?: boolean
10
+ }
11
+
12
+ export type PagePaginationConfigWithTotal = PagePaginationConfig & {
13
+ total: true
14
+ /** When true, requested pages beyond the last are clamped to the last page. */
15
+ clampPage?: boolean
16
+ }
17
+
4
18
  export interface PagePaginationParams {
5
- /** Page, 1-based. */
19
+ /** Page number, 1-based. */
6
20
  page?: number
7
- /** Limit. */
21
+ /** Page size. */
8
22
  limit?: number
9
23
  }
10
24
 
11
25
  export type PagePaginationPage<T extends ListQuery = ListQuery> = {
12
26
  items: Awaited<T>
13
- /** Effective page number. */
27
+ /** Effective page number, 1-based. */
14
28
  page: number
15
- /** Effective limit. Number of items is guaranteed to be less or equal. */
29
+ /** Effective page size. Number of items is guaranteed to be less than or equal to this value. */
16
30
  limit: number
17
- /** Offset of the first item, 1-based. */
31
+ /** Offset passed to the query, 0-based. */
18
32
  offset: number
19
- /** Prev page number (if exists). */
33
+ /** Previous page number, if it exists. */
20
34
  prevPage?: number
21
- /** Next page number (if exists). */
35
+ /** Next page number, if it exists. */
22
36
  nextPage?: number
23
37
  }
24
38
 
39
+ export type PagePaginationTotal = {
40
+ /** Total number of items across all pages. */
41
+ totalItems: number
42
+ /** Total number of pages. */
43
+ totalPages: number
44
+ }
45
+
46
+ export type PagePaginationPageWithTotal<T extends ListQuery = ListQuery> = PagePaginationPage<T> & PagePaginationTotal
47
+
25
48
  /** paginateByPage returns one page of results using offset-based pagination. */
26
- export async function paginateByPage<T extends ListQuery>(query: T, config?: PaginationConfig, params?: PagePaginationParams): Promise<PagePaginationPage<T>> {
49
+ export async function paginateByPage<T extends ListQuery>(
50
+ query: T,
51
+ config: PagePaginationConfigWithTotal,
52
+ params?: PagePaginationParams,
53
+ ): Promise<PagePaginationPageWithTotal<T>>
54
+ export async function paginateByPage<T extends ListQuery>(
55
+ query: T,
56
+ config?: PagePaginationConfig,
57
+ params?: PagePaginationParams,
58
+ ): Promise<PagePaginationPage<T>>
59
+ export async function paginateByPage<T extends ListQuery>(
60
+ query: T,
61
+ config?: PagePaginationConfig | PagePaginationConfigWithTotal,
62
+ params?: PagePaginationParams,
63
+ ) {
27
64
  const limit = getLimit(query, config, params)
28
65
 
29
- const page = Math.max(1, params?.page ?? 1)
66
+ let page = Math.max(1, params?.page ?? 1)
67
+
68
+ let total: PagePaginationTotal | undefined
69
+ if (config?.total) {
70
+ const totalItems = await (query as ListQuery).clear("select", "order").count() as number
71
+ const totalPages = Math.max(1, Math.ceil(totalItems / limit))
72
+ total = { totalItems, totalPages }
73
+ if ((config as PagePaginationConfigWithTotal).clampPage) {
74
+ page = Math.min(page, totalPages)
75
+ }
76
+ }
77
+
30
78
  const offset = (page - 1) * limit
31
79
 
32
- const items = await (query as ListQuery).offset(offset).limit(limit + 1) as Awaited<T>
80
+ // When total is known, we can request exactly `limit` items;
81
+ // otherwise, request limit + 1 to detect continuation.
82
+ const items = await (query as ListQuery)
83
+ .offset(offset)
84
+ .limit(total ? limit : limit + 1) as Awaited<T>
33
85
  if (!Array.isArray(items)) {
34
86
  throw new TypeError("Query must return an array.")
35
87
  }
36
- const hasContinuation = items.length > limit
37
- if (hasContinuation) {
88
+
89
+ const hasContinuation = total ? page < total.totalPages : items.length > limit
90
+ if (items.length > limit) {
38
91
  items.splice(limit)
39
92
  }
40
93
 
41
94
  const prevPage = page > 1 ? page - 1 : undefined
42
95
  const nextPage = hasContinuation ? page + 1 : undefined
43
96
 
44
- return { items, page, limit, offset, prevPage, nextPage }
97
+ const result: PagePaginationPage<T> = { items, page, limit, offset, prevPage, nextPage }
98
+
99
+ if (total) {
100
+ return { ...result, ...total } as PagePaginationPageWithTotal<T>
101
+ }
102
+
103
+ return result
45
104
  }