core-services-sdk 1.3.39 → 1.3.41

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "core-services-sdk",
3
- "version": "1.3.39",
3
+ "version": "1.3.41",
4
4
  "main": "src/index.js",
5
5
  "type": "module",
6
6
  "types": "types/index.d.ts",
@@ -1,7 +1,7 @@
1
1
  import { ObjectId } from 'mongodb'
2
2
 
3
3
  /**
4
- * Pagination with SQL-like ascending/descending
4
+ * Pagination with SQL-like ascending/descending and total count
5
5
  *
6
6
  * @param {import('mongodb').Collection} collection
7
7
  * @param {Object} options
@@ -11,7 +11,7 @@ import { ObjectId } from 'mongodb'
11
11
  * @param {'asc'|'desc'} [options.order='asc']
12
12
  * @param {number} [options.limit=10]
13
13
  */
14
- export async function paginate(
14
+ export async function paginateCursor(
15
15
  collection,
16
16
  {
17
17
  limit = 10,
@@ -25,24 +25,131 @@ export async function paginate(
25
25
  const query = { ...filter }
26
26
  const sort = { [cursorField]: order === 'asc' ? 1 : -1 }
27
27
 
28
+ // Cursor filtering
28
29
  if (cursor) {
29
30
  if (cursorField === '_id') {
30
31
  cursor = new ObjectId(cursor)
31
32
  }
32
-
33
33
  query[cursorField] = order === 'asc' ? { $gt: cursor } : { $lt: cursor }
34
34
  }
35
35
 
36
+ // Fetch results
36
37
  const results = await collection
37
38
  .find(query, { projection })
38
39
  .sort(sort)
39
40
  .limit(limit)
40
41
  .toArray()
41
42
 
43
+ // Get total count (for the original filter, not including cursor)
44
+ const totalCount = await collection.countDocuments(filter)
45
+
46
+ const paginationEdges =
47
+ results.length > 0
48
+ ? await getPaginationEdges({
49
+ order,
50
+ filter,
51
+ results,
52
+ collection,
53
+ cursorField,
54
+ })
55
+ : { hasNext: null, hasPrevious: null }
56
+
57
+ const { hasNext, hasPrevious } = paginationEdges
58
+ return {
59
+ order,
60
+ totalCount,
61
+ list: results,
62
+ previous: hasPrevious && results.length ? results[0][cursorField] : null,
63
+ next:
64
+ hasNext && results.length
65
+ ? results[results.length - 1][cursorField]
66
+ : null,
67
+ }
68
+ }
69
+
70
+ export async function getPaginationEdges({
71
+ order,
72
+ filter,
73
+ results,
74
+ collection,
75
+ cursorField,
76
+ }) {
77
+ const first = results[0][cursorField]
78
+ const last = results[results.length - 1][cursorField]
79
+
80
+ // Check if there are items before the first
81
+ const hasPrevious =
82
+ (await collection.countDocuments({
83
+ ...filter,
84
+ [cursorField]: order === 'asc' ? { $lt: first } : { $gt: first },
85
+ })) > 0
86
+
87
+ // Check if there are items after the last
88
+ const hasNext =
89
+ (await collection.countDocuments({
90
+ ...filter,
91
+ [cursorField]: order === 'asc' ? { $gt: last } : { $lt: last },
92
+ })) > 0
93
+
94
+ return {
95
+ hasNext,
96
+ hasPrevious,
97
+ }
98
+ }
99
+
100
+ import { ObjectId } from 'mongodb'
101
+
102
+ /**
103
+ * Classic page/limit pagination with total count
104
+ *
105
+ * @param {import('mongodb').Collection} collection
106
+ * @param {Object} options
107
+ * @param {Object} [options.filter={}] - MongoDB filter
108
+ * @param {string} [options.cursorField='_id'] - Field to sort by
109
+ * @param {'asc'|'desc'} [options.order='desc'] - Sort order
110
+ * @param {number} [options.limit=10] - Items per page
111
+ * @param {number} [options.page=1] - Page number (1-based)
112
+ * @param {Object} [options.projection] - Projection fields
113
+ */
114
+ export async function paginate(
115
+ collection,
116
+ {
117
+ filter = {},
118
+ projection,
119
+ order = 'desc',
120
+ cursorField = '_id',
121
+ limit = 10,
122
+ page = 1,
123
+ } = {},
124
+ ) {
125
+ // Validation
126
+ if (page < 1) page = 1
127
+ if (limit < 1) limit = 10
128
+
129
+ const sort = { [cursorField]: order === 'asc' ? 1 : -1 }
130
+ const skip = (page - 1) * limit
131
+
132
+ const [results, totalCount] = await Promise.all([
133
+ collection
134
+ .find(filter, { projection })
135
+ .sort(sort)
136
+ .skip(skip)
137
+ .limit(limit)
138
+ .toArray(),
139
+ collection.countDocuments(filter),
140
+ ])
141
+
142
+ const totalPages = Math.ceil(totalCount / limit)
143
+ const hasNext = page < totalPages
144
+ const hasPrevious = page > 1
145
+
42
146
  return {
43
147
  order,
148
+ totalCount,
149
+ totalPages,
150
+ currentPage: page,
151
+ hasNext,
152
+ hasPrevious,
44
153
  list: results,
45
- previous: results.length ? results[0][cursorField] : null,
46
- next: results.length ? results[results.length - 1][cursorField] : null,
47
154
  }
48
155
  }
@@ -0,0 +1,351 @@
1
+ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'
2
+
3
+ import { paginateCursor } from '../../src/mongodb/paginate.js'
4
+ import { startMongo, stopMongo } from '../resources/docker-mongo-test.js'
5
+ import { initializeMongoDb } from '../../src/mongodb/initialize-mongodb.js'
6
+
7
+ const MONGO_PORT = 29050
8
+ const CONTAINER_NAME = 'mongo-auth-attempts-paginate-cursor-test'
9
+
10
+ let db
11
+ let collection
12
+ describe('paginateCursor - Integration', () => {
13
+ beforeAll(async () => {
14
+ startMongo(MONGO_PORT, CONTAINER_NAME)
15
+
16
+ db = await initializeMongoDb({
17
+ config: {
18
+ uri: `mongodb://0.0.0.0:${MONGO_PORT}`,
19
+ options: { dbName: 'users-management' },
20
+ },
21
+ collectionNames: {
22
+ TestDocs: 'test_docs',
23
+ },
24
+ })
25
+
26
+ collection = db.TestDocs
27
+ })
28
+ afterAll(() => {
29
+ stopMongo(CONTAINER_NAME)
30
+ })
31
+
32
+ beforeEach(async () => {
33
+ await collection.deleteMany({})
34
+ })
35
+
36
+ const seedDocs = async (count = 12) => {
37
+ const docs = Array.from({ length: count }).map((_, i) => ({
38
+ name: `doc_${i}`,
39
+ createdAt: new Date(Date.now() + i * 1000),
40
+ }))
41
+ await collection.insertMany(docs)
42
+ return docs
43
+ }
44
+
45
+ it('returns first page with limit', async () => {
46
+ await seedDocs(12)
47
+
48
+ const result = await paginateCursor(collection, {
49
+ filter: {},
50
+ limit: 5,
51
+ order: 'desc',
52
+ cursorField: 'createdAt',
53
+ })
54
+
55
+ expect(result.list).toHaveLength(5)
56
+ expect(result.next).toBeDefined()
57
+ expect(result.previous).toBeNull()
58
+ })
59
+
60
+ it('returns empty when no docs match filter', async () => {
61
+ const result = await paginateCursor(collection, {
62
+ filter: { name: 'not-exist' },
63
+ limit: 5,
64
+ order: 'desc',
65
+ })
66
+
67
+ expect(result.list).toHaveLength(0)
68
+ expect(result.next).toBeNull()
69
+ })
70
+
71
+ it('supports ascending order', async () => {
72
+ const docs = await seedDocs(3)
73
+
74
+ const ascResult = await paginateCursor(collection, {
75
+ filter: {},
76
+ limit: 3,
77
+ order: 'asc',
78
+ cursorField: 'createdAt',
79
+ })
80
+
81
+ expect(ascResult.list[0].name).toBe(docs[0].name)
82
+ })
83
+
84
+ it('supports descending order', async () => {
85
+ const docs = await seedDocs(3)
86
+
87
+ const descResult = await paginateCursor(collection, {
88
+ filter: {},
89
+ limit: 3,
90
+ order: 'desc',
91
+ cursorField: 'createdAt',
92
+ })
93
+
94
+ expect(descResult.list[0].name).toBe(docs[2].name)
95
+ })
96
+
97
+ it('paginateCursors with cursor (next page)', async () => {
98
+ await seedDocs(7)
99
+
100
+ // first page
101
+ const firstPage = await paginateCursor(collection, {
102
+ filter: {},
103
+ limit: 3,
104
+ order: 'asc',
105
+ cursorField: 'createdAt',
106
+ })
107
+
108
+ expect(firstPage.list).toHaveLength(3)
109
+ expect(firstPage.next).toBeDefined()
110
+
111
+ // second page using cursor
112
+ const secondPage = await paginateCursor(collection, {
113
+ filter: {},
114
+ limit: 3,
115
+ order: 'asc',
116
+ cursorField: 'createdAt',
117
+ cursor: firstPage.next,
118
+ })
119
+
120
+ expect(secondPage.list).toHaveLength(3)
121
+ // validate no overlap between first and second page
122
+ expect(secondPage.list[0]._id.toString()).not.toBe(
123
+ firstPage.list[0]._id.toString(),
124
+ )
125
+ expect(secondPage.list.map((d) => d._id.toString())).not.toEqual(
126
+ firstPage.list.map((d) => d._id.toString()),
127
+ )
128
+ })
129
+
130
+ it('paginateCursors with stringified ObjectId as cursor', async () => {
131
+ const docs = await seedDocs(5)
132
+
133
+ // @ts-ignore
134
+ const stringCursor = docs[2]._id.toString()
135
+
136
+ const page = await paginateCursor(collection, {
137
+ filter: {},
138
+ limit: 2,
139
+ order: 'asc',
140
+ cursorField: '_id',
141
+ cursor: stringCursor,
142
+ })
143
+
144
+ expect(page.list).toHaveLength(2)
145
+
146
+ // @ts-ignore
147
+ expect(page.list[0]._id.toString()).toBe(docs[3]._id.toString())
148
+ // @ts-ignore
149
+ expect(page.list[1]._id.toString()).toBe(docs[4]._id.toString())
150
+ })
151
+
152
+ // ---------- PAGINATION EDGES (ASC ORDER) ----------
153
+
154
+ it('ASC - first page: has next, no previous', async () => {
155
+ await seedDocs(7)
156
+
157
+ const page = await paginateCursor(collection, {
158
+ limit: 3,
159
+ order: 'asc',
160
+ cursorField: 'createdAt',
161
+ })
162
+
163
+ expect(page.list).toHaveLength(3)
164
+ expect(page.previous).toBeNull()
165
+ expect(page.next).not.toBeNull()
166
+ })
167
+
168
+ it('ASC - middle page: has both next and previous', async () => {
169
+ await seedDocs(9)
170
+
171
+ const firstPage = await paginateCursor(collection, {
172
+ limit: 3,
173
+ order: 'asc',
174
+ cursorField: 'createdAt',
175
+ })
176
+
177
+ const middlePage = await paginateCursor(collection, {
178
+ limit: 3,
179
+ order: 'asc',
180
+ cursorField: 'createdAt',
181
+ cursor: firstPage.next,
182
+ })
183
+
184
+ expect(middlePage.list).toHaveLength(3)
185
+ expect(middlePage.previous).not.toBeNull()
186
+ expect(middlePage.next).not.toBeNull()
187
+ })
188
+
189
+ it('ASC - last page: has previous, no next', async () => {
190
+ await seedDocs(7)
191
+
192
+ const firstPage = await paginateCursor(collection, {
193
+ limit: 3,
194
+ order: 'asc',
195
+ cursorField: 'createdAt',
196
+ })
197
+ const secondPage = await paginateCursor(collection, {
198
+ limit: 3,
199
+ order: 'asc',
200
+ cursorField: 'createdAt',
201
+ cursor: firstPage.next,
202
+ })
203
+ const lastPage = await paginateCursor(collection, {
204
+ limit: 3,
205
+ order: 'asc',
206
+ cursorField: 'createdAt',
207
+ cursor: secondPage.next,
208
+ })
209
+
210
+ expect(lastPage.list.length).toBeGreaterThan(0)
211
+ expect(lastPage.previous).not.toBeNull()
212
+ expect(lastPage.next).toBeNull()
213
+ })
214
+
215
+ it('ASC - single page: no next, no previous', async () => {
216
+ await seedDocs(2)
217
+
218
+ const singlePage = await paginateCursor(collection, {
219
+ limit: 5,
220
+ order: 'asc',
221
+ cursorField: 'createdAt',
222
+ })
223
+
224
+ expect(singlePage.list).toHaveLength(2)
225
+ expect(singlePage.previous).toBeNull()
226
+ expect(singlePage.next).toBeNull()
227
+ })
228
+
229
+ // ---------- PAGINATION EDGES (DESC ORDER) ----------
230
+
231
+ it('DESC - first page: has next, no previous', async () => {
232
+ await seedDocs(7)
233
+
234
+ const page = await paginateCursor(collection, {
235
+ limit: 3,
236
+ order: 'desc',
237
+ cursorField: 'createdAt',
238
+ })
239
+
240
+ expect(page.list).toHaveLength(3)
241
+ expect(page.previous).toBeNull()
242
+ expect(page.next).not.toBeNull()
243
+ })
244
+
245
+ it('DESC - middle page: has both next and previous', async () => {
246
+ await seedDocs(9)
247
+
248
+ const firstPage = await paginateCursor(collection, {
249
+ limit: 3,
250
+ order: 'desc',
251
+ cursorField: 'createdAt',
252
+ })
253
+
254
+ const middlePage = await paginateCursor(collection, {
255
+ limit: 3,
256
+ order: 'desc',
257
+ cursorField: 'createdAt',
258
+ cursor: firstPage.next,
259
+ })
260
+
261
+ expect(middlePage.list).toHaveLength(3)
262
+ expect(middlePage.previous).not.toBeNull()
263
+ expect(middlePage.next).not.toBeNull()
264
+ })
265
+
266
+ it('DESC - last page: has previous, no next', async () => {
267
+ await seedDocs(7)
268
+
269
+ const firstPage = await paginateCursor(collection, {
270
+ limit: 3,
271
+ order: 'desc',
272
+ cursorField: 'createdAt',
273
+ })
274
+ const secondPage = await paginateCursor(collection, {
275
+ limit: 3,
276
+ order: 'desc',
277
+ cursorField: 'createdAt',
278
+ cursor: firstPage.next,
279
+ })
280
+ const lastPage = await paginateCursor(collection, {
281
+ limit: 3,
282
+ order: 'desc',
283
+ cursorField: 'createdAt',
284
+ cursor: secondPage.next,
285
+ })
286
+
287
+ expect(lastPage.list.length).toBeGreaterThan(0)
288
+ expect(lastPage.previous).not.toBeNull()
289
+ expect(lastPage.next).toBeNull()
290
+ })
291
+
292
+ it('DESC - single page: no next, no previous', async () => {
293
+ await seedDocs(2)
294
+
295
+ const singlePage = await paginateCursor(collection, {
296
+ limit: 5,
297
+ order: 'desc',
298
+ cursorField: 'createdAt',
299
+ })
300
+
301
+ expect(singlePage.list).toHaveLength(2)
302
+ expect(singlePage.previous).toBeNull()
303
+ expect(singlePage.next).toBeNull()
304
+ })
305
+
306
+ // ---------- STABILITY CHECKS ----------
307
+
308
+ it('returns consistent next/previous cursors across calls', async () => {
309
+ await seedDocs(6)
310
+
311
+ const firstPage = await paginateCursor(collection, {
312
+ limit: 3,
313
+ order: 'asc',
314
+ cursorField: 'createdAt',
315
+ })
316
+
317
+ const secondPage = await paginateCursor(collection, {
318
+ limit: 3,
319
+ order: 'asc',
320
+ cursorField: 'createdAt',
321
+ cursor: firstPage.next,
322
+ })
323
+ console.log(secondPage)
324
+ const firstPageLast = firstPage.list[firstPage.list.length - 1].createdAt
325
+ const secondPageFirst = secondPage.list[0].createdAt
326
+
327
+ expect(secondPageFirst.getTime()).toBeGreaterThan(firstPageLast.getTime())
328
+ expect(secondPage.previous).toEqual(secondPage.list[0].createdAt)
329
+ expect(secondPage.next).toBeNull()
330
+ })
331
+
332
+ it('totalCount remains constant across pages', async () => {
333
+ await seedDocs(8)
334
+
335
+ const firstPage = await paginateCursor(collection, {
336
+ limit: 3,
337
+ order: 'asc',
338
+ cursorField: 'createdAt',
339
+ })
340
+
341
+ const secondPage = await paginateCursor(collection, {
342
+ limit: 3,
343
+ order: 'asc',
344
+ cursorField: 'createdAt',
345
+ cursor: firstPage.next,
346
+ })
347
+
348
+ expect(firstPage.totalCount).toBe(8)
349
+ expect(secondPage.totalCount).toBe(8)
350
+ })
351
+ })
@@ -1,17 +1,17 @@
1
1
  import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'
2
-
3
2
  import { paginate } from '../../src/mongodb/paginate.js'
4
3
  import { startMongo, stopMongo } from '../resources/docker-mongo-test.js'
5
4
  import { initializeMongoDb } from '../../src/mongodb/initialize-mongodb.js'
6
5
 
7
- const MONGO_PORT = 29050
6
+ const MONGO_PORT = 29051
8
7
  const CONTAINER_NAME = 'mongo-auth-attempts-paginate-test'
9
8
 
10
9
  let db
11
10
  let collection
11
+
12
12
  describe('paginate - Integration', () => {
13
13
  beforeAll(async () => {
14
- startMongo(MONGO_PORT, CONTAINER_NAME)
14
+ await startMongo(MONGO_PORT, CONTAINER_NAME)
15
15
 
16
16
  db = await initializeMongoDb({
17
17
  config: {
@@ -19,21 +19,22 @@ describe('paginate - Integration', () => {
19
19
  options: { dbName: 'users-management' },
20
20
  },
21
21
  collectionNames: {
22
- TestDocs: 'test_docs',
22
+ TestDocs: 'test_docs_page',
23
23
  },
24
24
  })
25
25
 
26
26
  collection = db.TestDocs
27
27
  })
28
- afterAll(() => {
29
- stopMongo(CONTAINER_NAME)
28
+
29
+ afterAll(async () => {
30
+ await stopMongo(CONTAINER_NAME)
30
31
  })
31
32
 
32
33
  beforeEach(async () => {
33
34
  await collection.deleteMany({})
34
35
  })
35
36
 
36
- const seedDocs = async (count = 12) => {
37
+ const seedDocs = async (count = 25) => {
37
38
  const docs = Array.from({ length: count }).map((_, i) => ({
38
39
  name: `doc_${i}`,
39
40
  createdAt: new Date(Date.now() + i * 1000),
@@ -42,110 +43,152 @@ describe('paginate - Integration', () => {
42
43
  return docs
43
44
  }
44
45
 
45
- it('returns first page with limit', async () => {
46
- await seedDocs(12)
46
+ it('returns first page with correct size and metadata', async () => {
47
+ await seedDocs(15)
47
48
 
48
49
  const result = await paginate(collection, {
49
- filter: {},
50
50
  limit: 5,
51
- order: 'desc',
51
+ page: 1,
52
+ order: 'asc',
52
53
  cursorField: 'createdAt',
53
54
  })
54
55
 
55
56
  expect(result.list).toHaveLength(5)
56
- expect(result.next).toBeDefined()
57
- expect(result.previous).toBeDefined()
57
+ expect(result.currentPage).toBe(1)
58
+ expect(result.totalPages).toBe(3)
59
+ expect(result.hasNext).toBe(true)
60
+ expect(result.hasPrevious).toBe(false)
58
61
  })
59
62
 
60
- it('returns empty when no docs match filter', async () => {
63
+ it('returns middle page correctly', async () => {
64
+ await seedDocs(15)
65
+
61
66
  const result = await paginate(collection, {
62
- filter: { name: 'not-exist' },
63
67
  limit: 5,
64
- order: 'desc',
68
+ page: 2,
69
+ order: 'asc',
70
+ cursorField: 'createdAt',
65
71
  })
66
72
 
67
- expect(result.list).toHaveLength(0)
68
- expect(result.next).toBeNull()
73
+ expect(result.list).toHaveLength(5)
74
+ expect(result.currentPage).toBe(2)
75
+ expect(result.hasNext).toBe(true)
76
+ expect(result.hasPrevious).toBe(true)
69
77
  })
70
78
 
71
- it('supports ascending order', async () => {
72
- const docs = await seedDocs(3)
79
+ it('returns last page correctly', async () => {
80
+ await seedDocs(15)
73
81
 
74
- const ascResult = await paginate(collection, {
75
- filter: {},
76
- limit: 3,
82
+ const result = await paginate(collection, {
83
+ limit: 5,
84
+ page: 3,
85
+ order: 'asc',
86
+ cursorField: 'createdAt',
87
+ })
88
+
89
+ expect(result.list).toHaveLength(5)
90
+ expect(result.hasNext).toBe(false)
91
+ expect(result.hasPrevious).toBe(true)
92
+ })
93
+
94
+ it('returns empty list for page out of range', async () => {
95
+ await seedDocs(10)
96
+
97
+ const result = await paginate(collection, {
98
+ limit: 5,
99
+ page: 5,
77
100
  order: 'asc',
78
101
  cursorField: 'createdAt',
79
102
  })
80
103
 
81
- expect(ascResult.list[0].name).toBe(docs[0].name)
104
+ expect(result.list).toHaveLength(0)
105
+ expect(result.hasNext).toBe(false)
106
+ expect(result.hasPrevious).toBe(true)
107
+ })
108
+
109
+ it('returns totalCount consistent across pages', async () => {
110
+ await seedDocs(9)
111
+
112
+ const page1 = await paginate(collection, {
113
+ limit: 3,
114
+ page: 1,
115
+ })
116
+
117
+ const page2 = await paginate(collection, {
118
+ limit: 3,
119
+ page: 2,
120
+ })
121
+
122
+ expect(page1.totalCount).toBe(9)
123
+ expect(page2.totalCount).toBe(9)
82
124
  })
83
125
 
84
126
  it('supports descending order', async () => {
85
- const docs = await seedDocs(3)
127
+ const docs = await seedDocs(6)
86
128
 
87
- const descResult = await paginate(collection, {
88
- filter: {},
129
+ const result = await paginate(collection, {
89
130
  limit: 3,
131
+ page: 1,
90
132
  order: 'desc',
91
133
  cursorField: 'createdAt',
92
134
  })
93
135
 
94
- expect(descResult.list[0].name).toBe(docs[2].name)
136
+ expect(result.list[0].name).toBe(docs[5].name)
137
+ expect(result.list[2].name).toBe(docs[3].name)
95
138
  })
96
139
 
97
- it('paginates with cursor (next page)', async () => {
98
- await seedDocs(7)
140
+ it('supports ascending order', async () => {
141
+ const docs = await seedDocs(6)
99
142
 
100
- // first page
101
- const firstPage = await paginate(collection, {
102
- filter: {},
143
+ const result = await paginate(collection, {
103
144
  limit: 3,
145
+ page: 1,
104
146
  order: 'asc',
105
147
  cursorField: 'createdAt',
106
148
  })
107
149
 
108
- expect(firstPage.list).toHaveLength(3)
109
- expect(firstPage.next).toBeDefined()
150
+ expect(result.list[0].name).toBe(docs[0].name)
151
+ expect(result.list[2].name).toBe(docs[2].name)
152
+ })
153
+
154
+ it('returns no documents when filter excludes all', async () => {
155
+ await seedDocs(5)
110
156
 
111
- // second page using cursor
112
- const secondPage = await paginate(collection, {
113
- filter: {},
157
+ const result = await paginate(collection, {
158
+ filter: { name: 'not_existing' },
114
159
  limit: 3,
115
- order: 'asc',
116
- cursorField: 'createdAt',
117
- cursor: firstPage.next,
160
+ page: 1,
118
161
  })
119
162
 
120
- expect(secondPage.list).toHaveLength(3)
121
- // validate no overlap between first and second page
122
- expect(secondPage.list[0]._id.toString()).not.toBe(
123
- firstPage.list[0]._id.toString(),
124
- )
125
- expect(secondPage.list.map((d) => d._id.toString())).not.toEqual(
126
- firstPage.list.map((d) => d._id.toString()),
127
- )
163
+ expect(result.list).toHaveLength(0)
164
+ expect(result.totalCount).toBe(0)
165
+ expect(result.totalPages).toBe(0)
128
166
  })
129
167
 
130
- it('paginates with stringified ObjectId as cursor', async () => {
131
- const docs = await seedDocs(5)
168
+ it('paginates deterministically across pages', async () => {
169
+ const docs = await seedDocs(9)
132
170
 
133
- // @ts-ignore
134
- const stringCursor = docs[2]._id.toString()
171
+ const page1 = await paginate(collection, {
172
+ limit: 3,
173
+ page: 1,
174
+ order: 'asc',
175
+ cursorField: 'createdAt',
176
+ })
135
177
 
136
- const page = await paginate(collection, {
137
- filter: {},
138
- limit: 2,
178
+ const page2 = await paginate(collection, {
179
+ limit: 3,
180
+ page: 2,
139
181
  order: 'asc',
140
- cursorField: '_id',
141
- cursor: stringCursor,
182
+ cursorField: 'createdAt',
142
183
  })
143
184
 
144
- expect(page.list).toHaveLength(2)
185
+ // ensure continuity: last of page1 == item before first of page2
186
+ const lastOfPage1 = page1.list[page1.list.length - 1].name
187
+ const firstOfPage2 = page2.list[0].name
145
188
 
146
- // @ts-ignore
147
- expect(page.list[0]._id.toString()).toBe(docs[3]._id.toString())
148
- // @ts-ignore
149
- expect(page.list[1]._id.toString()).toBe(docs[4]._id.toString())
189
+ const indexDiff =
190
+ docs.findIndex((d) => d.name === firstOfPage2) -
191
+ docs.findIndex((d) => d.name === lastOfPage1)
192
+ expect(indexDiff).toBe(1)
150
193
  })
151
194
  })
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Pagination with SQL-like ascending/descending
2
+ * Pagination with SQL-like ascending/descending and total count
3
3
  *
4
4
  * @param {import('mongodb').Collection} collection
5
5
  * @param {Object} options
@@ -27,8 +27,25 @@ export function paginate(
27
27
  },
28
28
  ): Promise<{
29
29
  order: 'asc' | 'desc'
30
+ totalCount: number
30
31
  list: import('mongodb').WithId<import('bson').Document>[]
31
32
  previous: any
32
33
  next: any
33
34
  }>
35
+ export function getPaginationEdges({
36
+ order,
37
+ filter,
38
+ results,
39
+ collection,
40
+ cursorField,
41
+ }: {
42
+ order: any
43
+ filter: any
44
+ results: any
45
+ collection: any
46
+ cursorField: any
47
+ }): Promise<{
48
+ hasNext: boolean
49
+ hasPrevious: boolean
50
+ }>
34
51
  import { ObjectId } from 'mongodb'