@geekmidas/db 1.0.0 → 1.0.2

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/kysely/pagination.cjs +15 -41
  3. package/dist/kysely/pagination.cjs.map +1 -1
  4. package/dist/kysely/pagination.d.cts +12 -34
  5. package/dist/kysely/pagination.d.cts.map +1 -1
  6. package/dist/kysely/pagination.d.mts +12 -34
  7. package/dist/kysely/pagination.d.mts.map +1 -1
  8. package/dist/kysely/pagination.mjs +11 -36
  9. package/dist/kysely/pagination.mjs.map +1 -1
  10. package/dist/{kysely-DUdsB0BP.d.mts → kysely-Dm1w5gAY.d.mts} +1 -1
  11. package/dist/{kysely-DUdsB0BP.d.mts.map → kysely-Dm1w5gAY.d.mts.map} +1 -1
  12. package/dist/kysely.d.mts +1 -1
  13. package/dist/objection/pagination.cjs +50 -0
  14. package/dist/objection/pagination.cjs.map +1 -0
  15. package/dist/objection/pagination.d.cts +51 -0
  16. package/dist/objection/pagination.d.cts.map +1 -0
  17. package/dist/objection/pagination.d.mts +51 -0
  18. package/dist/objection/pagination.d.mts.map +1 -0
  19. package/dist/objection/pagination.mjs +47 -0
  20. package/dist/objection/pagination.mjs.map +1 -0
  21. package/dist/pagination-BAFX7C1I.d.cts +32 -0
  22. package/dist/pagination-BAFX7C1I.d.cts.map +1 -0
  23. package/dist/pagination-BDLa7Yb_.mjs +37 -0
  24. package/dist/pagination-BDLa7Yb_.mjs.map +1 -0
  25. package/dist/pagination-Bdoa4PVj.cjs +55 -0
  26. package/dist/pagination-Bdoa4PVj.cjs.map +1 -0
  27. package/dist/pagination-BziGl-B8.d.mts +32 -0
  28. package/dist/pagination-BziGl-B8.d.mts.map +1 -0
  29. package/dist/pagination.cjs +5 -0
  30. package/dist/pagination.d.cts +2 -0
  31. package/dist/pagination.d.mts +2 -0
  32. package/dist/pagination.mjs +3 -0
  33. package/dist/rls.d.mts +1 -1
  34. package/package.json +45 -10
  35. package/src/__tests__/rls.spec.ts +272 -0
  36. package/src/kysely/pagination.ts +20 -57
  37. package/src/objection/__tests__/pagination.integration.spec.ts +291 -0
  38. package/src/objection/pagination.ts +98 -0
  39. package/src/pagination.ts +49 -0
@@ -1,31 +1,19 @@
1
1
  import type { SelectQueryBuilder } from 'kysely';
2
+ import {
3
+ Direction,
4
+ decodeCursor,
5
+ encodeCursor,
6
+ type PaginationResult,
7
+ } from '../pagination';
2
8
 
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
- }
9
+ export { Direction, decodeCursor, encodeCursor, type PaginationResult };
22
10
 
23
11
  /**
24
12
  * Options for paginated search.
25
13
  */
26
14
  export interface PaginatedSearchOptions<
27
15
  TRow,
