@geekmidas/db 0.1.0 → 0.2.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/kysely/pagination.cjs +83 -0
- package/dist/kysely/pagination.cjs.map +1 -0
- package/dist/kysely/pagination.d.cts +78 -0
- package/dist/kysely/pagination.d.mts +78 -0
- package/dist/kysely/pagination.mjs +79 -0
- package/dist/kysely/pagination.mjs.map +1 -0
- package/package.json +6 -1
- package/src/kysely/__tests__/pagination.integration.spec.ts +341 -0
- package/src/kysely/pagination.ts +150 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/kysely/pagination.ts
|
|
3
|
+
/**
|
|
4
|
+
* Sort direction for cursor-based pagination.
|
|
5
|
+
*/
|
|
6
|
+
let Direction = /* @__PURE__ */ function(Direction$1) {
|
|
7
|
+
Direction$1["Asc"] = "asc";
|
|
8
|
+
Direction$1["Desc"] = "desc";
|
|
9
|
+
return Direction$1;
|
|
10
|
+
}({});
|
|
11
|
+
/**
|
|
12
|
+
* Generic paginated search function that handles:
|
|
13
|
+
* - Total count calculation
|
|
14
|
+
* - Cursor-based pagination
|
|
15
|
+
* - Fetching one extra row to determine hasMore
|
|
16
|
+
* - Mapping rows to output format
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```typescript
|
|
20
|
+
* const result = await paginatedSearch({
|
|
21
|
+
* query: db.selectFrom('users').selectAll(),
|
|
22
|
+
* cursor: previousCursor,
|
|
23
|
+
* limit: 20,
|
|
24
|
+
* mapRow: (row) => ({ id: row.id, name: row.name }),
|
|
25
|
+
* cursorField: 'id',
|
|
26
|
+
* cursorDirection: Direction.Asc,
|
|
27
|
+
* });
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
async function paginatedSearch({ query, cursor, limit = 20, mapRow, cursorField = "id", cursorDirection = Direction.Asc }) {
|
|
31
|
+
const countResult = await query.clearSelect().clearOrderBy().select((eb) => eb.fn.countAll().as("count")).executeTakeFirstOrThrow();
|
|
32
|
+
const count = countResult.count;
|
|
33
|
+
let paginatedQuery = query;
|
|
34
|
+
if (cursor) {
|
|
35
|
+
const operator = cursorDirection === Direction.Asc ? ">" : "<";
|
|
36
|
+
paginatedQuery = paginatedQuery.where(cursorField, operator, cursor);
|
|
37
|
+
}
|
|
38
|
+
const data = await paginatedQuery.orderBy(cursorField, cursorDirection).limit(limit + 1).execute();
|
|
39
|
+
const hasMore = data.length > limit;
|
|
40
|
+
const rows = hasMore ? data.slice(0, limit) : data;
|
|
41
|
+
const lastRow = rows[rows.length - 1];
|
|
42
|
+
const nextCursor = hasMore && lastRow ? String(lastRow[cursorField]) : void 0;
|
|
43
|
+
const items = await Promise.all(rows.map(mapRow));
|
|
44
|
+
return {
|
|
45
|
+
items,
|
|
46
|
+
pagination: {
|
|
47
|
+
total: Number(count),
|
|
48
|
+
hasMore,
|
|
49
|
+
cursor: nextCursor
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Encode a cursor value for safe URL transmission.
|
|
55
|
+
* Supports various types: string, number, Date, etc.
|
|
56
|
+
*/
|
|
57
|
+
function encodeCursor(value) {
|
|
58
|
+
const payload = {
|
|
59
|
+
v: value instanceof Date ? value.toISOString() : value,
|
|
60
|
+
t: value instanceof Date ? "date" : typeof value
|
|
61
|
+
};
|
|
62
|
+
return Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Decode a cursor string back to its original value.
|
|
66
|
+
*/
|
|
67
|
+
function decodeCursor(cursor) {
|
|
68
|
+
try {
|
|
69
|
+
const json = Buffer.from(cursor, "base64url").toString("utf-8");
|
|
70
|
+
const payload = JSON.parse(json);
|
|
71
|
+
if (payload.t === "date") return new Date(payload.v);
|
|
72
|
+
return payload.v;
|
|
73
|
+
} catch {
|
|
74
|
+
throw new Error("Invalid cursor format");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
//#endregion
|
|
79
|
+
exports.Direction = Direction;
|
|
80
|
+
exports.decodeCursor = decodeCursor;
|
|
81
|
+
exports.encodeCursor = encodeCursor;
|
|
82
|
+
exports.paginatedSearch = paginatedSearch;
|
|
83
|
+
//# sourceMappingURL=pagination.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pagination.cjs","names":["value: unknown","cursor: string"],"sources":["../../src/kysely/pagination.ts"],"sourcesContent":["import type { SelectQueryBuilder } from 'kysely';\n\n/**\n * Sort direction for cursor-based pagination.\n */\nexport enum Direction {\n Asc = 'asc',\n Desc = 'desc',\n}\n\n/**\n * Result of a paginated query.\n */\nexport interface PaginationResult<TItem> {\n items: TItem[];\n pagination: {\n total: number;\n hasMore: boolean;\n cursor?: string;\n };\n}\n\n/**\n * Options for paginated search.\n */\nexport interface PaginatedSearchOptions<\n TRow,\n TMapRow extends (row: TRow) => unknown,\n> {\n /** The base Kysely query to paginate */\n query: SelectQueryBuilder<any, any, TRow>;\n /** Cursor value for pagination (value of cursorField from previous page) */\n cursor?: string;\n /** Maximum number of items per page (default: 20) */\n limit?: number;\n /** Function to transform each row to the output format */\n mapRow: TMapRow;\n /** Field to use for cursor pagination (default: 'id') */\n cursorField?: string;\n /** Sort direction for cursor field (default: Direction.Asc) */\n cursorDirection?: Direction;\n}\n\n/**\n * Generic paginated search function that handles:\n * - Total count calculation\n * - Cursor-based pagination\n * - Fetching one extra row to determine hasMore\n * - Mapping rows to output format\n *\n * @example\n * ```typescript\n * const result = await paginatedSearch({\n * query: db.selectFrom('users').selectAll(),\n * cursor: previousCursor,\n * limit: 20,\n * mapRow: (row) => ({ id: row.id, name: row.name }),\n * cursorField: 'id',\n * cursorDirection: Direction.Asc,\n * });\n * ```\n */\nexport async function paginatedSearch<\n TRow extends Record<string, unknown>,\n TMapRow extends (row: TRow) => unknown,\n>({\n query,\n cursor,\n limit = 20,\n mapRow,\n cursorField = 'id',\n cursorDirection = Direction.Asc,\n}: PaginatedSearchOptions<TRow, TMapRow>): Promise<\n PaginationResult<Awaited<ReturnType<TMapRow>>>\n> {\n // Get total count (without cursor)\n const countResult = await query\n .clearSelect()\n .clearOrderBy()\n .select((eb) => eb.fn.countAll().as('count'))\n .executeTakeFirstOrThrow();\n\n const count = countResult.count;\n\n // Apply cursor if provided\n let paginatedQuery = query;\n if (cursor) {\n const operator = cursorDirection === Direction.Asc ? '>' : '<';\n paginatedQuery = paginatedQuery.where(\n cursorField as any,\n operator,\n cursor,\n ) as typeof query;\n }\n\n // Fetch one extra to determine if there are more results\n const data = await paginatedQuery\n .orderBy(cursorField as any, cursorDirection)\n .limit(limit + 1)\n .execute();\n\n const hasMore = data.length > limit;\n const rows = hasMore ? data.slice(0, limit) : data;\n const lastRow = rows[rows.length - 1];\n const nextCursor =\n hasMore && lastRow ? String(lastRow[cursorField]) : undefined;\n\n const items = (await Promise.all(rows.map(mapRow))) as Awaited<\n ReturnType<TMapRow>\n >[];\n\n return {\n items,\n pagination: {\n total: Number(count),\n hasMore,\n cursor: nextCursor,\n },\n };\n}\n\n/**\n * Encode a cursor value for safe URL transmission.\n * Supports various types: string, number, Date, etc.\n */\nexport function encodeCursor(value: unknown): string {\n const payload = {\n v: value instanceof Date ? value.toISOString() : value,\n t: value instanceof Date ? 'date' : typeof value,\n };\n return Buffer.from(JSON.stringify(payload)).toString('base64url');\n}\n\n/**\n * Decode a cursor string back to its original value.\n */\nexport function decodeCursor(cursor: string): unknown {\n try {\n const json = Buffer.from(cursor, 'base64url').toString('utf-8');\n const payload = JSON.parse(json);\n\n if (payload.t === 'date') {\n return new Date(payload.v);\n }\n\n return payload.v;\n } catch {\n throw new Error('Invalid cursor format');\n }\n}\n"],"mappings":";;;;;AAKA,IAAY,kDAAL;AACL;AACA;;AACD;;;;;;;;;;;;;;;;;;;;AAsDD,eAAsB,gBAGpB,EACA,OACA,QACA,QAAQ,IACR,QACA,cAAc,MACd,kBAAkB,UAAU,KACU,EAEtC;CAEA,MAAM,cAAc,MAAM,MACvB,aAAa,CACb,cAAc,CACd,OAAO,CAAC,OAAO,GAAG,GAAG,UAAU,CAAC,GAAG,QAAQ,CAAC,CAC5C,yBAAyB;CAE5B,MAAM,QAAQ,YAAY;CAG1B,IAAI,iBAAiB;AACrB,KAAI,QAAQ;EACV,MAAM,WAAW,oBAAoB,UAAU,MAAM,MAAM;AAC3D,mBAAiB,eAAe,MAC9B,aACA,UACA,OACD;CACF;CAGD,MAAM,OAAO,MAAM,eAChB,QAAQ,aAAoB,gBAAgB,CAC5C,MAAM,QAAQ,EAAE,CAChB,SAAS;CAEZ,MAAM,UAAU,KAAK,SAAS;CAC9B,MAAM,OAAO,UAAU,KAAK,MAAM,GAAG,MAAM,GAAG;CAC9C,MAAM,UAAU,KAAK,KAAK,SAAS;CACnC,MAAM,aACJ,WAAW,UAAU,OAAO,QAAQ,aAAa;CAEnD,MAAM,QAAS,MAAM,QAAQ,IAAI,KAAK,IAAI,OAAO,CAAC;AAIlD,QAAO;EACL;EACA,YAAY;GACV,OAAO,OAAO,MAAM;GACpB;GACA,QAAQ;EACT;CACF;AACF;;;;;AAMD,SAAgB,aAAaA,OAAwB;CACnD,MAAM,UAAU;EACd,GAAG,iBAAiB,OAAO,MAAM,aAAa,GAAG;EACjD,GAAG,iBAAiB,OAAO,gBAAgB;CAC5C;AACD,QAAO,OAAO,KAAK,KAAK,UAAU,QAAQ,CAAC,CAAC,SAAS,YAAY;AAClE;;;;AAKD,SAAgB,aAAaC,QAAyB;AACpD,KAAI;EACF,MAAM,OAAO,OAAO,KAAK,QAAQ,YAAY,CAAC,SAAS,QAAQ;EAC/D,MAAM,UAAU,KAAK,MAAM,KAAK;AAEhC,MAAI,QAAQ,MAAM,OAChB,QAAO,IAAI,KAAK,QAAQ;AAG1B,SAAO,QAAQ;CAChB,QAAO;AACN,QAAM,IAAI,MAAM;CACjB;AACF"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { SelectQueryBuilder } from "kysely";
|
|
2
|
+
|
|
3
|
+
//#region src/kysely/pagination.d.ts
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sort direction for cursor-based pagination.
|
|
7
|
+
*/
|
|
8
|
+
declare enum Direction {
|
|
9
|
+
Asc = "asc",
|
|
10
|
+
Desc = "desc",
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Result of a paginated query.
|
|
14
|
+
*/
|
|
15
|
+
interface PaginationResult<TItem> {
|
|
16
|
+
items: TItem[];
|
|
17
|
+
pagination: {
|
|
18
|
+
total: number;
|
|
19
|
+
hasMore: boolean;
|
|
20
|
+
cursor?: string;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Options for paginated search.
|
|
25
|
+
*/
|
|
26
|
+
interface PaginatedSearchOptions<TRow, TMapRow extends (row: TRow) => unknown> {
|
|
27
|
+
/** The base Kysely query to paginate */
|
|
28
|
+
query: SelectQueryBuilder<any, any, TRow>;
|
|
29
|
+
/** Cursor value for pagination (value of cursorField from previous page) */
|
|
30
|
+
cursor?: string;
|
|
31
|
+
/** Maximum number of items per page (default: 20) */
|
|
32
|
+
limit?: number;
|
|
33
|
+
/** Function to transform each row to the output format */
|
|
34
|
+
mapRow: TMapRow;
|
|
35
|
+
/** Field to use for cursor pagination (default: 'id') */
|
|
36
|
+
cursorField?: string;
|
|
37
|
+
/** Sort direction for cursor field (default: Direction.Asc) */
|
|
38
|
+
cursorDirection?: Direction;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Generic paginated search function that handles:
|
|
42
|
+
* - Total count calculation
|
|
43
|
+
* - Cursor-based pagination
|
|
44
|
+
* - Fetching one extra row to determine hasMore
|
|
45
|
+
* - Mapping rows to output format
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```typescript
|
|
49
|
+
* const result = await paginatedSearch({
|
|
50
|
+
* query: db.selectFrom('users').selectAll(),
|
|
51
|
+
* cursor: previousCursor,
|
|
52
|
+
* limit: 20,
|
|
53
|
+
* mapRow: (row) => ({ id: row.id, name: row.name }),
|
|
54
|
+
* cursorField: 'id',
|
|
55
|
+
* cursorDirection: Direction.Asc,
|
|
56
|
+
* });
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
declare function paginatedSearch<TRow extends Record<string, unknown>, TMapRow extends (row: TRow) => unknown>({
|
|
60
|
+
query,
|
|
61
|
+
cursor,
|
|
62
|
+
limit,
|
|
63
|
+
mapRow,
|
|
64
|
+
cursorField,
|
|
65
|
+
cursorDirection
|
|
66
|
+
}: PaginatedSearchOptions<TRow, TMapRow>): Promise<PaginationResult<Awaited<ReturnType<TMapRow>>>>;
|
|
67
|
+
/**
|
|
68
|
+
* Encode a cursor value for safe URL transmission.
|
|
69
|
+
* Supports various types: string, number, Date, etc.
|
|
70
|
+
*/
|
|
71
|
+
declare function encodeCursor(value: unknown): string;
|
|
72
|
+
/**
|
|
73
|
+
* Decode a cursor string back to its original value.
|
|
74
|
+
*/
|
|
75
|
+
declare function decodeCursor(cursor: string): unknown;
|
|
76
|
+
//#endregion
|
|
77
|
+
export { Direction, PaginatedSearchOptions, PaginationResult, decodeCursor, encodeCursor, paginatedSearch };
|
|
78
|
+
//# sourceMappingURL=pagination.d.cts.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { SelectQueryBuilder } from "kysely";
|
|
2
|
+
|
|
3
|
+
//#region src/kysely/pagination.d.ts
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sort direction for cursor-based pagination.
|
|
7
|
+
*/
|
|
8
|
+
declare enum Direction {
|
|
9
|
+
Asc = "asc",
|
|
10
|
+
Desc = "desc",
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Result of a paginated query.
|
|
14
|
+
*/
|
|
15
|
+
interface PaginationResult<TItem> {
|
|
16
|
+
items: TItem[];
|
|
17
|
+
pagination: {
|
|
18
|
+
total: number;
|
|
19
|
+
hasMore: boolean;
|
|
20
|
+
cursor?: string;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Options for paginated search.
|
|
25
|
+
*/
|
|
26
|
+
interface PaginatedSearchOptions<TRow, TMapRow extends (row: TRow) => unknown> {
|
|
27
|
+
/** The base Kysely query to paginate */
|
|
28
|
+
query: SelectQueryBuilder<any, any, TRow>;
|
|
29
|
+
/** Cursor value for pagination (value of cursorField from previous page) */
|
|
30
|
+
cursor?: string;
|
|
31
|
+
/** Maximum number of items per page (default: 20) */
|
|
32
|
+
limit?: number;
|
|
33
|
+
/** Function to transform each row to the output format */
|
|
34
|
+
mapRow: TMapRow;
|
|
35
|
+
/** Field to use for cursor pagination (default: 'id') */
|
|
36
|
+
cursorField?: string;
|
|
37
|
+
/** Sort direction for cursor field (default: Direction.Asc) */
|
|
38
|
+
cursorDirection?: Direction;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Generic paginated search function that handles:
|
|
42
|
+
* - Total count calculation
|
|
43
|
+
* - Cursor-based pagination
|
|
44
|
+
* - Fetching one extra row to determine hasMore
|
|
45
|
+
* - Mapping rows to output format
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```typescript
|
|
49
|
+
* const result = await paginatedSearch({
|
|
50
|
+
* query: db.selectFrom('users').selectAll(),
|
|
51
|
+
* cursor: previousCursor,
|
|
52
|
+
* limit: 20,
|
|
53
|
+
* mapRow: (row) => ({ id: row.id, name: row.name }),
|
|
54
|
+
* cursorField: 'id',
|
|
55
|
+
* cursorDirection: Direction.Asc,
|
|
56
|
+
* });
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
declare function paginatedSearch<TRow extends Record<string, unknown>, TMapRow extends (row: TRow) => unknown>({
|
|
60
|
+
query,
|
|
61
|
+
cursor,
|
|
62
|
+
limit,
|
|
63
|
+
mapRow,
|
|
64
|
+
cursorField,
|
|
65
|
+
cursorDirection
|
|
66
|
+
}: PaginatedSearchOptions<TRow, TMapRow>): Promise<PaginationResult<Awaited<ReturnType<TMapRow>>>>;
|
|
67
|
+
/**
|
|
68
|
+
* Encode a cursor value for safe URL transmission.
|
|
69
|
+
* Supports various types: string, number, Date, etc.
|
|
70
|
+
*/
|
|
71
|
+
declare function encodeCursor(value: unknown): string;
|
|
72
|
+
/**
|
|
73
|
+
* Decode a cursor string back to its original value.
|
|
74
|
+
*/
|
|
75
|
+
declare function decodeCursor(cursor: string): unknown;
|
|
76
|
+
//#endregion
|
|
77
|
+
export { Direction, PaginatedSearchOptions, PaginationResult, decodeCursor, encodeCursor, paginatedSearch };
|
|
78
|
+
//# sourceMappingURL=pagination.d.mts.map
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
//#region src/kysely/pagination.ts
|
|
2
|
+
/**
|
|
3
|
+
* Sort direction for cursor-based pagination.
|
|
4
|
+
*/
|
|
5
|
+
let Direction = /* @__PURE__ */ function(Direction$1) {
|
|
6
|
+
Direction$1["Asc"] = "asc";
|
|
7
|
+
Direction$1["Desc"] = "desc";
|
|
8
|
+
return Direction$1;
|
|
9
|
+
}({});
|
|
10
|
+
/**
|
|
11
|
+
* Generic paginated search function that handles:
|
|
12
|
+
* - Total count calculation
|
|
13
|
+
* - Cursor-based pagination
|
|
14
|
+
* - Fetching one extra row to determine hasMore
|
|
15
|
+
* - Mapping rows to output format
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```typescript
|
|
19
|
+
* const result = await paginatedSearch({
|
|
20
|
+
* query: db.selectFrom('users').selectAll(),
|
|
21
|
+
* cursor: previousCursor,
|
|
22
|
+
* limit: 20,
|
|
23
|
+
* mapRow: (row) => ({ id: row.id, name: row.name }),
|
|
24
|
+
* cursorField: 'id',
|
|
25
|
+
* cursorDirection: Direction.Asc,
|
|
26
|
+
* });
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
async function paginatedSearch({ query, cursor, limit = 20, mapRow, cursorField = "id", cursorDirection = Direction.Asc }) {
|
|
30
|
+
const countResult = await query.clearSelect().clearOrderBy().select((eb) => eb.fn.countAll().as("count")).executeTakeFirstOrThrow();
|
|
31
|
+
const count = countResult.count;
|
|
32
|
+
let paginatedQuery = query;
|
|
33
|
+
if (cursor) {
|
|
34
|
+
const operator = cursorDirection === Direction.Asc ? ">" : "<";
|
|
35
|
+
paginatedQuery = paginatedQuery.where(cursorField, operator, cursor);
|
|
36
|
+
}
|
|
37
|
+
const data = await paginatedQuery.orderBy(cursorField, cursorDirection).limit(limit + 1).execute();
|
|
38
|
+
const hasMore = data.length > limit;
|
|
39
|
+
const rows = hasMore ? data.slice(0, limit) : data;
|
|
40
|
+
const lastRow = rows[rows.length - 1];
|
|
41
|
+
const nextCursor = hasMore && lastRow ? String(lastRow[cursorField]) : void 0;
|
|
42
|
+
const items = await Promise.all(rows.map(mapRow));
|
|
43
|
+
return {
|
|
44
|
+
items,
|
|
45
|
+
pagination: {
|
|
46
|
+
total: Number(count),
|
|
47
|
+
hasMore,
|
|
48
|
+
cursor: nextCursor
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Encode a cursor value for safe URL transmission.
|
|
54
|
+
* Supports various types: string, number, Date, etc.
|
|
55
|
+
*/
|
|
56
|
+
function encodeCursor(value) {
|
|
57
|
+
const payload = {
|
|
58
|
+
v: value instanceof Date ? value.toISOString() : value,
|
|
59
|
+
t: value instanceof Date ? "date" : typeof value
|
|
60
|
+
};
|
|
61
|
+
return Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Decode a cursor string back to its original value.
|
|
65
|
+
*/
|
|
66
|
+
function decodeCursor(cursor) {
|
|
67
|
+
try {
|
|
68
|
+
const json = Buffer.from(cursor, "base64url").toString("utf-8");
|
|
69
|
+
const payload = JSON.parse(json);
|
|
70
|
+
if (payload.t === "date") return new Date(payload.v);
|
|
71
|
+
return payload.v;
|
|
72
|
+
} catch {
|
|
73
|
+
throw new Error("Invalid cursor format");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
//#endregion
|
|
78
|
+
export { Direction, decodeCursor, encodeCursor, paginatedSearch };
|
|
79
|
+
//# sourceMappingURL=pagination.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pagination.mjs","names":["value: unknown","cursor: string"],"sources":["../../src/kysely/pagination.ts"],"sourcesContent":["import type { SelectQueryBuilder } from 'kysely';\n\n/**\n * Sort direction for cursor-based pagination.\n */\nexport enum Direction {\n Asc = 'asc',\n Desc = 'desc',\n}\n\n/**\n * Result of a paginated query.\n */\nexport interface PaginationResult<TItem> {\n items: TItem[];\n pagination: {\n total: number;\n hasMore: boolean;\n cursor?: string;\n };\n}\n\n/**\n * Options for paginated search.\n */\nexport interface PaginatedSearchOptions<\n TRow,\n TMapRow extends (row: TRow) => unknown,\n> {\n /** The base Kysely query to paginate */\n query: SelectQueryBuilder<any, any, TRow>;\n /** Cursor value for pagination (value of cursorField from previous page) */\n cursor?: string;\n /** Maximum number of items per page (default: 20) */\n limit?: number;\n /** Function to transform each row to the output format */\n mapRow: TMapRow;\n /** Field to use for cursor pagination (default: 'id') */\n cursorField?: string;\n /** Sort direction for cursor field (default: Direction.Asc) */\n cursorDirection?: Direction;\n}\n\n/**\n * Generic paginated search function that handles:\n * - Total count calculation\n * - Cursor-based pagination\n * - Fetching one extra row to determine hasMore\n * - Mapping rows to output format\n *\n * @example\n * ```typescript\n * const result = await paginatedSearch({\n * query: db.selectFrom('users').selectAll(),\n * cursor: previousCursor,\n * limit: 20,\n * mapRow: (row) => ({ id: row.id, name: row.name }),\n * cursorField: 'id',\n * cursorDirection: Direction.Asc,\n * });\n * ```\n */\nexport async function paginatedSearch<\n TRow extends Record<string, unknown>,\n TMapRow extends (row: TRow) => unknown,\n>({\n query,\n cursor,\n limit = 20,\n mapRow,\n cursorField = 'id',\n cursorDirection = Direction.Asc,\n}: PaginatedSearchOptions<TRow, TMapRow>): Promise<\n PaginationResult<Awaited<ReturnType<TMapRow>>>\n> {\n // Get total count (without cursor)\n const countResult = await query\n .clearSelect()\n .clearOrderBy()\n .select((eb) => eb.fn.countAll().as('count'))\n .executeTakeFirstOrThrow();\n\n const count = countResult.count;\n\n // Apply cursor if provided\n let paginatedQuery = query;\n if (cursor) {\n const operator = cursorDirection === Direction.Asc ? '>' : '<';\n paginatedQuery = paginatedQuery.where(\n cursorField as any,\n operator,\n cursor,\n ) as typeof query;\n }\n\n // Fetch one extra to determine if there are more results\n const data = await paginatedQuery\n .orderBy(cursorField as any, cursorDirection)\n .limit(limit + 1)\n .execute();\n\n const hasMore = data.length > limit;\n const rows = hasMore ? data.slice(0, limit) : data;\n const lastRow = rows[rows.length - 1];\n const nextCursor =\n hasMore && lastRow ? String(lastRow[cursorField]) : undefined;\n\n const items = (await Promise.all(rows.map(mapRow))) as Awaited<\n ReturnType<TMapRow>\n >[];\n\n return {\n items,\n pagination: {\n total: Number(count),\n hasMore,\n cursor: nextCursor,\n },\n };\n}\n\n/**\n * Encode a cursor value for safe URL transmission.\n * Supports various types: string, number, Date, etc.\n */\nexport function encodeCursor(value: unknown): string {\n const payload = {\n v: value instanceof Date ? value.toISOString() : value,\n t: value instanceof Date ? 'date' : typeof value,\n };\n return Buffer.from(JSON.stringify(payload)).toString('base64url');\n}\n\n/**\n * Decode a cursor string back to its original value.\n */\nexport function decodeCursor(cursor: string): unknown {\n try {\n const json = Buffer.from(cursor, 'base64url').toString('utf-8');\n const payload = JSON.parse(json);\n\n if (payload.t === 'date') {\n return new Date(payload.v);\n }\n\n return payload.v;\n } catch {\n throw new Error('Invalid cursor format');\n }\n}\n"],"mappings":";;;;AAKA,IAAY,kDAAL;AACL;AACA;;AACD;;;;;;;;;;;;;;;;;;;;AAsDD,eAAsB,gBAGpB,EACA,OACA,QACA,QAAQ,IACR,QACA,cAAc,MACd,kBAAkB,UAAU,KACU,EAEtC;CAEA,MAAM,cAAc,MAAM,MACvB,aAAa,CACb,cAAc,CACd,OAAO,CAAC,OAAO,GAAG,GAAG,UAAU,CAAC,GAAG,QAAQ,CAAC,CAC5C,yBAAyB;CAE5B,MAAM,QAAQ,YAAY;CAG1B,IAAI,iBAAiB;AACrB,KAAI,QAAQ;EACV,MAAM,WAAW,oBAAoB,UAAU,MAAM,MAAM;AAC3D,mBAAiB,eAAe,MAC9B,aACA,UACA,OACD;CACF;CAGD,MAAM,OAAO,MAAM,eAChB,QAAQ,aAAoB,gBAAgB,CAC5C,MAAM,QAAQ,EAAE,CAChB,SAAS;CAEZ,MAAM,UAAU,KAAK,SAAS;CAC9B,MAAM,OAAO,UAAU,KAAK,MAAM,GAAG,MAAM,GAAG;CAC9C,MAAM,UAAU,KAAK,KAAK,SAAS;CACnC,MAAM,aACJ,WAAW,UAAU,OAAO,QAAQ,aAAa;CAEnD,MAAM,QAAS,MAAM,QAAQ,IAAI,KAAK,IAAI,OAAO,CAAC;AAIlD,QAAO;EACL;EACA,YAAY;GACV,OAAO,OAAO,MAAM;GACpB;GACA,QAAQ;EACT;CACF;AACF;;;;;AAMD,SAAgB,aAAaA,OAAwB;CACnD,MAAM,UAAU;EACd,GAAG,iBAAiB,OAAO,MAAM,aAAa,GAAG;EACjD,GAAG,iBAAiB,OAAO,gBAAgB;CAC5C;AACD,QAAO,OAAO,KAAK,KAAK,UAAU,QAAQ,CAAC,CAAC,SAAS,YAAY;AAClE;;;;AAKD,SAAgB,aAAaC,QAAyB;AACpD,KAAI;EACF,MAAM,OAAO,OAAO,KAAK,QAAQ,YAAY,CAAC,SAAS,QAAQ;EAC/D,MAAM,UAAU,KAAK,MAAM,KAAK;AAEhC,MAAI,QAAQ,MAAM,OAChB,QAAO,IAAI,KAAK,QAAQ;AAG1B,SAAO,QAAQ;CAChB,QAAO;AACN,QAAM,IAAI,MAAM;CACjB;AACF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geekmidas/db",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
"import": "./dist/kysely.mjs",
|
|
10
10
|
"require": "./dist/kysely.cjs"
|
|
11
11
|
},
|
|
12
|
+
"./kysely/pagination": {
|
|
13
|
+
"types": "./dist/kysely/pagination.d.ts",
|
|
14
|
+
"import": "./dist/kysely/pagination.mjs",
|
|
15
|
+
"require": "./dist/kysely/pagination.cjs"
|
|
16
|
+
},
|
|
12
17
|
"./rls": {
|
|
13
18
|
"types": "./dist/rls.d.ts",
|
|
14
19
|
"import": "./dist/rls.mjs",
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CamelCasePlugin,
|
|
3
|
+
type Generated,
|
|
4
|
+
Kysely,
|
|
5
|
+
PostgresDialect,
|
|
6
|
+
sql,
|
|
7
|
+
} from 'kysely';
|
|
8
|
+
import pg from 'pg';
|
|
9
|
+
import {
|
|
10
|
+
afterAll,
|
|
11
|
+
afterEach,
|
|
12
|
+
beforeAll,
|
|
13
|
+
beforeEach,
|
|
14
|
+
describe,
|
|
15
|
+
expect,
|
|
16
|
+
it,
|
|
17
|
+
} from 'vitest';
|
|
18
|
+
import { TEST_DATABASE_CONFIG } from '../../../../testkit/test/globalSetup';
|
|
19
|
+
import {
|
|
20
|
+
Direction,
|
|
21
|
+
decodeCursor,
|
|
22
|
+
encodeCursor,
|
|
23
|
+
paginatedSearch,
|
|
24
|
+
} from '../pagination';
|
|
25
|
+
|
|
26
|
+
interface TestDatabase {
|
|
27
|
+
paginationTestItems: {
|
|
28
|
+
id: Generated<number>;
|
|
29
|
+
name: string;
|
|
30
|
+
category: string;
|
|
31
|
+
price: number;
|
|
32
|
+
createdAt: Generated<Date>;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('Pagination Integration Tests', () => {
|
|
37
|
+
let db: Kysely<TestDatabase>;
|
|
38
|
+
|
|
39
|
+
beforeAll(async () => {
|
|
40
|
+
db = new Kysely<TestDatabase>({
|
|
41
|
+
dialect: new PostgresDialect({
|
|
42
|
+
pool: new pg.Pool({
|
|
43
|
+
...TEST_DATABASE_CONFIG,
|
|
44
|
+
database: 'postgres',
|
|
45
|
+
}),
|
|
46
|
+
}),
|
|
47
|
+
plugins: [new CamelCasePlugin()],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Create test table
|
|
51
|
+
await db.schema
|
|
52
|
+
.createTable('pagination_test_items')
|
|
53
|
+
.ifNotExists()
|
|
54
|
+
.addColumn('id', 'serial', (col) => col.primaryKey())
|
|
55
|
+
.addColumn('name', 'varchar(255)', (col) => col.notNull())
|
|
56
|
+
.addColumn('category', 'varchar(100)', (col) => col.notNull())
|
|
57
|
+
.addColumn('price', 'numeric(10, 2)', (col) => col.notNull())
|
|
58
|
+
.addColumn('created_at', 'timestamptz', (col) =>
|
|
59
|
+
col.defaultTo(sql`now()`).notNull(),
|
|
60
|
+
)
|
|
61
|
+
.execute();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
beforeEach(async () => {
|
|
65
|
+
// Insert 25 test items
|
|
66
|
+
const items = [];
|
|
67
|
+
for (let i = 1; i <= 25; i++) {
|
|
68
|
+
items.push({
|
|
69
|
+
name: `Item ${String(i).padStart(2, '0')}`,
|
|
70
|
+
category:
|
|
71
|
+
i <= 10 ? 'category-a' : i <= 20 ? 'category-b' : 'category-c',
|
|
72
|
+
price: i * 10,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
await db.insertInto('paginationTestItems').values(items).execute();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterEach(async () => {
|
|
79
|
+
await db.deleteFrom('paginationTestItems').execute();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterAll(async () => {
|
|
83
|
+
await db.schema.dropTable('pagination_test_items').ifExists().execute();
|
|
84
|
+
await db.destroy();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('paginatedSearch', () => {
|
|
88
|
+
it('should return first page of results with default settings', async () => {
|
|
89
|
+
const result = await paginatedSearch({
|
|
90
|
+
query: db.selectFrom('paginationTestItems').selectAll(),
|
|
91
|
+
limit: 10,
|
|
92
|
+
mapRow: (row) => ({ id: row.id, name: row.name }),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(result.items).toHaveLength(10);
|
|
96
|
+
expect(result.pagination.total).toBe(25);
|
|
97
|
+
expect(result.pagination.hasMore).toBe(true);
|
|
98
|
+
expect(result.pagination.cursor).toBeDefined();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should return second page using cursor', async () => {
|
|
102
|
+
// Get first page
|
|
103
|
+
const firstPage = await paginatedSearch({
|
|
104
|
+
query: db.selectFrom('paginationTestItems').selectAll(),
|
|
105
|
+
limit: 10,
|
|
106
|
+
mapRow: (row) => ({ id: row.id, name: row.name }),
|
|
107
|
+
cursorDirection: Direction.Asc,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(firstPage.pagination.cursor).toBeDefined();
|
|
111
|
+
|
|
112
|
+
// Get second page using cursor
|
|
113
|
+
const secondPage = await paginatedSearch({
|
|
114
|
+
query: db.selectFrom('paginationTestItems').selectAll(),
|
|
115
|
+
cursor: firstPage.pagination.cursor,
|
|
116
|
+
limit: 10,
|
|
117
|
+
mapRow: (row) => ({ id: row.id, name: row.name }),
|
|
118
|
+
cursorDirection: Direction.Asc,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(secondPage.items).toHaveLength(10);
|
|
122
|
+
expect(secondPage.pagination.total).toBe(25);
|
|
123
|
+
expect(secondPage.pagination.hasMore).toBe(true);
|
|
124
|
+
|
|
125
|
+
// Items should be different from first page
|
|
126
|
+
const firstIds = firstPage.items.map((i) => i.id);
|
|
127
|
+
const secondIds = secondPage.items.map((i) => i.id);
|
|
128
|
+
expect(firstIds.every((id) => !secondIds.includes(id))).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should return last page with hasMore false', async () => {
|
|
132
|
+
// Get all pages
|
|
133
|
+
let cursor: string | undefined;
|
|
134
|
+
let pages: { id: number; name: string }[][] = [];
|
|
135
|
+
|
|
136
|
+
do {
|
|
137
|
+
const page = await paginatedSearch({
|
|
138
|
+
query: db.selectFrom('paginationTestItems').selectAll(),
|
|
139
|
+
cursor,
|
|
140
|
+
limit: 10,
|
|
141
|
+
mapRow: (row) => ({ id: row.id, name: row.name }),
|
|
142
|
+
cursorDirection: Direction.Asc,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
pages.push(page.items);
|
|
146
|
+
cursor = page.pagination.cursor;
|
|
147
|
+
|
|
148
|
+
if (!page.pagination.hasMore) break;
|
|
149
|
+
} while (cursor);
|
|
150
|
+
|
|
151
|
+
// Should have 3 pages
|
|
152
|
+
expect(pages).toHaveLength(3);
|
|
153
|
+
expect(pages[0]).toHaveLength(10);
|
|
154
|
+
expect(pages[1]).toHaveLength(10);
|
|
155
|
+
expect(pages[2]).toHaveLength(5);
|
|
156
|
+
|
|
157
|
+
// Total items should be 25
|
|
158
|
+
const allItems = pages.flat();
|
|
159
|
+
expect(allItems).toHaveLength(25);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should paginate in descending order', async () => {
|
|
163
|
+
const result = await paginatedSearch({
|
|
164
|
+
query: db.selectFrom('paginationTestItems').selectAll(),
|
|
165
|
+
limit: 5,
|
|
166
|
+
mapRow: (row) => ({ id: row.id, name: row.name }),
|
|
167
|
+
cursorDirection: Direction.Desc,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// First item should have highest ID
|
|
171
|
+
const ids = result.items.map((i) => i.id);
|
|
172
|
+
for (let i = 1; i < ids.length; i++) {
|
|
173
|
+
expect(ids[i]).toBeLessThan(ids[i - 1]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Get second page
|
|
177
|
+
const secondPage = await paginatedSearch({
|
|
178
|
+
query: db.selectFrom('paginationTestItems').selectAll(),
|
|
179
|
+
cursor: result.pagination.cursor,
|
|
180
|
+
limit: 5,
|
|
181
|
+
mapRow: (row) => ({ id: row.id, name: row.name }),
|
|
182
|
+
cursorDirection: Direction.Desc,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// All IDs in second page should be less than all in first page
|
|
186
|
+
const maxSecondPage = Math.max(...secondPage.items.map((i) => i.id));
|
|
187
|
+
const minFirstPage = Math.min(...ids);
|
|
188
|
+
expect(maxSecondPage).toBeLessThan(minFirstPage);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should work with custom cursor field', async () => {
|
|
192
|
+
const result = await paginatedSearch({
|
|
193
|
+
query: db.selectFrom('paginationTestItems').selectAll(),
|
|
194
|
+
limit: 5,
|
|
195
|
+
mapRow: (row) => ({ id: row.id, name: row.name, price: row.price }),
|
|
196
|
+
cursorField: 'price',
|
|
197
|
+
cursorDirection: Direction.Asc,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Prices should be in ascending order
|
|
201
|
+
const prices = result.items.map((i) => Number(i.price));
|
|
202
|
+
for (let i = 1; i < prices.length; i++) {
|
|
203
|
+
expect(prices[i]).toBeGreaterThan(prices[i - 1]);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Cursor should contain the last price value
|
|
207
|
+
expect(Number(result.pagination.cursor)).toBe(prices[prices.length - 1]);
|
|
208
|
+
|
|
209
|
+
// Get second page
|
|
210
|
+
const secondPage = await paginatedSearch({
|
|
211
|
+
query: db.selectFrom('paginationTestItems').selectAll(),
|
|
212
|
+
cursor: result.pagination.cursor,
|
|
213
|
+
limit: 5,
|
|
214
|
+
mapRow: (row) => ({ id: row.id, name: row.name, price: row.price }),
|
|
215
|
+
cursorField: 'price',
|
|
216
|
+
cursorDirection: Direction.Asc,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// All prices in second page should be greater than cursor
|
|
220
|
+
const minSecondPage = Math.min(
|
|
221
|
+
...secondPage.items.map((i) => Number(i.price)),
|
|
222
|
+
);
|
|
223
|
+
expect(minSecondPage).toBeGreaterThan(prices[prices.length - 1]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should use default limit of 20', async () => {
|
|
227
|
+
const result = await paginatedSearch({
|
|
228
|
+
query: db.selectFrom('paginationTestItems').selectAll(),
|
|
229
|
+
mapRow: (row) => ({ id: row.id }),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
expect(result.items).toHaveLength(20);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('should work with filtered queries', async () => {
|
|
236
|
+
const result = await paginatedSearch({
|
|
237
|
+
query: db
|
|
238
|
+
.selectFrom('paginationTestItems')
|
|
239
|
+
.selectAll()
|
|
240
|
+
.where('category', '=', 'category-a'),
|
|
241
|
+
limit: 5,
|
|
242
|
+
mapRow: (row) => ({
|
|
243
|
+
id: row.id,
|
|
244
|
+
name: row.name,
|
|
245
|
+
category: row.category,
|
|
246
|
+
}),
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(result.pagination.total).toBe(10); // Only category-a items
|
|
250
|
+
expect(result.items).toHaveLength(5);
|
|
251
|
+
result.items.forEach((item) => {
|
|
252
|
+
expect(item.category).toBe('category-a');
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('should handle empty result set', async () => {
|
|
257
|
+
const result = await paginatedSearch({
|
|
258
|
+
query: db
|
|
259
|
+
.selectFrom('paginationTestItems')
|
|
260
|
+
.selectAll()
|
|
261
|
+
.where('category', '=', 'nonexistent'),
|
|
262
|
+
limit: 10,
|
|
263
|
+
mapRow: (row) => ({ id: row.id }),
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
expect(result.items).toHaveLength(0);
|
|
267
|
+
expect(result.pagination.total).toBe(0);
|
|
268
|
+
expect(result.pagination.hasMore).toBe(false);
|
|
269
|
+
expect(result.pagination.cursor).toBeUndefined();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('should handle async mapRow function', async () => {
|
|
273
|
+
const result = await paginatedSearch({
|
|
274
|
+
query: db.selectFrom('paginationTestItems').selectAll(),
|
|
275
|
+
limit: 5,
|
|
276
|
+
mapRow: async (row) => {
|
|
277
|
+
// Simulate async transformation
|
|
278
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
279
|
+
return {
|
|
280
|
+
id: row.id,
|
|
281
|
+
displayName: `Product: ${row.name}`,
|
|
282
|
+
priceFormatted: `$${Number(row.price).toFixed(2)}`,
|
|
283
|
+
};
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
expect(result.items).toHaveLength(5);
|
|
288
|
+
result.items.forEach((item) => {
|
|
289
|
+
expect(item.displayName).toMatch(/^Product: Item \d+$/);
|
|
290
|
+
expect(item.priceFormatted).toMatch(/^\$\d+\.\d{2}$/);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe('encodeCursor / decodeCursor', () => {
|
|
296
|
+
it('should encode and decode string values', () => {
|
|
297
|
+
const original = 'some-cursor-value';
|
|
298
|
+
const encoded = encodeCursor(original);
|
|
299
|
+
const decoded = decodeCursor(encoded);
|
|
300
|
+
|
|
301
|
+
expect(decoded).toBe(original);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('should encode and decode number values', () => {
|
|
305
|
+
const original = 12345;
|
|
306
|
+
const encoded = encodeCursor(original);
|
|
307
|
+
const decoded = decodeCursor(encoded);
|
|
308
|
+
|
|
309
|
+
expect(decoded).toBe(original);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('should encode and decode Date values', () => {
|
|
313
|
+
const original = new Date('2024-01-15T10:30:00.000Z');
|
|
314
|
+
const encoded = encodeCursor(original);
|
|
315
|
+
const decoded = decodeCursor(encoded);
|
|
316
|
+
|
|
317
|
+
expect(decoded).toEqual(original);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('should produce URL-safe base64 encoding', () => {
|
|
321
|
+
const encoded = encodeCursor('test/value+with=special');
|
|
322
|
+
|
|
323
|
+
// Base64url should not contain +, /, or =
|
|
324
|
+
expect(encoded).not.toMatch(/[+/=]/);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('should throw error for invalid cursor format', () => {
|
|
328
|
+
expect(() => decodeCursor('invalid-cursor')).toThrow(
|
|
329
|
+
'Invalid cursor format',
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('should throw error for malformed JSON', () => {
|
|
334
|
+
// Create valid base64url but with invalid JSON content
|
|
335
|
+
const malformedCursor = Buffer.from('not-json').toString('base64url');
|
|
336
|
+
expect(() => decodeCursor(malformedCursor)).toThrow(
|
|
337
|
+
'Invalid cursor format',
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { SelectQueryBuilder } from 'kysely';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sort direction for cursor-based pagination.
|
|
5
|
+
*/
|
|
6
|
+
export enum Direction {
|
|
7
|
+
Asc = 'asc',
|
|
8
|
+
Desc = 'desc',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Result of a paginated query.
|
|
13
|
+
*/
|
|
14
|
+
export interface PaginationResult<TItem> {
|
|
15
|
+
items: TItem[];
|
|
16
|
+
pagination: {
|
|
17
|
+
total: number;
|
|
18
|
+
hasMore: boolean;
|
|
19
|
+
cursor?: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Options for paginated search.
|
|
25
|
+
*/
|
|
26
|
+
export interface PaginatedSearchOptions<
|
|
27
|
+
TRow,
|
|
28
|
+
TMapRow extends (row: TRow) => unknown,
|
|
29
|
+
> {
|
|
30
|
+
/** The base Kysely query to paginate */
|
|
31
|
+
query: SelectQueryBuilder<any, any, TRow>;
|
|
32
|
+
/** Cursor value for pagination (value of cursorField from previous page) */
|
|
33
|
+
cursor?: string;
|
|
34
|
+
/** Maximum number of items per page (default: 20) */
|
|
35
|
+
limit?: number;
|
|
36
|
+
/** Function to transform each row to the output format */
|
|
37
|
+
mapRow: TMapRow;
|
|
38
|
+
/** Field to use for cursor pagination (default: 'id') */
|
|
39
|
+
cursorField?: string;
|
|
40
|
+
/** Sort direction for cursor field (default: Direction.Asc) */
|
|
41
|
+
cursorDirection?: Direction;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Generic paginated search function that handles:
|
|
46
|
+
* - Total count calculation
|
|
47
|
+
* - Cursor-based pagination
|
|
48
|
+
* - Fetching one extra row to determine hasMore
|
|
49
|
+
* - Mapping rows to output format
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```typescript
|
|
53
|
+
* const result = await paginatedSearch({
|
|
54
|
+
* query: db.selectFrom('users').selectAll(),
|
|
55
|
+
* cursor: previousCursor,
|
|
56
|
+
* limit: 20,
|
|
57
|
+
* mapRow: (row) => ({ id: row.id, name: row.name }),
|
|
58
|
+
* cursorField: 'id',
|
|
59
|
+
* cursorDirection: Direction.Asc,
|
|
60
|
+
* });
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export async function paginatedSearch<
|
|
64
|
+
TRow extends Record<string, unknown>,
|
|
65
|
+
TMapRow extends (row: TRow) => unknown,
|
|
66
|
+
>({
|
|
67
|
+
query,
|
|
68
|
+
cursor,
|
|
69
|
+
limit = 20,
|
|
70
|
+
mapRow,
|
|
71
|
+
cursorField = 'id',
|
|
72
|
+
cursorDirection = Direction.Asc,
|
|
73
|
+
}: PaginatedSearchOptions<TRow, TMapRow>): Promise<
|
|
74
|
+
PaginationResult<Awaited<ReturnType<TMapRow>>>
|
|
75
|
+
> {
|
|
76
|
+
// Get total count (without cursor)
|
|
77
|
+
const countResult = await query
|
|
78
|
+
.clearSelect()
|
|
79
|
+
.clearOrderBy()
|
|
80
|
+
.select((eb) => eb.fn.countAll().as('count'))
|
|
81
|
+
.executeTakeFirstOrThrow();
|
|
82
|
+
|
|
83
|
+
const count = countResult.count;
|
|
84
|
+
|
|
85
|
+
// Apply cursor if provided
|
|
86
|
+
let paginatedQuery = query;
|
|
87
|
+
if (cursor) {
|
|
88
|
+
const operator = cursorDirection === Direction.Asc ? '>' : '<';
|
|
89
|
+
paginatedQuery = paginatedQuery.where(
|
|
90
|
+
cursorField as any,
|
|
91
|
+
operator,
|
|
92
|
+
cursor,
|
|
93
|
+
) as typeof query;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Fetch one extra to determine if there are more results
|
|
97
|
+
const data = await paginatedQuery
|
|
98
|
+
.orderBy(cursorField as any, cursorDirection)
|
|
99
|
+
.limit(limit + 1)
|
|
100
|
+
.execute();
|
|
101
|
+
|
|
102
|
+
const hasMore = data.length > limit;
|
|
103
|
+
const rows = hasMore ? data.slice(0, limit) : data;
|
|
104
|
+
const lastRow = rows[rows.length - 1];
|
|
105
|
+
const nextCursor =
|
|
106
|
+
hasMore && lastRow ? String(lastRow[cursorField]) : undefined;
|
|
107
|
+
|
|
108
|
+
const items = (await Promise.all(rows.map(mapRow))) as Awaited<
|
|
109
|
+
ReturnType<TMapRow>
|
|
110
|
+
>[];
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
items,
|
|
114
|
+
pagination: {
|
|
115
|
+
total: Number(count),
|
|
116
|
+
hasMore,
|
|
117
|
+
cursor: nextCursor,
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Encode a cursor value for safe URL transmission.
|
|
124
|
+
* Supports various types: string, number, Date, etc.
|
|
125
|
+
*/
|
|
126
|
+
export function encodeCursor(value: unknown): string {
|
|
127
|
+
const payload = {
|
|
128
|
+
v: value instanceof Date ? value.toISOString() : value,
|
|
129
|
+
t: value instanceof Date ? 'date' : typeof value,
|
|
130
|
+
};
|
|
131
|
+
return Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Decode a cursor string back to its original value.
|
|
136
|
+
*/
|
|
137
|
+
export function decodeCursor(cursor: string): unknown {
|
|
138
|
+
try {
|
|
139
|
+
const json = Buffer.from(cursor, 'base64url').toString('utf-8');
|
|
140
|
+
const payload = JSON.parse(json);
|
|
141
|
+
|
|
142
|
+
if (payload.t === 'date') {
|
|
143
|
+
return new Date(payload.v);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return payload.v;
|
|
147
|
+
} catch {
|
|
148
|
+
throw new Error('Invalid cursor format');
|
|
149
|
+
}
|
|
150
|
+
}
|