s3db.js 10.0.15 → 10.0.17

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": "s3db.js",
3
- "version": "10.0.15",
3
+ "version": "10.0.17",
4
4
  "description": "Use AWS S3, the world's most reliable document storage, as a database with this ORM.",
5
5
  "main": "dist/s3db.cjs.js",
6
6
  "module": "dist/s3db.es.js",
@@ -0,0 +1,668 @@
1
+ /**
2
+ * Analytics for EventualConsistencyPlugin
3
+ * @module eventual-consistency/analytics
4
+ */
5
+
6
+ import tryFn from "../../concerns/try-fn.js";
7
+ import { groupByCohort } from "./utils.js";
8
+
9
+ /**
10
+ * Update analytics with consolidated transactions
11
+ *
12
+ * @param {Array} transactions - Transactions that were just consolidated
13
+ * @param {Object} analyticsResource - Analytics resource
14
+ * @param {Object} config - Plugin configuration
15
+ * @returns {Promise<void>}
16
+ */
17
+ export async function updateAnalytics(transactions, analyticsResource, config) {
18
+ if (!analyticsResource || transactions.length === 0) return;
19
+
20
+ if (config.verbose) {
21
+ console.log(
22
+ `[EventualConsistency] ${config.resource}.${config.field} - ` +
23
+ `Updating analytics for ${transactions.length} transactions...`
24
+ );
25
+ }
26
+
27
+ try {
28
+ // Group transactions by cohort hour
29
+ const byHour = groupByCohort(transactions, 'cohortHour');
30
+ const cohortCount = Object.keys(byHour).length;
31
+
32
+ if (config.verbose) {
33
+ console.log(
34
+ `[EventualConsistency] ${config.resource}.${config.field} - ` +
35
+ `Updating ${cohortCount} hourly analytics cohorts...`
36
+ );
37
+ }
38
+
39
+ // Update hourly analytics
40
+ for (const [cohort, txns] of Object.entries(byHour)) {
41
+ await upsertAnalytics('hour', cohort, txns, analyticsResource, config);
42
+ }
43
+
44
+ // Roll up to daily and monthly if configured
45
+ if (config.analyticsConfig.rollupStrategy === 'incremental') {
46
+ const uniqueHours = Object.keys(byHour);
47
+
48
+ if (config.verbose) {
49
+ console.log(
50
+ `[EventualConsistency] ${config.resource}.${config.field} - ` +
51
+ `Rolling up ${uniqueHours.length} hours to daily/monthly analytics...`
52
+ );
53
+ }
54
+
55
+ for (const cohortHour of uniqueHours) {
56
+ await rollupAnalytics(cohortHour, analyticsResource, config);
57
+ }
58
+ }
59
+
60
+ if (config.verbose) {
61
+ console.log(
62
+ `[EventualConsistency] ${config.resource}.${config.field} - ` +
63
+ `Analytics update complete for ${cohortCount} cohorts`
64
+ );
65
+ }
66
+ } catch (error) {
67
+ console.warn(
68
+ `[EventualConsistency] ${config.resource}.${config.field} - ` +
69
+ `Analytics update error:`,
70
+ error.message
71
+ );
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Upsert analytics for a specific period and cohort
77
+ * @private
78
+ */
79
+ async function upsertAnalytics(period, cohort, transactions, analyticsResource, config) {
80
+ const id = `${period}-${cohort}`;
81
+
82
+ // Calculate metrics
83
+ const transactionCount = transactions.length;
84
+
85
+ // Calculate signed values (considering operation type)
86
+ const signedValues = transactions.map(t => {
87
+ if (t.operation === 'sub') return -t.value;
88
+ return t.value;
89
+ });
90
+
91
+ const totalValue = signedValues.reduce((sum, v) => sum + v, 0);
92
+ const avgValue = totalValue / transactionCount;
93
+ const minValue = Math.min(...signedValues);
94
+ const maxValue = Math.max(...signedValues);
95
+
96
+ // Calculate operation breakdown
97
+ const operations = calculateOperationBreakdown(transactions);
98
+
99
+ // Count distinct records
100
+ const recordCount = new Set(transactions.map(t => t.originalId)).size;
101
+
102
+ const now = new Date().toISOString();
103
+
104
+ // Try to get existing analytics
105
+ const [existingOk, existingErr, existing] = await tryFn(() =>
106
+ analyticsResource.get(id)
107
+ );
108
+
109
+ if (existingOk && existing) {
110
+ // Update existing analytics (incremental)
111
+ const newTransactionCount = existing.transactionCount + transactionCount;
112
+ const newTotalValue = existing.totalValue + totalValue;
113
+ const newAvgValue = newTotalValue / newTransactionCount;
114
+ const newMinValue = Math.min(existing.minValue, minValue);
115
+ const newMaxValue = Math.max(existing.maxValue, maxValue);
116
+
117
+ // Merge operation breakdown
118
+ const newOperations = { ...existing.operations };
119
+ for (const [op, stats] of Object.entries(operations)) {
120
+ if (!newOperations[op]) {
121
+ newOperations[op] = { count: 0, sum: 0 };
122
+ }
123
+ newOperations[op].count += stats.count;
124
+ newOperations[op].sum += stats.sum;
125
+ }
126
+
127
+ // Update record count (approximate - we don't track all unique IDs)
128
+ const newRecordCount = Math.max(existing.recordCount, recordCount);
129
+
130
+ await tryFn(() =>
131
+ analyticsResource.update(id, {
132
+ transactionCount: newTransactionCount,
133
+ totalValue: newTotalValue,
134
+ avgValue: newAvgValue,
135
+ minValue: newMinValue,
136
+ maxValue: newMaxValue,
137
+ operations: newOperations,
138
+ recordCount: newRecordCount,
139
+ updatedAt: now
140
+ })
141
+ );
142
+ } else {
143
+ // Create new analytics
144
+ await tryFn(() =>
145
+ analyticsResource.insert({
146
+ id,
147
+ period,
148
+ cohort,
149
+ transactionCount,
150
+ totalValue,
151
+ avgValue,
152
+ minValue,
153
+ maxValue,
154
+ operations,
155
+ recordCount,
156
+ consolidatedAt: now,
157
+ updatedAt: now
158
+ })
159
+ );
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Calculate operation breakdown
165
+ * @private
166
+ */
167
+ function calculateOperationBreakdown(transactions) {
168
+ const breakdown = {};
169
+
170
+ for (const txn of transactions) {
171
+ const op = txn.operation;
172
+ if (!breakdown[op]) {
173
+ breakdown[op] = { count: 0, sum: 0 };
174
+ }
175
+ breakdown[op].count++;
176
+
177
+ // Use signed value for sum (sub operations are negative)
178
+ const signedValue = op === 'sub' ? -txn.value : txn.value;
179
+ breakdown[op].sum += signedValue;
180
+ }
181
+
182
+ return breakdown;
183
+ }
184
+
185
+ /**
186
+ * Roll up hourly analytics to daily and monthly
187
+ * @private
188
+ */
189
+ async function rollupAnalytics(cohortHour, analyticsResource, config) {
190
+ // cohortHour format: '2025-10-09T14'
191
+ const cohortDate = cohortHour.substring(0, 10); // '2025-10-09'
192
+ const cohortMonth = cohortHour.substring(0, 7); // '2025-10'
193
+
194
+ // Roll up to day
195
+ await rollupPeriod('day', cohortDate, cohortDate, analyticsResource, config);
196
+
197
+ // Roll up to month
198
+ await rollupPeriod('month', cohortMonth, cohortMonth, analyticsResource, config);
199
+ }
200
+
201
+ /**
202
+ * Roll up analytics for a specific period
203
+ * @private
204
+ */
205
+ async function rollupPeriod(period, cohort, sourcePrefix, analyticsResource, config) {
206
+ // Get all source analytics (e.g., all hours for a day)
207
+ const sourcePeriod = period === 'day' ? 'hour' : 'day';
208
+
209
+ const [ok, err, allAnalytics] = await tryFn(() =>
210
+ analyticsResource.list()
211
+ );
212
+
213
+ if (!ok || !allAnalytics) return;
214
+
215
+ // Filter to matching cohorts
216
+ const sourceAnalytics = allAnalytics.filter(a =>
217
+ a.period === sourcePeriod && a.cohort.startsWith(sourcePrefix)
218
+ );
219
+
220
+ if (sourceAnalytics.length === 0) return;
221
+
222
+ // Aggregate metrics
223
+ const transactionCount = sourceAnalytics.reduce((sum, a) => sum + a.transactionCount, 0);
224
+ const totalValue = sourceAnalytics.reduce((sum, a) => sum + a.totalValue, 0);
225
+ const avgValue = totalValue / transactionCount;
226
+ const minValue = Math.min(...sourceAnalytics.map(a => a.minValue));
227
+ const maxValue = Math.max(...sourceAnalytics.map(a => a.maxValue));
228
+
229
+ // Merge operation breakdown
230
+ const operations = {};
231
+ for (const analytics of sourceAnalytics) {
232
+ for (const [op, stats] of Object.entries(analytics.operations || {})) {
233
+ if (!operations[op]) {
234
+ operations[op] = { count: 0, sum: 0 };
235
+ }
236
+ operations[op].count += stats.count;
237
+ operations[op].sum += stats.sum;
238
+ }
239
+ }
240
+
241
+ // Approximate record count (max of all periods)
242
+ const recordCount = Math.max(...sourceAnalytics.map(a => a.recordCount));
243
+
244
+ const id = `${period}-${cohort}`;
245
+ const now = new Date().toISOString();
246
+
247
+ // Upsert rolled-up analytics
248
+ const [existingOk, existingErr, existing] = await tryFn(() =>
249
+ analyticsResource.get(id)
250
+ );
251
+
252
+ if (existingOk && existing) {
253
+ await tryFn(() =>
254
+ analyticsResource.update(id, {
255
+ transactionCount,
256
+ totalValue,
257
+ avgValue,
258
+ minValue,
259
+ maxValue,
260
+ operations,
261
+ recordCount,
262
+ updatedAt: now
263
+ })
264
+ );
265
+ } else {
266
+ await tryFn(() =>
267
+ analyticsResource.insert({
268
+ id,
269
+ period,
270
+ cohort,
271
+ transactionCount,
272
+ totalValue,
273
+ avgValue,
274
+ minValue,
275
+ maxValue,
276
+ operations,
277
+ recordCount,
278
+ consolidatedAt: now,
279
+ updatedAt: now
280
+ })
281
+ );
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Fill gaps in analytics data with zeros for continuous time series
287
+ *
288
+ * @param {Array} data - Sparse analytics data
289
+ * @param {string} period - Period type ('hour', 'day', 'month')
290
+ * @param {string} startDate - Start date (ISO format)
291
+ * @param {string} endDate - End date (ISO format)
292
+ * @returns {Array} Complete time series with gaps filled
293
+ */
294
+ export function fillGaps(data, period, startDate, endDate) {
295
+ if (!data || data.length === 0) {
296
+ // If no data, still generate empty series
297
+ data = [];
298
+ }
299
+
300
+ // Create a map of existing data by cohort
301
+ const dataMap = new Map();
302
+ data.forEach(item => {
303
+ dataMap.set(item.cohort, item);
304
+ });
305
+
306
+ const result = [];
307
+ const emptyRecord = {
308
+ count: 0,
309
+ sum: 0,
310
+ avg: 0,
311
+ min: 0,
312
+ max: 0,
313
+ recordCount: 0
314
+ };
315
+
316
+ if (period === 'hour') {
317
+ // Generate all hours between startDate and endDate
318
+ const start = new Date(startDate + 'T00:00:00Z');
319
+ const end = new Date(endDate + 'T23:59:59Z');
320
+
321
+ for (let dt = new Date(start); dt <= end; dt.setHours(dt.getHours() + 1)) {
322
+ const cohort = dt.toISOString().substring(0, 13); // YYYY-MM-DDTHH
323
+ result.push(dataMap.get(cohort) || { cohort, ...emptyRecord });
324
+ }
325
+ } else if (period === 'day') {
326
+ // Generate all days between startDate and endDate
327
+ const start = new Date(startDate);
328
+ const end = new Date(endDate);
329
+
330
+ for (let dt = new Date(start); dt <= end; dt.setDate(dt.getDate() + 1)) {
331
+ const cohort = dt.toISOString().substring(0, 10); // YYYY-MM-DD
332
+ result.push(dataMap.get(cohort) || { cohort, ...emptyRecord });
333
+ }
334
+ } else if (period === 'month') {
335
+ // Generate all months between startDate and endDate
336
+ const startYear = parseInt(startDate.substring(0, 4));
337
+ const startMonth = parseInt(startDate.substring(5, 7));
338
+ const endYear = parseInt(endDate.substring(0, 4));
339
+ const endMonth = parseInt(endDate.substring(5, 7));
340
+
341
+ for (let year = startYear; year <= endYear; year++) {
342
+ const firstMonth = (year === startYear) ? startMonth : 1;
343
+ const lastMonth = (year === endYear) ? endMonth : 12;
344
+
345
+ for (let month = firstMonth; month <= lastMonth; month++) {
346
+ const cohort = `${year}-${month.toString().padStart(2, '0')}`;
347
+ result.push(dataMap.get(cohort) || { cohort, ...emptyRecord });
348
+ }
349
+ }
350
+ }
351
+
352
+ return result;
353
+ }
354
+
355
+ /**
356
+ * Get analytics for a specific period
357
+ *
358
+ * @param {string} resourceName - Resource name
359
+ * @param {string} field - Field name
360
+ * @param {Object} options - Query options
361
+ * @param {Object} fieldHandlers - Field handlers map
362
+ * @returns {Promise<Array>} Analytics data
363
+ */
364
+ export async function getAnalytics(resourceName, field, options, fieldHandlers) {
365
+ // Get handler for this resource/field combination
366
+ const resourceHandlers = fieldHandlers.get(resourceName);
367
+ if (!resourceHandlers) {
368
+ throw new Error(`No eventual consistency configured for resource: ${resourceName}`);
369
+ }
370
+
371
+ const handler = resourceHandlers.get(field);
372
+ if (!handler) {
373
+ throw new Error(`No eventual consistency configured for field: ${resourceName}.${field}`);
374
+ }
375
+
376
+ if (!handler.analyticsResource) {
377
+ throw new Error('Analytics not enabled for this plugin');
378
+ }
379
+
380
+ const { period = 'day', date, startDate, endDate, month, year, breakdown = false } = options;
381
+
382
+ const [ok, err, allAnalytics] = await tryFn(() =>
383
+ handler.analyticsResource.list()
384
+ );
385
+
386
+ if (!ok || !allAnalytics) {
387
+ return [];
388
+ }
389
+
390
+ // Filter by period
391
+ let filtered = allAnalytics.filter(a => a.period === period);
392
+
393
+ // Filter by date/range
394
+ if (date) {
395
+ if (period === 'hour') {
396
+ // Match all hours of the date
397
+ filtered = filtered.filter(a => a.cohort.startsWith(date));
398
+ } else {
399
+ filtered = filtered.filter(a => a.cohort === date);
400
+ }
401
+ } else if (startDate && endDate) {
402
+ filtered = filtered.filter(a => a.cohort >= startDate && a.cohort <= endDate);
403
+ } else if (month) {
404
+ filtered = filtered.filter(a => a.cohort.startsWith(month));
405
+ } else if (year) {
406
+ filtered = filtered.filter(a => a.cohort.startsWith(String(year)));
407
+ }
408
+
409
+ // Sort by cohort
410
+ filtered.sort((a, b) => a.cohort.localeCompare(b.cohort));
411
+
412
+ // Return with or without breakdown
413
+ if (breakdown === 'operations') {
414
+ return filtered.map(a => ({
415
+ cohort: a.cohort,
416
+ ...a.operations
417
+ }));
418
+ }
419
+
420
+ return filtered.map(a => ({
421
+ cohort: a.cohort,
422
+ count: a.transactionCount,
423
+ sum: a.totalValue,
424
+ avg: a.avgValue,
425
+ min: a.minValue,
426
+ max: a.maxValue,
427
+ operations: a.operations,
428
+ recordCount: a.recordCount
429
+ }));
430
+ }
431
+
432
+ /**
433
+ * Get analytics for entire month, broken down by days
434
+ *
435
+ * @param {string} resourceName - Resource name
436
+ * @param {string} field - Field name
437
+ * @param {string} month - Month in YYYY-MM format
438
+ * @param {Object} options - Options
439
+ * @param {Object} fieldHandlers - Field handlers map
440
+ * @returns {Promise<Array>} Daily analytics for the month
441
+ */
442
+ export async function getMonthByDay(resourceName, field, month, options, fieldHandlers) {
443
+ // month format: '2025-10'
444
+ const year = parseInt(month.substring(0, 4));
445
+ const monthNum = parseInt(month.substring(5, 7));
446
+
447
+ // Get first and last day of month
448
+ const firstDay = new Date(year, monthNum - 1, 1);
449
+ const lastDay = new Date(year, monthNum, 0);
450
+
451
+ const startDate = firstDay.toISOString().substring(0, 10);
452
+ const endDate = lastDay.toISOString().substring(0, 10);
453
+
454
+ const data = await getAnalytics(resourceName, field, {
455
+ period: 'day',
456
+ startDate,
457
+ endDate
458
+ }, fieldHandlers);
459
+
460
+ if (options.fillGaps) {
461
+ return fillGaps(data, 'day', startDate, endDate);
462
+ }
463
+
464
+ return data;
465
+ }
466
+
467
+ /**
468
+ * Get analytics for entire day, broken down by hours
469
+ *
470
+ * @param {string} resourceName - Resource name
471
+ * @param {string} field - Field name
472
+ * @param {string} date - Date in YYYY-MM-DD format
473
+ * @param {Object} options - Options
474
+ * @param {Object} fieldHandlers - Field handlers map
475
+ * @returns {Promise<Array>} Hourly analytics for the day
476
+ */
477
+ export async function getDayByHour(resourceName, field, date, options, fieldHandlers) {
478
+ // date format: '2025-10-09'
479
+ const data = await getAnalytics(resourceName, field, {
480
+ period: 'hour',
481
+ date
482
+ }, fieldHandlers);
483
+
484
+ if (options.fillGaps) {
485
+ return fillGaps(data, 'hour', date, date);
486
+ }
487
+
488
+ return data;
489
+ }
490
+
491
+ /**
492
+ * Get analytics for last N days, broken down by days
493
+ *
494
+ * @param {string} resourceName - Resource name
495
+ * @param {string} field - Field name
496
+ * @param {number} days - Number of days to look back (default: 7)
497
+ * @param {Object} options - Options
498
+ * @param {Object} fieldHandlers - Field handlers map
499
+ * @returns {Promise<Array>} Daily analytics
500
+ */
501
+ export async function getLastNDays(resourceName, field, days, options, fieldHandlers) {
502
+ const dates = Array.from({ length: days }, (_, i) => {
503
+ const date = new Date();
504
+ date.setDate(date.getDate() - i);
505
+ return date.toISOString().substring(0, 10);
506
+ }).reverse();
507
+
508
+ const data = await getAnalytics(resourceName, field, {
509
+ period: 'day',
510
+ startDate: dates[0],
511
+ endDate: dates[dates.length - 1]
512
+ }, fieldHandlers);
513
+
514
+ if (options.fillGaps) {
515
+ return fillGaps(data, 'day', dates[0], dates[dates.length - 1]);
516
+ }
517
+
518
+ return data;
519
+ }
520
+
521
+ /**
522
+ * Get analytics for entire year, broken down by months
523
+ *
524
+ * @param {string} resourceName - Resource name
525
+ * @param {string} field - Field name
526
+ * @param {number} year - Year (e.g., 2025)
527
+ * @param {Object} options - Options
528
+ * @param {Object} fieldHandlers - Field handlers map
529
+ * @returns {Promise<Array>} Monthly analytics for the year
530
+ */
531
+ export async function getYearByMonth(resourceName, field, year, options, fieldHandlers) {
532
+ const data = await getAnalytics(resourceName, field, {
533
+ period: 'month',
534
+ year
535
+ }, fieldHandlers);
536
+
537
+ if (options.fillGaps) {
538
+ const startDate = `${year}-01`;
539
+ const endDate = `${year}-12`;
540
+ return fillGaps(data, 'month', startDate, endDate);
541
+ }
542
+
543
+ return data;
544
+ }
545
+
546
+ /**
547
+ * Get analytics for entire month, broken down by hours
548
+ *
549
+ * @param {string} resourceName - Resource name
550
+ * @param {string} field - Field name
551
+ * @param {string} month - Month in YYYY-MM format (or 'last' for previous month)
552
+ * @param {Object} options - Options
553
+ * @param {Object} fieldHandlers - Field handlers map
554
+ * @returns {Promise<Array>} Hourly analytics for the month (up to 24*31=744 records)
555
+ */
556
+ export async function getMonthByHour(resourceName, field, month, options, fieldHandlers) {
557
+ // month format: '2025-10' or 'last'
558
+ let year, monthNum;
559
+
560
+ if (month === 'last') {
561
+ const now = new Date();
562
+ now.setMonth(now.getMonth() - 1);
563
+ year = now.getFullYear();
564
+ monthNum = now.getMonth() + 1;
565
+ } else {
566
+ year = parseInt(month.substring(0, 4));
567
+ monthNum = parseInt(month.substring(5, 7));
568
+ }
569
+
570
+ // Get first and last day of month
571
+ const firstDay = new Date(year, monthNum - 1, 1);
572
+ const lastDay = new Date(year, monthNum, 0);
573
+
574
+ const startDate = firstDay.toISOString().substring(0, 10);
575
+ const endDate = lastDay.toISOString().substring(0, 10);
576
+
577
+ const data = await getAnalytics(resourceName, field, {
578
+ period: 'hour',
579
+ startDate,
580
+ endDate
581
+ }, fieldHandlers);
582
+
583
+ if (options.fillGaps) {
584
+ return fillGaps(data, 'hour', startDate, endDate);
585
+ }
586
+
587
+ return data;
588
+ }
589
+
590
+ /**
591
+ * Get top records by volume
592
+ *
593
+ * @param {string} resourceName - Resource name
594
+ * @param {string} field - Field name
595
+ * @param {Object} options - Query options
596
+ * @param {Object} fieldHandlers - Field handlers map
597
+ * @returns {Promise<Array>} Top records
598
+ */
599
+ export async function getTopRecords(resourceName, field, options, fieldHandlers) {
600
+ // Get handler for this resource/field combination
601
+ const resourceHandlers = fieldHandlers.get(resourceName);
602
+ if (!resourceHandlers) {
603
+ throw new Error(`No eventual consistency configured for resource: ${resourceName}`);
604
+ }
605
+
606
+ const handler = resourceHandlers.get(field);
607
+ if (!handler) {
608
+ throw new Error(`No eventual consistency configured for field: ${resourceName}.${field}`);
609
+ }
610
+
611
+ if (!handler.transactionResource) {
612
+ throw new Error('Transaction resource not initialized');
613
+ }
614
+
615
+ const { period = 'day', date, metric = 'transactionCount', limit = 10 } = options;
616
+
617
+ // Get all transactions for the period
618
+ const [ok, err, transactions] = await tryFn(() =>
619
+ handler.transactionResource.list()
620
+ );
621
+
622
+ if (!ok || !transactions) {
623
+ return [];
624
+ }
625
+
626
+ // Filter by date
627
+ let filtered = transactions;
628
+ if (date) {
629
+ if (period === 'hour') {
630
+ filtered = transactions.filter(t => t.cohortHour && t.cohortHour.startsWith(date));
631
+ } else if (period === 'day') {
632
+ filtered = transactions.filter(t => t.cohortDate === date);
633
+ } else if (period === 'month') {
634
+ filtered = transactions.filter(t => t.cohortMonth && t.cohortMonth.startsWith(date));
635
+ }
636
+ }
637
+
638
+ // Group by originalId
639
+ const byRecord = {};
640
+ for (const txn of filtered) {
641
+ const recordId = txn.originalId;
642
+ if (!byRecord[recordId]) {
643
+ byRecord[recordId] = { count: 0, sum: 0 };
644
+ }
645
+ byRecord[recordId].count++;
646
+ byRecord[recordId].sum += txn.value;
647
+ }
648
+
649
+ // Convert to array and sort
650
+ const records = Object.entries(byRecord).map(([recordId, stats]) => ({
651
+ recordId,
652
+ count: stats.count,
653
+ sum: stats.sum
654
+ }));
655
+
656
+ // Sort by metric
657
+ records.sort((a, b) => {
658
+ if (metric === 'transactionCount') {
659
+ return b.count - a.count;
660
+ } else if (metric === 'totalValue') {
661
+ return b.sum - a.sum;
662
+ }
663
+ return 0;
664
+ });
665
+
666
+ // Limit results
667
+ return records.slice(0, limit);
668
+ }