28
- TMapRow extends (row: TRow) => unknown,
16
+ TMapRow extends (row: TRow) => unknown = (row: TRow) => TRow,
29
17
  > {
30
18
  /** The base Kysely query to paginate */
31
19
  query: SelectQueryBuilder<any, any, TRow>;
@@ -33,8 +21,8 @@ export interface PaginatedSearchOptions<
33
21
  cursor?: string;
34
22
  /** Maximum number of items per page (default: 20) */
35
23
  limit?: number;
36
- /** Function to transform each row to the output format */
37
- mapRow: TMapRow;
24
+ /** Function to transform each row to the output format (default: identity) */
25
+ mapRow?: TMapRow;
38
26
  /** Field to use for cursor pagination (default: 'id') */
39
27
  cursorField?: string;
40
28
  /** Sort direction for cursor field (default: Direction.Asc) */
@@ -50,19 +38,23 @@ export interface PaginatedSearchOptions<
50
38
  *
51
39
  * @example
52
40
  * ```typescript
41
+ * // With mapRow
53
42
  * const result = await paginatedSearch({
54
43
  * query: db.selectFrom('users').selectAll(),
55
- * cursor: previousCursor,
56
44
  * limit: 20,
57
45
  * mapRow: (row) => ({ id: row.id, name: row.name }),
58
- * cursorField: 'id',
59
- * cursorDirection: Direction.Asc,
46
+ * });
47
+ *
48
+ * // Without mapRow — items are the raw row type
49
+ * const result = await paginatedSearch({
50
+ * query: db.selectFrom('users').selectAll(),
51
+ * limit: 20,
60
52
  * });
61
53
  * ```
62
54
  */
63
55
  export async function paginatedSearch<
64
56
  TRow extends Record<string, unknown>,
65
- TMapRow extends (row: TRow) => unknown,
57
+ TMapRow extends (row: TRow) => unknown = (row: TRow) => TRow,
66
58
  >({
67
59
  query,
68
60
  cursor,
@@ -105,7 +97,8 @@ export async function paginatedSearch<
105
97
  const nextCursor =
106
98
  hasMore && lastRow ? String(lastRow[cursorField]) : undefined;
107
99
 
108
- const items = (await Promise.all(rows.map(mapRow))) as Awaited<
100
+ const mapper = mapRow ?? ((row: TRow) => row);
101
+ const items = (await Promise.all(rows.map(mapper))) as Awaited<
109
102
  ReturnType<TMapRow>
110
103
  >[];
111
104
 
@@ -118,33 +111,3 @@ export async function paginatedSearch<
118
111
  },
119
112
  };
120
113
  }
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
- }
@@ -0,0 +1,291 @@
1
+ import knex, { type Knex } from 'knex';
2
+ import { Model } from 'objection';
3
+ import {
4
+ afterAll,
5
+ afterEach,
6
+ beforeAll,
7
+ beforeEach,
8
+ describe,
9
+ expect,
10
+ it,
11
+ } from 'vitest';
12
+ import { TEST_DATABASE_CONFIG } from '../../../../testkit/test/globalSetup';
13
+ import { Direction, paginatedSearch } from '../pagination';
14
+
15
+ class PaginationTestItem extends Model {
16
+ static tableName = 'objection_pagination_test_items';
17
+
18
+ id!: number;
19
+ name!: string;
20
+ category!: string;
21
+ price!: number;
22
+ createdAt!: Date;
23
+ }
24
+
25
+ describe('Objection Pagination Integration Tests', () => {
26
+ let db: Knex;
27
+
28
+ beforeAll(async () => {
29
+ db = knex({
30
+ client: 'pg',
31
+ connection: {
32
+ ...TEST_DATABASE_CONFIG,
33
+ database: 'postgres',
34
+ },
35
+ });
36
+
37
+ Model.knex(db);
38
+
39
+ await db.schema.createTableIfNotExists(
40
+ 'objection_pagination_test_items',
41
+ (table) => {
42
+ table.increments('id').primary();
43
+ table.string('name', 255).notNullable();
44
+ table.string('category', 100).notNullable();
45
+ table.decimal('price', 10, 2).notNullable();
46
+ table.timestamp('created_at').defaultTo(db.fn.now()).notNullable();
47
+ },
48
+ );
49
+ });
50
+
51
+ beforeEach(async () => {
52
+ const items = [];
53
+ for (let i = 1; i <= 25; i++) {
54
+ items.push({
55
+ name: `Item ${String(i).padStart(2, '0')}`,
56
+ category:
57
+ i <= 10 ? 'category-a' : i <= 20 ? 'category-b' : 'category-c',
58
+ price: i * 10,
59
+ });
60
+ }
61
+ await PaginationTestItem.query().insert(items);
62
+ });
63
+
64
+ afterEach(async () => {
65
+ await PaginationTestItem.query().delete();
66
+ });
67
+
68
+ afterAll(async () => {
69
+ await db.schema.dropTableIfExists('objection_pagination_test_items');
70
+ await db.destroy();
71
+ });
72
+
73
+ describe('paginatedSearch', () => {
74
+ it('should return first page of results with default settings', async () => {
75
+ const result = await paginatedSearch({
76
+ query: PaginationTestItem.query(),
77
+ limit: 10,
78
+ });
79
+
80
+ expect(result.items).toHaveLength(10);
81
+ expect(result.pagination.total).toBe(25);
82
+ expect(result.pagination.hasMore).toBe(true);
83
+ expect(result.pagination.cursor).toBeDefined();
84
+ });
85
+
86
+ it('should return model instances when no mapRow is provided', async () => {
87
+ const result = await paginatedSearch({
88
+ query: PaginationTestItem.query(),
89
+ limit: 5,
90
+ });
91
+
92
+ expect(result.items[0]).toBeInstanceOf(PaginationTestItem);
93
+ expect(result.items[0].name).toBeDefined();
94
+ });
95
+
96
+ it('should return second page using cursor', async () => {
97
+ const firstPage = await paginatedSearch({
98
+ query: PaginationTestItem.query(),
99
+ limit: 10,
100
+ cursorDirection: Direction.Asc,
101
+ });
102
+
103
+ expect(firstPage.pagination.cursor).toBeDefined();
104
+
105
+ const secondPage = await paginatedSearch({
106
+ query: PaginationTestItem.query(),
107
+ cursor: firstPage.pagination.cursor,
108
+ limit: 10,
109
+ cursorDirection: Direction.Asc,
110
+ });
111
+
112
+ expect(secondPage.items).toHaveLength(10);
113
+ expect(secondPage.pagination.total).toBe(25);
114
+ expect(secondPage.pagination.hasMore).toBe(true);
115
+
116
+ const firstIds = firstPage.items.map((i) => i.id);
117
+ const secondIds = secondPage.items.map((i) => i.id);
118
+ expect(firstIds.every((id) => !secondIds.includes(id))).toBe(true);
119
+ });
120
+
121
+ it('should return last page with hasMore false', async () => {
122
+ let cursor: string | undefined;
123
+ const pages: PaginationTestItem[][] = [];
124
+
125
+ do {
126
+ const page = await paginatedSearch({
127
+ query: PaginationTestItem.query(),
128
+ cursor,
129
+ limit: 10,
130
+ cursorDirection: Direction.Asc,
131
+ });
132
+
133
+ pages.push(page.items);
134
+ cursor = page.pagination.cursor;
135
+
136
+ if (!page.pagination.hasMore) break;
137
+ } while (cursor);
138
+
139
+ expect(pages).toHaveLength(3);
140
+ expect(pages[0]).toHaveLength(10);
141
+ expect(pages[1]).toHaveLength(10);
142
+ expect(pages[2]).toHaveLength(5);
143
+
144
+ const allItems = pages.flat();
145
+ expect(allItems).toHaveLength(25);
146
+ });
147
+
148
+ it('should paginate in descending order', async () => {
149
+ const result = await paginatedSearch({
150
+ query: PaginationTestItem.query(),
151
+ limit: 5,
152
+ cursorDirection: Direction.Desc,
153
+ });
154
+
155
+ const ids = result.items.map((i) => i.id);
156
+ for (let i = 1; i < ids.length; i++) {
157
+ expect(ids[i]).toBeLessThan(ids[i - 1]);
158
+ }
159
+
160
+ const secondPage = await paginatedSearch({
161
+ query: PaginationTestItem.query(),
162
+ cursor: result.pagination.cursor,
163
+ limit: 5,
164
+ cursorDirection: Direction.Desc,
165
+ });
166
+
167
+ const maxSecondPage = Math.max(...secondPage.items.map((i) => i.id));
168
+ const minFirstPage = Math.min(...ids);
169
+ expect(maxSecondPage).toBeLessThan(minFirstPage);
170
+ });
171
+
172
+ it('should work with custom cursor field', async () => {
173
+ const result = await paginatedSearch({
174
+ query: PaginationTestItem.query(),
175
+ limit: 5,
176
+ mapRow: (row) => ({ id: row.id, name: row.name, price: row.price }),
177
+ cursorField: 'price',
178
+ cursorDirection: Direction.Asc,
179
+ });
180
+
181
+ const prices = result.items.map((i) => Number(i.price));
182
+ for (let i = 1; i < prices.length; i++) {
183
+ expect(prices[i]).toBeGreaterThan(prices[i - 1]);
184
+ }
185
+
186
+ expect(Number(result.pagination.cursor)).toBe(prices[prices.length - 1]);
187
+
188
+ const secondPage = await paginatedSearch({
189
+ query: PaginationTestItem.query(),
190
+ cursor: result.pagination.cursor,
191
+ limit: 5,
192
+ mapRow: (row) => ({ id: row.id, name: row.name, price: row.price }),
193
+ cursorField: 'price',
194
+ cursorDirection: Direction.Asc,
195
+ });
196
+
197
+ const minSecondPage = Math.min(
198
+ ...secondPage.items.map((i) => Number(i.price)),
199
+ );
200
+ expect(minSecondPage).toBeGreaterThan(prices[prices.length - 1]);
201
+ });
202
+
203
+ it('should use default limit of 20', async () => {
204
+ const result = await paginatedSearch({
205
+ query: PaginationTestItem.query(),
206
+ });
207
+
208
+ expect(result.items).toHaveLength(20);
209
+ });
210
+
211
+ it('should work with filtered queries', async () => {
212
+ const result = await paginatedSearch({
213
+ query: PaginationTestItem.query().where('category', 'category-a'),
214
+ limit: 5,
215
+ mapRow: (row) => ({
216
+ id: row.id,
217
+ name: row.name,
218
+ category: row.category,
219
+ }),
220
+ });
221
+
222
+ expect(result.pagination.total).toBe(10);
223
+ expect(result.items).toHaveLength(5);
224
+ for (const item of result.items) {
225
+ expect(item.category).toBe('category-a');
226
+ }
227
+ });
228
+
229
+ it('should handle empty result set', async () => {
230
+ const result = await paginatedSearch({
231
+ query: PaginationTestItem.query().where('category', 'nonexistent'),
232
+ limit: 10,
233
+ });
234
+
235
+ expect(result.items).toHaveLength(0);
236
+ expect(result.pagination.total).toBe(0);
237
+ expect(result.pagination.hasMore).toBe(false);
238
+ expect(result.pagination.cursor).toBeUndefined();
239
+ });
240
+
241
+ it('should work with mapRow transformation', async () => {
242
+ const result = await paginatedSearch({
243
+ query: PaginationTestItem.query(),
244
+ limit: 5,
245
+ mapRow: (row) => ({
246
+ id: row.id,
247
+ displayName: `Product: ${row.name}`,
248
+ priceFormatted: `$${Number(row.price).toFixed(2)}`,
249
+ }),
250
+ });
251
+
252
+ expect(result.items).toHaveLength(5);
253
+ for (const item of result.items) {
254
+ expect(item.displayName).toMatch(/^Product: Item \d+$/);
255
+ expect(item.priceFormatted).toMatch(/^\$\d+\.\d{2}$/);
256
+ }
257
+ });
258
+
259
+ it('should work with eager loading', async () => {
260
+ // withGraphFetched doesn't error even without relations defined;
261
+ // this validates the query builder composition works end-to-end
262
+ const result = await paginatedSearch({
263
+ query: PaginationTestItem.query().select('id', 'name'),
264
+ limit: 5,
265
+ });
266
+
267
+ expect(result.items).toHaveLength(5);
268
+ expect(result.items[0].id).toBeDefined();
269
+ expect(result.items[0].name).toBeDefined();
270
+ });
271
+
272
+ it('should work with async mapRow function', async () => {
273
+ const result = await paginatedSearch({
274
+ query: PaginationTestItem.query(),
275
+ limit: 5,
276
+ mapRow: async (row) => {
277
+ await new Promise((resolve) => setTimeout(resolve, 1));
278
+ return {
279
+ id: row.id,
280
+ displayName: `Product: ${row.name}`,
281
+ };
282
+ },
283
+ });
284
+
285
+ expect(result.items).toHaveLength(5);
286
+ for (const item of result.items) {
287
+ expect(item.displayName).toMatch(/^Product: Item \d+$/);
288
+ }
289
+ });
290
+ });
291
+ });
@@ -0,0 +1,98 @@
1
+ import type { Model, QueryBuilder } from 'objection';
2
+ import {
3
+ Direction,
4
+ decodeCursor,
5
+ encodeCursor,
6
+ type PaginationResult,
7
+ } from '../pagination';
8
+
9
+ export { Direction, decodeCursor, encodeCursor, type PaginationResult };
10
+
11
+ /**
12
+ * Options for paginated search with Objection.js models.
13
+ */
14
+ export interface ObjectionPaginatedSearchOptions<
15
+ TModel extends Model,
16
+ TMapRow extends (row: TModel) => unknown = (row: TModel) => TModel,
17
+ > {
18
+ /** The Objection QueryBuilder to paginate (e.g. User.query(trx).where(...)) */
19
+ query: QueryBuilder<TModel>;
20
+ /** Cursor value for pagination (value of cursorField from previous page) */
21
+ cursor?: string;
22
+ /** Maximum number of items per page (default: 20) */
23
+ limit?: number;
24
+ /** Function to transform each row to the output format (default: identity) */
25
+ mapRow?: TMapRow;
26
+ /** Field to use for cursor pagination (default: 'id') */
27
+ cursorField?: string;
28
+ /** Sort direction for cursor field (default: Direction.Asc) */
29
+ cursorDirection?: Direction;
30
+ }
31
+
32
+ /**
33
+ * Cursor-based paginated search for Objection.js models.
34
+ *
35
+ * Accepts a pre-built QueryBuilder so callers can apply filters,
36
+ * eager loading, and scopes before pagination is layered on top.
37
+ *
38
+ * @example
39
+ * ```typescript
40
+ * const result = await paginatedSearch({
41
+ * query: User.query(trx).where('orgId', orgId).withGraphFetched('roles'),
42
+ * cursor: previousCursor,
43
+ * limit: 20,
44
+ * cursorField: 'createdAt',
45
+ * cursorDirection: Direction.Desc,
46
+ * });
47
+ * ```
48
+ */
49
+ export async function paginatedSearch<
50
+ TModel extends Model,
51
+ TMapRow extends (row: TModel) => unknown = (row: TModel) => TModel,
52
+ >({
53
+ query,
54
+ cursor,
55
+ limit = 20,
56
+ mapRow,
57
+ cursorField = 'id',
58
+ cursorDirection = Direction.Asc,
59
+ }: ObjectionPaginatedSearchOptions<TModel, TMapRow>): Promise<
60
+ PaginationResult<Awaited<ReturnType<TMapRow>>>
61
+ > {
62
+ // Get total count (without cursor filtering)
63
+ const total = await query.resultSize();
64
+
65
+ // Apply cursor if provided
66
+ let paginatedQuery = query.clone();
67
+ if (cursor) {
68
+ const operator = cursorDirection === Direction.Asc ? '>' : '<';
69
+ paginatedQuery = paginatedQuery.where(cursorField, operator, cursor);
70
+ }
71
+
72
+ // Fetch one extra to determine if there are more results
73
+ const data = await paginatedQuery
74
+ .orderBy(cursorField, cursorDirection)
75
+ .limit(limit + 1);
76
+
77
+ const hasMore = data.length > limit;
78
+ const rows = hasMore ? data.slice(0, limit) : data;
79
+ const lastRow = rows[rows.length - 1];
80
+ const nextCursor =
81
+ hasMore && lastRow
82
+ ? String((lastRow as Record<string, unknown>)[cursorField])
83
+ : undefined;
84
+
85
+ const mapper = mapRow ?? ((row: TModel) => row);
86
+ const items = (await Promise.all(rows.map(mapper))) as Awaited<
87
+ ReturnType<TMapRow>
88
+ >[];
89
+
90
+ return {
91
+ items,
92
+ pagination: {
93
+ total,
94
+ hasMore,
95
+ cursor: nextCursor,
96
+ },
97
+ };
98
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Sort direction for cursor-based pagination.
3
+ */
4
+ export enum Direction {
5
+ Asc = 'asc',
6
+ Desc = 'desc',
7
+ }
8
+
9
+ /**
10
+ * Result of a paginated query.
11
+ */
12
+ export interface PaginationResult<TItem> {
13
+ items: TItem[];
14
+ pagination: {
15
+ total: number;
16
+ hasMore: boolean;
17
+ cursor?: string;
18
+ };
19
+ }
20
+
21
+ /**
22
+ * Encode a cursor value for safe URL transmission.
23
+ * Supports various types: string, number, Date, etc.
24
+ */
25
+ export function encodeCursor(value: unknown): string {
26
+ const payload = {
27
+ v: value instanceof Date ? value.toISOString() : value,
28
+ t: value instanceof Date ? 'date' : typeof value,
29
+ };
30
+ return Buffer.from(JSON.stringify(payload)).toString('base64url');
31
+ }
32
+
33
+ /**
34
+ * Decode a cursor string back to its original value.
35
+ */
36
+ export function decodeCursor(cursor: string): unknown {
37
+ try {
38
+ const json = Buffer.from(cursor, 'base64url').toString('utf-8');
39
+ const payload = JSON.parse(json);
40
+
41
+ if (payload.t === 'date') {
42
+ return new Date(payload.v);
43
+ }
44
+
45
+ return payload.v;
46
+ } catch {
47
+ throw new Error('Invalid cursor format');
48
+ }
49
+ }