@andrew_l/mongo-pagination 0.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.
@@ -0,0 +1,654 @@
1
+ import { crc32, isObject, isEmpty, intersection, isFunction, isNumber, deepClone, cleanEmpty, toError, pick } from '@andrew_l/toolkit';
2
+ import Kareem from 'kareem';
3
+ import { ObjectId, MongoInvalidArgumentError } from 'mongodb';
4
+ import { createExtension, BinaryWriter, BinaryReader } from '@andrew_l/tl-pack';
5
+ import d from 'debug';
6
+ import { Transform } from 'node:stream';
7
+
8
+ const SCHEMA_VERSION = 1;
9
+ const OBJECT_ID_TOKEN = 100;
10
+ const extensions = [
11
+ createExtension(OBJECT_ID_TOKEN, {
12
+ encode(value) {
13
+ if (value?._bsontype?.toLowerCase() === "objectid") {
14
+ this.writeBytes(value.id);
15
+ }
16
+ },
17
+ decode() {
18
+ const bytes = this.readBytes();
19
+ return new ObjectId(bytes);
20
+ }
21
+ })
22
+ ];
23
+ class Token {
24
+ /**
25
+ * The schema version of the token.
26
+ */
27
+ schemaVersion = SCHEMA_VERSION;
28
+ /**
29
+ * The CRC of the model name, used for verify the model.
30
+ */
31
+ modelNameCRC = 0;
32
+ /**
33
+ * The sorting direction used for pagination building pagination query.
34
+ */
35
+ sortDirection = {};
36
+ /**
37
+ * The sorting values associated with the pagination.
38
+ */
39
+ sortValues = {};
40
+ /**
41
+ * The payload of the token, which can contain any associated metadata or data (may be `null`).
42
+ */
43
+ payload = null;
44
+ constructor(options) {
45
+ if (options) {
46
+ Object.assign(this, { ...options, modelName: void 0 });
47
+ if (options.modelName) {
48
+ this.modelNameCRC = crc32(options.modelName);
49
+ }
50
+ }
51
+ }
52
+ /**
53
+ * Retrieves the string representation of token.
54
+ */
55
+ stringify() {
56
+ return this.buffer().toString("base64url");
57
+ }
58
+ /**
59
+ * Retrieves binary buffer representation of token.
60
+ */
61
+ buffer() {
62
+ const writer = new BinaryWriter({
63
+ extensions
64
+ });
65
+ writer.writeInt8(this.schemaVersion, false);
66
+ writer.writeObject(this.modelNameCRC);
67
+ writer.writeMap(this.sortDirection || {});
68
+ writer.writeMap(this.sortValues || {});
69
+ writer.writeObject(this.payload || null);
70
+ return Buffer.from(writer.getBuffer());
71
+ }
72
+ /**
73
+ * Encode token from provided value
74
+ */
75
+ static from(value) {
76
+ const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value, "base64url");
77
+ const reader = new BinaryReader(buffer, { extensions });
78
+ const schemaVersion = reader.readInt8(false);
79
+ if (schemaVersion !== SCHEMA_VERSION) {
80
+ throw new TypeError(`Unexpected schema version: ${schemaVersion}`);
81
+ }
82
+ const token = new Token();
83
+ token.modelNameCRC = reader.readObject();
84
+ token.sortDirection = reader.readMap(false);
85
+ token.sortValues = reader.readMap(false);
86
+ token.payload = reader.readObject();
87
+ return token;
88
+ }
89
+ }
90
+ function createToken(options) {
91
+ return new Token(options);
92
+ }
93
+
94
+ const debug = {
95
+ init: d("mongo-pagination:init"),
96
+ tweak: d("mongo-pagination:tweak"),
97
+ exec: d("mongo-pagination:exec"),
98
+ stream: d("mongo-pagination:query:stream")
99
+ };
100
+
101
+ function isEmptyObject(obj) {
102
+ return !isObject(obj) || isEmpty(obj);
103
+ }
104
+ function sameKeys(...args) {
105
+ return intersection(...args.map((v) => Object.keys(v)));
106
+ }
107
+ function defineGetter(obj, key, getterFn) {
108
+ delete obj[key];
109
+ Object.defineProperty(obj, key, {
110
+ get: getterFn,
111
+ enumerable: true,
112
+ configurable: true
113
+ });
114
+ }
115
+ function isLastKeys(value, keys, strictOrder) {
116
+ const objKeys = isObject(value) ? Object.keys(value) : [];
117
+ const lastKeys = objKeys.slice(-keys.length);
118
+ if (lastKeys.length !== keys.length) {
119
+ return false;
120
+ }
121
+ for (let idx = 0; idx < keys.length; idx++) {
122
+ {
123
+ if (!lastKeys.includes(keys[idx])) {
124
+ return false;
125
+ }
126
+ }
127
+ }
128
+ return true;
129
+ }
130
+ function toObject(value) {
131
+ return isFunction(value?.toObject) ? value?.toObject() : value;
132
+ }
133
+
134
+ function prepareDirection(direction = 1) {
135
+ const value = `${direction}`.toLowerCase();
136
+ if (isMeta(direction)) return direction;
137
+ switch (value) {
138
+ case "ascending":
139
+ case "asc":
140
+ case "1":
141
+ return 1;
142
+ case "descending":
143
+ case "desc":
144
+ case "-1":
145
+ return -1;
146
+ default:
147
+ throw new MongoInvalidArgumentError(
148
+ `Invalid sort direction: ${JSON.stringify(direction)}`
149
+ );
150
+ }
151
+ }
152
+ function isMeta(t) {
153
+ return typeof t === "object" && t != null && "$meta" in t && typeof t.$meta === "string";
154
+ }
155
+ function isPair(t) {
156
+ if (Array.isArray(t) && t.length === 2) {
157
+ try {
158
+ prepareDirection(t[1]);
159
+ return true;
160
+ } catch {
161
+ return false;
162
+ }
163
+ }
164
+ return false;
165
+ }
166
+ function isDeep(t) {
167
+ return Array.isArray(t) && Array.isArray(t[0]);
168
+ }
169
+ function isMap(t) {
170
+ return t instanceof Map && t.size > 0;
171
+ }
172
+ function pairToMap(v) {
173
+ return /* @__PURE__ */ new Map([[`${v[0]}`, prepareDirection([v[1]])]]);
174
+ }
175
+ function deepToMap(t) {
176
+ const sortEntries = t.map(([k, v]) => [
177
+ `${k}`,
178
+ prepareDirection(v)
179
+ ]);
180
+ return new Map(sortEntries);
181
+ }
182
+ function stringsToMap(t) {
183
+ const sortEntries = t.map((key) => [`${key}`, 1]);
184
+ return new Map(sortEntries);
185
+ }
186
+ function objectToMap(t) {
187
+ const sortEntries = Object.entries(t).map(([k, v]) => [
188
+ `${k}`,
189
+ prepareDirection(v)
190
+ ]);
191
+ return new Map(sortEntries);
192
+ }
193
+ function mapToMap(t) {
194
+ const sortEntries = Array.from(t).map(([k, v]) => [
195
+ `${k}`,
196
+ prepareDirection(v)
197
+ ]);
198
+ return new Map(sortEntries);
199
+ }
200
+ function formatSort(sort, direction) {
201
+ if (sort == null) return void 0;
202
+ if (typeof sort === "string")
203
+ return /* @__PURE__ */ new Map([[sort, prepareDirection(direction)]]);
204
+ if (typeof sort !== "object") {
205
+ throw new MongoInvalidArgumentError(
206
+ `Invalid sort format: ${JSON.stringify(sort)} Sort must be a valid object`
207
+ );
208
+ }
209
+ if (!Array.isArray(sort)) {
210
+ return isMap(sort) ? mapToMap(sort) : Object.keys(sort).length ? objectToMap(sort) : void 0;
211
+ }
212
+ if (!sort.length) return void 0;
213
+ if (isDeep(sort)) return deepToMap(sort);
214
+ if (isPair(sort)) return pairToMap(sort);
215
+ return stringsToMap(sort);
216
+ }
217
+
218
+ const DEF_LIMIT = 10;
219
+ function createRangeFilter(sortDirection, sortValues) {
220
+ const sortCmd = formatSort(sortDirection) ?? {};
221
+ const keys = [];
222
+ for (const [sortKey, sortValue] of sortCmd.entries()) {
223
+ if (sortValues[sortKey] === void 0) continue;
224
+ if (!isNumber(sortValue)) continue;
225
+ keys.push(sortKey);
226
+ }
227
+ const op = (sortKey) => {
228
+ const direction = sortCmd.get(sortKey);
229
+ return direction === 1 ? "$gt" : "$lt";
230
+ };
231
+ const val = (sortKey) => sortValues[sortKey];
232
+ if (keys.length === 0) {
233
+ return {};
234
+ } else if (keys.length === 1) {
235
+ const sortKey = keys[0];
236
+ return {
237
+ [sortKey]: {
238
+ [op(sortKey)]: val(sortKey)
239
+ }
240
+ };
241
+ }
242
+ const $or = [];
243
+ const keysAmount = keys.length;
244
+ for (let i = 0; i < keysAmount; i++) {
245
+ const sortKey = keys[i];
246
+ if (i === 0) {
247
+ $or.push({
248
+ [sortKey]: { [op(sortKey)]: val(sortKey) }
249
+ });
250
+ } else {
251
+ const filter = {};
252
+ keys.slice(0, i).map((sortKey3) => {
253
+ filter[sortKey3] = { $eq: val(sortKey3) };
254
+ });
255
+ const sortKey2 = keys[i];
256
+ filter[sortKey2] = {
257
+ [op(sortKey2)]: val(sortKey2)
258
+ };
259
+ $or.push(filter);
260
+ }
261
+ }
262
+ if ($or.length === 1) return $or[0];
263
+ return { $or };
264
+ }
265
+ function mergeFilters(first, second) {
266
+ switch (true) {
267
+ // Just return second
268
+ case isEmptyObject(first):
269
+ return deepClone(second);
270
+ // Just return first
271
+ case isEmptyObject(second):
272
+ return deepClone(first);
273
+ // Just assign when no conflicts
274
+ case sameKeys(first, second).length === 0:
275
+ return Object.assign(deepClone(first), deepClone(second));
276
+ // Merge with $and way
277
+ case Array.isArray(first.$and):
278
+ first = deepClone(first);
279
+ second = deepClone(second);
280
+ if (Object.keys(second).length === 1 && second.$and) {
281
+ first.$and.push(...second.$and);
282
+ } else {
283
+ first.$and.push(second);
284
+ }
285
+ return first;
286
+ // Regular merge via $and wrapping
287
+ default:
288
+ return {
289
+ $and: [deepClone(first), deepClone(second)]
290
+ };
291
+ }
292
+ }
293
+ function tweakQuery({
294
+ paginationFields,
295
+ token,
296
+ query
297
+ }) {
298
+ let queryOptions = deepClone(query.getOptions());
299
+ let queryFilter = deepClone(query.getFilter());
300
+ paginationFields = deepClone(paginationFields);
301
+ if (queryOptions.sort) {
302
+ const sortMap = formatSort(queryOptions.sort);
303
+ if (sortMap) {
304
+ queryOptions.sort = Object.fromEntries(sortMap.entries());
305
+ }
306
+ }
307
+ if (token) {
308
+ if (!isEmptyObject(token.sortDirection)) {
309
+ debug.tweak(
310
+ "set from token [queryOptions.sort] = %o",
311
+ token.sortDirection
312
+ );
313
+ queryOptions.sort = token.sortDirection;
314
+ }
315
+ if (!isEmptyObject(token.sortValues)) {
316
+ const rangeFilter = createRangeFilter(
317
+ queryOptions.sort,
318
+ token.sortValues
319
+ );
320
+ debug.tweak(
321
+ "range conditions:\n sortDirection = %o\n sortValues = %o\n result = %o",
322
+ queryOptions.sort,
323
+ token.sortValues,
324
+ rangeFilter
325
+ );
326
+ queryFilter = mergeFilters(queryFilter, rangeFilter);
327
+ }
328
+ }
329
+ if (isEmptyObject(queryOptions.sort)) {
330
+ queryOptions.sort = { _id: -1 };
331
+ debug.tweak("set default [queryOptions.sort] = %o", queryOptions.sort);
332
+ }
333
+ if (!Array.isArray(paginationFields) || !paginationFields.length) {
334
+ paginationFields = Object.keys(queryOptions.sort);
335
+ debug.tweak("set default [paginationFields] = %o", paginationFields);
336
+ }
337
+ if (!isLastKeys(queryOptions.sort, paginationFields)) {
338
+ const expectedKeys = paginationFields.join(", ");
339
+ throw new MongoInvalidArgumentError(
340
+ `The query sort keys must ends with "${expectedKeys}".`
341
+ );
342
+ }
343
+ if (!queryOptions.limit) {
344
+ queryOptions.limit = DEF_LIMIT + 1;
345
+ debug.tweak("set default [queryOptions.limit] = %d", queryOptions.limit);
346
+ } else {
347
+ queryOptions.limit = Math.max(queryOptions.limit || DEF_LIMIT, 1) + 1;
348
+ }
349
+ query.setOptions(queryOptions);
350
+ query.setFilter(queryFilter);
351
+ return paginationFields;
352
+ }
353
+
354
+ class QueryAdapterMongoose {
355
+ constructor(query) {
356
+ this.query = query;
357
+ }
358
+ getModelName() {
359
+ return this.query.model.modelName;
360
+ }
361
+ getOptions() {
362
+ const options = this.query.getOptions();
363
+ return {
364
+ limit: options.limit,
365
+ sort: options.sort
366
+ };
367
+ }
368
+ getFilter() {
369
+ return this.query.getFilter();
370
+ }
371
+ setOptions({ limit, sort }) {
372
+ this.query.setOptions(cleanEmpty({ limit, sort }));
373
+ }
374
+ setFilter(filter) {
375
+ this.query.setQuery(filter);
376
+ }
377
+ toArray() {
378
+ return this.query.exec();
379
+ }
380
+ stream() {
381
+ return this.query.cursor();
382
+ }
383
+ }
384
+
385
+ class Filter extends Transform {
386
+ constructor(has, options) {
387
+ super({
388
+ autoDestroy: true,
389
+ objectMode: true,
390
+ ...options,
391
+ readableObjectMode: true,
392
+ writableObjectMode: true,
393
+ highWaterMark: 16
394
+ });
395
+ this.has = has;
396
+ this._count = -1;
397
+ }
398
+ _count = 0;
399
+ _transform(chunk, encoding, next) {
400
+ const result = this.has(chunk, ++this._count);
401
+ if (typeof result === "boolean") {
402
+ if (result) {
403
+ return next(null, chunk);
404
+ } else {
405
+ return next();
406
+ }
407
+ }
408
+ if ("then" in result) {
409
+ return void result.then((r) => {
410
+ if (r) next(null, chunk);
411
+ }).catch(next);
412
+ }
413
+ next();
414
+ }
415
+ }
416
+ function streamFilter(fn, options) {
417
+ return new Filter(fn, options);
418
+ }
419
+
420
+ class QueryPaginator {
421
+ constructor(query, {
422
+ paginationFields = [],
423
+ postQuery,
424
+ preQuery,
425
+ next: previousToken,
426
+ debugQuery = false
427
+ } = {}) {
428
+ this.query = query;
429
+ this.paginationFields = paginationFields;
430
+ this.debugQuery = debugQuery;
431
+ this.next = createToken({
432
+ modelName: query.getModelName(),
433
+ sortDirection: {},
434
+ sortValues: {},
435
+ payload: {}
436
+ });
437
+ if (previousToken) {
438
+ try {
439
+ this.previous = Token.from(previousToken);
440
+ this.paginationFields = Object.keys(this.previous.sortValues);
441
+ debug.init("parse previous = %o", this.previous);
442
+ debug.init(
443
+ "set from previous [paginationFields] = %o",
444
+ this.paginationFields
445
+ );
446
+ } catch (orignErr) {
447
+ const err = new MongoInvalidArgumentError(
448
+ "Failed to parse next pagination cursor.",
449
+ { cause: toError(orignErr) }
450
+ );
451
+ throw err;
452
+ }
453
+ const modelNameCRC = crc32(query.getModelName());
454
+ debug.init(
455
+ "compare model name: previous = %d, query = %d",
456
+ this.previous.modelNameCRC,
457
+ modelNameCRC
458
+ );
459
+ if (modelNameCRC !== this.previous.modelNameCRC) {
460
+ throw new MongoInvalidArgumentError(
461
+ "The model name of next cursor token is not equal with query model name. Expected: " + this.query.getModelName()
462
+ );
463
+ }
464
+ }
465
+ if (preQuery) this.pre("query", preQuery);
466
+ if (postQuery) this.post("query", postQuery);
467
+ defineGetter(this.next, "sortDirection", () => {
468
+ const sortMap = formatSort(this.query.getOptions().sort);
469
+ return sortMap ? Object.fromEntries(sortMap.entries()) : {};
470
+ });
471
+ defineGetter(this.next, "sortValues", () => {
472
+ if (!this.resLastItem) {
473
+ return {};
474
+ }
475
+ return pick(this.resLastItem, this.paginationFields);
476
+ });
477
+ debug.init("with options = %o", arguments[1]);
478
+ }
479
+ hooks = new Kareem();
480
+ next;
481
+ previous;
482
+ resHasNext = false;
483
+ resLastItem = null;
484
+ paginationFields;
485
+ tweaked = false;
486
+ debugQuery = false;
487
+ getMetadata = () => this.metadata;
488
+ /**
489
+ * Retrieves metadata about the current state of pagination.
490
+ */
491
+ get metadata() {
492
+ return {
493
+ hasNext: this.hasNext,
494
+ next: this.nextTokenString
495
+ };
496
+ }
497
+ /**
498
+ * Indicates whether there is a next page available.
499
+ */
500
+ get hasNext() {
501
+ return this.resHasNext;
502
+ }
503
+ /**
504
+ * Retrieves the token for the next page.
505
+ */
506
+ get nextToken() {
507
+ return this.next;
508
+ }
509
+ /**
510
+ * Retrieves the string representation of the next page token.
511
+ * If there is no next page, returns `null`.
512
+ */
513
+ get nextTokenString() {
514
+ if (!this.resHasNext) {
515
+ return null;
516
+ }
517
+ return this.next.stringify();
518
+ }
519
+ /**
520
+ * Retrieves the token for the previous page.
521
+ */
522
+ get prevToken() {
523
+ return this.previous || null;
524
+ }
525
+ /**
526
+ * Retrieves the string representation of the previous page token.
527
+ */
528
+ get prevTokenString() {
529
+ return this.previous ? this.previous.stringify() : null;
530
+ }
531
+ getQuery() {
532
+ return this.query;
533
+ }
534
+ pre(hookName, fn) {
535
+ this.hooks.pre(hookName, fn);
536
+ return this;
537
+ }
538
+ post(hookName, fn) {
539
+ this.hooks.post(hookName, fn);
540
+ return this;
541
+ }
542
+ stream() {
543
+ return new Promise((resolve, reject) => {
544
+ this.hooks.execPre("query", this, [], (error) => {
545
+ if (error) return reject(error);
546
+ try {
547
+ this.maybeTweak();
548
+ } catch (err) {
549
+ return reject(err);
550
+ }
551
+ const limit = this.query.getOptions().limit;
552
+ const filter = streamFilter((item, index) => {
553
+ if (index + 1 === limit) {
554
+ this.resHasNext = true;
555
+ debug.stream(
556
+ "has next:\n cutoff_idx = %d\n next = %j",
557
+ index,
558
+ this.next
559
+ );
560
+ return false;
561
+ } else {
562
+ this.resLastItem = toObject(item);
563
+ }
564
+ return true;
565
+ });
566
+ const stream = this.query.stream().pipe(filter);
567
+ resolve(stream);
568
+ });
569
+ });
570
+ }
571
+ /** @internal */
572
+ maybeTweak() {
573
+ if (!this.tweaked) {
574
+ this.tweak();
575
+ }
576
+ return this;
577
+ }
578
+ /** @internal */
579
+ tweak() {
580
+ this.paginationFields = tweakQuery({
581
+ query: this.query,
582
+ paginationFields: this.paginationFields,
583
+ token: this.previous
584
+ });
585
+ this.tweaked = true;
586
+ return this;
587
+ }
588
+ /** @internal */
589
+ _handleResult(items) {
590
+ const limit = this.query.getOptions().limit;
591
+ if (items.length === limit) {
592
+ items.pop();
593
+ this.resHasNext = true;
594
+ }
595
+ this.resLastItem = toObject(items.at(-1));
596
+ if (this.resHasNext) {
597
+ debug.exec(
598
+ "has next:\n cutoff_idx = %d\n next = %j",
599
+ limit - 1,
600
+ this.next
601
+ );
602
+ }
603
+ const metadata = this.getMetadata();
604
+ return { metadata, items };
605
+ }
606
+ /**
607
+ * Executes the query and returns the results along with pagination metadata.
608
+ */
609
+ exec() {
610
+ return new Promise((resolve, reject) => {
611
+ this.hooks.execPre("query", this, [], (preHookErr) => {
612
+ if (preHookErr) {
613
+ return reject(preHookErr);
614
+ }
615
+ if (this.debugQuery) {
616
+ console.info("pre tweak", {
617
+ queryFilter: this.query.getFilter(),
618
+ getOptions: this.query.getOptions(),
619
+ prevToken: this.prevToken
620
+ });
621
+ }
622
+ this.maybeTweak();
623
+ if (this.debugQuery) {
624
+ console.info("post tweak", {
625
+ queryFilter: this.query.getFilter(),
626
+ getOptions: this.query.getOptions(),
627
+ prevToken: this.prevToken
628
+ });
629
+ }
630
+ this.query.toArray().then((items) => {
631
+ const result = this._handleResult(items);
632
+ this.hooks.execPost("query", this, [], (postHookErr) => {
633
+ if (postHookErr) {
634
+ return reject(postHookErr);
635
+ }
636
+ return resolve(result);
637
+ });
638
+ }).catch(reject);
639
+ });
640
+ });
641
+ }
642
+ then = (onfulfilled, onrejected) => this.exec().then(onfulfilled, onrejected);
643
+ cath = (onrejected) => this.exec().catch(onrejected);
644
+ finally = (onfinally) => this.exec().finally(onfinally);
645
+ }
646
+
647
+ function withMongoosePagination(query, options) {
648
+ const adapter = new QueryAdapterMongoose(query);
649
+ const pagin = new QueryPaginator(adapter, options);
650
+ return pagin;
651
+ }
652
+
653
+ export { QueryPaginator as Q, createRangeFilter as a, createToken as c, mergeFilters as m, tweakQuery as t, withMongoosePagination as w };
654
+ //# sourceMappingURL=mongo-pagination.YG1DNE27.mjs.map