s3db.js 10.0.3 → 10.0.5

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.
@@ -51,11 +51,21 @@ export class EventualConsistencyPlugin extends Plugin {
51
51
  lockTimeout: options.lockTimeout || 300, // 5 minutes (in seconds, configurable)
52
52
  transactionRetention: options.transactionRetention || 30, // Days to keep applied transactions
53
53
  gcInterval: options.gcInterval || 86400, // 24 hours (in seconds)
54
- verbose: options.verbose || false
54
+ verbose: options.verbose || false,
55
+
56
+ // Analytics configuration
57
+ enableAnalytics: options.enableAnalytics || false,
58
+ analyticsConfig: {
59
+ periods: options.analyticsConfig?.periods || ['hour', 'day', 'month'],
60
+ metrics: options.analyticsConfig?.metrics || ['count', 'sum', 'avg', 'min', 'max'],
61
+ rollupStrategy: options.analyticsConfig?.rollupStrategy || 'incremental', // 'incremental' or 'batch'
62
+ retentionDays: options.analyticsConfig?.retentionDays || 365
63
+ }
55
64
  };
56
-
65
+
57
66
  this.transactionResource = null;
58
67
  this.targetResource = null;
68
+ this.analyticsResource = null; // Analytics resource
59
69
  this.consolidationTimer = null;
60
70
  this.gcTimer = null; // Garbage collection timer
61
71
  this.pendingTransactions = new Map(); // Cache for batching
@@ -165,6 +175,11 @@ export class EventualConsistencyPlugin extends Plugin {
165
175
 
166
176
  this.lockResource = lockOk ? lockResource : this.database.resources[lockResourceName];
167
177
 
178
+ // Create analytics resource if enabled
179
+ if (this.config.enableAnalytics) {
180
+ await this.createAnalyticsResource();
181
+ }
182
+
168
183
  // Add helper methods to the resource
169
184
  this.addHelperMethods();
170
185
 
@@ -236,6 +251,57 @@ export class EventualConsistencyPlugin extends Plugin {
236
251
  return partitions;
237
252
  }
238
253
 
254
+ async createAnalyticsResource() {
255
+ const analyticsResourceName = `${this.config.resource}_analytics_${this.config.field}`;
256
+
257
+ const [ok, err, analyticsResource] = await tryFn(() =>
258
+ this.database.createResource({
259
+ name: analyticsResourceName,
260
+ attributes: {
261
+ id: 'string|required',
262
+ period: 'string|required', // 'hour', 'day', 'month'
263
+ cohort: 'string|required', // ISO format: '2025-10-09T14', '2025-10-09', '2025-10'
264
+
265
+ // Aggregated metrics
266
+ transactionCount: 'number|required',
267
+ totalValue: 'number|required',
268
+ avgValue: 'number|required',
269
+ minValue: 'number|required',
270
+ maxValue: 'number|required',
271
+
272
+ // Operation breakdown
273
+ operations: 'object|optional', // { add: { count, sum }, sub: { count, sum }, set: { count, sum } }
274
+
275
+ // Metadata
276
+ recordCount: 'number|required', // Distinct originalIds
277
+ consolidatedAt: 'string|required',
278
+ updatedAt: 'string|required'
279
+ },
280
+ behavior: 'body-overflow',
281
+ timestamps: false,
282
+ partitions: {
283
+ byPeriod: {
284
+ fields: { period: 'string' }
285
+ },
286
+ byCohort: {
287
+ fields: { cohort: 'string' }
288
+ }
289
+ }
290
+ })
291
+ );
292
+
293
+ if (!ok && !this.database.resources[analyticsResourceName]) {
294
+ console.warn(`[EventualConsistency] Failed to create analytics resource: ${err?.message}`);
295
+ return;
296
+ }
297
+
298
+ this.analyticsResource = ok ? analyticsResource : this.database.resources[analyticsResourceName];
299
+
300
+ if (this.config.verbose) {
301
+ console.log(`[EventualConsistency] Analytics resource created: ${analyticsResourceName}`);
302
+ }
303
+ }
304
+
239
305
  /**
240
306
  * Auto-detect timezone from environment or system
241
307
  * @private
@@ -729,6 +795,11 @@ export class EventualConsistencyPlugin extends Plugin {
729
795
  if (errors && errors.length > 0 && this.config.verbose) {
730
796
  console.warn(`[EventualConsistency] ${errors.length} transactions failed to mark as applied`);
731
797
  }
798
+
799
+ // Update analytics if enabled (only for real transactions, not synthetic)
800
+ if (this.config.enableAnalytics && transactionsToUpdate.length > 0) {
801
+ await this.updateAnalytics(transactionsToUpdate);
802
+ }
732
803
  }
733
804
 
734
805
  return consolidatedValue;
@@ -1007,6 +1078,619 @@ export class EventualConsistencyPlugin extends Plugin {
1007
1078
  await tryFn(() => this.lockResource.delete(gcLockId));
1008
1079
  }
1009
1080
  }
1081
+
1082
+ /**
1083
+ * Update analytics with consolidated transactions
1084
+ * @param {Array} transactions - Array of transactions that were just consolidated
1085
+ * @private
1086
+ */
1087
+ async updateAnalytics(transactions) {
1088
+ if (!this.analyticsResource || transactions.length === 0) return;
1089
+
1090
+ try {
1091
+ // Group transactions by cohort hour
1092
+ const byHour = this._groupByCohort(transactions, 'cohortHour');
1093
+
1094
+ // Update hourly analytics
1095
+ for (const [cohort, txns] of Object.entries(byHour)) {
1096
+ await this._upsertAnalytics('hour', cohort, txns);
1097
+ }
1098
+
1099
+ // Roll up to daily and monthly if configured
1100
+ if (this.config.analyticsConfig.rollupStrategy === 'incremental') {
1101
+ const uniqueHours = Object.keys(byHour);
1102
+ for (const cohortHour of uniqueHours) {
1103
+ await this._rollupAnalytics(cohortHour);
1104
+ }
1105
+ }
1106
+ } catch (error) {
1107
+ if (this.config.verbose) {
1108
+ console.warn(`[EventualConsistency] Analytics update error:`, error.message);
1109
+ }
1110
+ }
1111
+ }
1112
+
1113
+ /**
1114
+ * Group transactions by cohort
1115
+ * @private
1116
+ */
1117
+ _groupByCohort(transactions, cohortField) {
1118
+ const groups = {};
1119
+ for (const txn of transactions) {
1120
+ const cohort = txn[cohortField];
1121
+ if (!cohort) continue;
1122
+
1123
+ if (!groups[cohort]) {
1124
+ groups[cohort] = [];
1125
+ }
1126
+ groups[cohort].push(txn);
1127
+ }
1128
+ return groups;
1129
+ }
1130
+
1131
+ /**
1132
+ * Upsert analytics for a specific period and cohort
1133
+ * @private
1134
+ */
1135
+ async _upsertAnalytics(period, cohort, transactions) {
1136
+ const id = `${period}-${cohort}`;
1137
+
1138
+ // Calculate metrics
1139
+ const transactionCount = transactions.length;
1140
+
1141
+ // Calculate signed values (considering operation type)
1142
+ const signedValues = transactions.map(t => {
1143
+ if (t.operation === 'sub') return -t.value;
1144
+ return t.value;
1145
+ });
1146
+
1147
+ const totalValue = signedValues.reduce((sum, v) => sum + v, 0);
1148
+ const avgValue = totalValue / transactionCount;
1149
+ const minValue = Math.min(...signedValues);
1150
+ const maxValue = Math.max(...signedValues);
1151
+
1152
+ // Calculate operation breakdown
1153
+ const operations = this._calculateOperationBreakdown(transactions);
1154
+
1155
+ // Count distinct records
1156
+ const recordCount = new Set(transactions.map(t => t.originalId)).size;
1157
+
1158
+ const now = new Date().toISOString();
1159
+
1160
+ // Try to get existing analytics
1161
+ const [existingOk, existingErr, existing] = await tryFn(() =>
1162
+ this.analyticsResource.get(id)
1163
+ );
1164
+
1165
+ if (existingOk && existing) {
1166
+ // Update existing analytics (incremental)
1167
+ const newTransactionCount = existing.transactionCount + transactionCount;
1168
+ const newTotalValue = existing.totalValue + totalValue;
1169
+ const newAvgValue = newTotalValue / newTransactionCount;
1170
+ const newMinValue = Math.min(existing.minValue, minValue);
1171
+ const newMaxValue = Math.max(existing.maxValue, maxValue);
1172
+
1173
+ // Merge operation breakdown
1174
+ const newOperations = { ...existing.operations };
1175
+ for (const [op, stats] of Object.entries(operations)) {
1176
+ if (!newOperations[op]) {
1177
+ newOperations[op] = { count: 0, sum: 0 };
1178
+ }
1179
+ newOperations[op].count += stats.count;
1180
+ newOperations[op].sum += stats.sum;
1181
+ }
1182
+
1183
+ // Update record count (approximate - we don't track all unique IDs)
1184
+ const newRecordCount = Math.max(existing.recordCount, recordCount);
1185
+
1186
+ await tryFn(() =>
1187
+ this.analyticsResource.update(id, {
1188
+ transactionCount: newTransactionCount,
1189
+ totalValue: newTotalValue,
1190
+ avgValue: newAvgValue,
1191
+ minValue: newMinValue,
1192
+ maxValue: newMaxValue,
1193
+ operations: newOperations,
1194
+ recordCount: newRecordCount,
1195
+ updatedAt: now
1196
+ })
1197
+ );
1198
+ } else {
1199
+ // Create new analytics
1200
+ await tryFn(() =>
1201
+ this.analyticsResource.insert({
1202
+ id,
1203
+ period,
1204
+ cohort,
1205
+ transactionCount,
1206
+ totalValue,
1207
+ avgValue,
1208
+ minValue,
1209
+ maxValue,
1210
+ operations,
1211
+ recordCount,
1212
+ consolidatedAt: now,
1213
+ updatedAt: now
1214
+ })
1215
+ );
1216
+ }
1217
+ }
1218
+
1219
+ /**
1220
+ * Calculate operation breakdown
1221
+ * @private
1222
+ */
1223
+ _calculateOperationBreakdown(transactions) {
1224
+ const breakdown = {};
1225
+
1226
+ for (const txn of transactions) {
1227
+ const op = txn.operation;
1228
+ if (!breakdown[op]) {
1229
+ breakdown[op] = { count: 0, sum: 0 };
1230
+ }
1231
+ breakdown[op].count++;
1232
+
1233
+ // Use signed value for sum (sub operations are negative)
1234
+ const signedValue = op === 'sub' ? -txn.value : txn.value;
1235
+ breakdown[op].sum += signedValue;
1236
+ }
1237
+
1238
+ return breakdown;
1239
+ }
1240
+
1241
+ /**
1242
+ * Roll up hourly analytics to daily and monthly
1243
+ * @private
1244
+ */
1245
+ async _rollupAnalytics(cohortHour) {
1246
+ // cohortHour format: '2025-10-09T14'
1247
+ const cohortDate = cohortHour.substring(0, 10); // '2025-10-09'
1248
+ const cohortMonth = cohortHour.substring(0, 7); // '2025-10'
1249
+
1250
+ // Roll up to day
1251
+ await this._rollupPeriod('day', cohortDate, cohortDate);
1252
+
1253
+ // Roll up to month
1254
+ await this._rollupPeriod('month', cohortMonth, cohortMonth);
1255
+ }
1256
+
1257
+ /**
1258
+ * Roll up analytics for a specific period
1259
+ * @private
1260
+ */
1261
+ async _rollupPeriod(period, cohort, sourcePrefix) {
1262
+ // Get all source analytics (e.g., all hours for a day)
1263
+ const sourcePeriod = period === 'day' ? 'hour' : 'day';
1264
+
1265
+ const [ok, err, allAnalytics] = await tryFn(() =>
1266
+ this.analyticsResource.list()
1267
+ );
1268
+
1269
+ if (!ok || !allAnalytics) return;
1270
+
1271
+ // Filter to matching cohorts
1272
+ const sourceAnalytics = allAnalytics.filter(a =>
1273
+ a.period === sourcePeriod && a.cohort.startsWith(sourcePrefix)
1274
+ );
1275
+
1276
+ if (sourceAnalytics.length === 0) return;
1277
+
1278
+ // Aggregate metrics
1279
+ const transactionCount = sourceAnalytics.reduce((sum, a) => sum + a.transactionCount, 0);
1280
+ const totalValue = sourceAnalytics.reduce((sum, a) => sum + a.totalValue, 0);
1281
+ const avgValue = totalValue / transactionCount;
1282
+ const minValue = Math.min(...sourceAnalytics.map(a => a.minValue));
1283
+ const maxValue = Math.max(...sourceAnalytics.map(a => a.maxValue));
1284
+
1285
+ // Merge operation breakdown
1286
+ const operations = {};
1287
+ for (const analytics of sourceAnalytics) {
1288
+ for (const [op, stats] of Object.entries(analytics.operations || {})) {
1289
+ if (!operations[op]) {
1290
+ operations[op] = { count: 0, sum: 0 };
1291
+ }
1292
+ operations[op].count += stats.count;
1293
+ operations[op].sum += stats.sum;
1294
+ }
1295
+ }
1296
+
1297
+ // Approximate record count (max of all periods)
1298
+ const recordCount = Math.max(...sourceAnalytics.map(a => a.recordCount));
1299
+
1300
+ const id = `${period}-${cohort}`;
1301
+ const now = new Date().toISOString();
1302
+
1303
+ // Upsert rolled-up analytics
1304
+ const [existingOk, existingErr, existing] = await tryFn(() =>
1305
+ this.analyticsResource.get(id)
1306
+ );
1307
+
1308
+ if (existingOk && existing) {
1309
+ await tryFn(() =>
1310
+ this.analyticsResource.update(id, {
1311
+ transactionCount,
1312
+ totalValue,
1313
+ avgValue,
1314
+ minValue,
1315
+ maxValue,
1316
+ operations,
1317
+ recordCount,
1318
+ updatedAt: now
1319
+ })
1320
+ );
1321
+ } else {
1322
+ await tryFn(() =>
1323
+ this.analyticsResource.insert({
1324
+ id,
1325
+ period,
1326
+ cohort,
1327
+ transactionCount,
1328
+ totalValue,
1329
+ avgValue,
1330
+ minValue,
1331
+ maxValue,
1332
+ operations,
1333
+ recordCount,
1334
+ consolidatedAt: now,
1335
+ updatedAt: now
1336
+ })
1337
+ );
1338
+ }
1339
+ }
1340
+
1341
+ /**
1342
+ * Get analytics for a specific period
1343
+ * @param {string} resourceName - Resource name
1344
+ * @param {string} field - Field name
1345
+ * @param {Object} options - Query options
1346
+ * @returns {Promise<Array>} Analytics data
1347
+ */
1348
+ async getAnalytics(resourceName, field, options = {}) {
1349
+ if (!this.analyticsResource) {
1350
+ throw new Error('Analytics not enabled for this plugin');
1351
+ }
1352
+
1353
+ const { period = 'day', date, startDate, endDate, month, year, breakdown = false } = options;
1354
+
1355
+ const [ok, err, allAnalytics] = await tryFn(() =>
1356
+ this.analyticsResource.list()
1357
+ );
1358
+
1359
+ if (!ok || !allAnalytics) {
1360
+ return [];
1361
+ }
1362
+
1363
+ // Filter by period
1364
+ let filtered = allAnalytics.filter(a => a.period === period);
1365
+
1366
+ // Filter by date/range
1367
+ if (date) {
1368
+ if (period === 'hour') {
1369
+ // Match all hours of the date
1370
+ filtered = filtered.filter(a => a.cohort.startsWith(date));
1371
+ } else {
1372
+ filtered = filtered.filter(a => a.cohort === date);
1373
+ }
1374
+ } else if (startDate && endDate) {
1375
+ filtered = filtered.filter(a => a.cohort >= startDate && a.cohort <= endDate);
1376
+ } else if (month) {
1377
+ filtered = filtered.filter(a => a.cohort.startsWith(month));
1378
+ } else if (year) {
1379
+ filtered = filtered.filter(a => a.cohort.startsWith(String(year)));
1380
+ }
1381
+
1382
+ // Sort by cohort
1383
+ filtered.sort((a, b) => a.cohort.localeCompare(b.cohort));
1384
+
1385
+ // Return with or without breakdown
1386
+ if (breakdown === 'operations') {
1387
+ return filtered.map(a => ({
1388
+ cohort: a.cohort,
1389
+ ...a.operations
1390
+ }));
1391
+ }
1392
+
1393
+ return filtered.map(a => ({
1394
+ cohort: a.cohort,
1395
+ count: a.transactionCount,
1396
+ sum: a.totalValue,
1397
+ avg: a.avgValue,
1398
+ min: a.minValue,
1399
+ max: a.maxValue,
1400
+ operations: a.operations,
1401
+ recordCount: a.recordCount
1402
+ }));
1403
+ }
1404
+
1405
+ /**
1406
+ * Fill gaps in analytics data with zeros for continuous time series
1407
+ * @private
1408
+ * @param {Array} data - Sparse analytics data
1409
+ * @param {string} period - Period type ('hour', 'day', 'month')
1410
+ * @param {string} startDate - Start date (ISO format)
1411
+ * @param {string} endDate - End date (ISO format)
1412
+ * @returns {Array} Complete time series with gaps filled
1413
+ */
1414
+ _fillGaps(data, period, startDate, endDate) {
1415
+ if (!data || data.length === 0) {
1416
+ // If no data, still generate empty series
1417
+ data = [];
1418
+ }
1419
+
1420
+ // Create a map of existing data by cohort
1421
+ const dataMap = new Map();
1422
+ data.forEach(item => {
1423
+ dataMap.set(item.cohort, item);
1424
+ });
1425
+
1426
+ const result = [];
1427
+ const emptyRecord = {
1428
+ count: 0,
1429
+ sum: 0,
1430
+ avg: 0,
1431
+ min: 0,
1432
+ max: 0,
1433
+ recordCount: 0
1434
+ };
1435
+
1436
+ if (period === 'hour') {
1437
+ // Generate all hours between startDate and endDate
1438
+ const start = new Date(startDate + 'T00:00:00Z');
1439
+ const end = new Date(endDate + 'T23:59:59Z');
1440
+
1441
+ for (let dt = new Date(start); dt <= end; dt.setHours(dt.getHours() + 1)) {
1442
+ const cohort = dt.toISOString().substring(0, 13); // YYYY-MM-DDTHH
1443
+ result.push(dataMap.get(cohort) || { cohort, ...emptyRecord });
1444
+ }
1445
+ } else if (period === 'day') {
1446
+ // Generate all days between startDate and endDate
1447
+ const start = new Date(startDate);
1448
+ const end = new Date(endDate);
1449
+
1450
+ for (let dt = new Date(start); dt <= end; dt.setDate(dt.getDate() + 1)) {
1451
+ const cohort = dt.toISOString().substring(0, 10); // YYYY-MM-DD
1452
+ result.push(dataMap.get(cohort) || { cohort, ...emptyRecord });
1453
+ }
1454
+ } else if (period === 'month') {
1455
+ // Generate all months between startDate and endDate
1456
+ const startYear = parseInt(startDate.substring(0, 4));
1457
+ const startMonth = parseInt(startDate.substring(5, 7));
1458
+ const endYear = parseInt(endDate.substring(0, 4));
1459
+ const endMonth = parseInt(endDate.substring(5, 7));
1460
+
1461
+ for (let year = startYear; year <= endYear; year++) {
1462
+ const firstMonth = (year === startYear) ? startMonth : 1;
1463
+ const lastMonth = (year === endYear) ? endMonth : 12;
1464
+
1465
+ for (let month = firstMonth; month <= lastMonth; month++) {
1466
+ const cohort = `${year}-${month.toString().padStart(2, '0')}`;
1467
+ result.push(dataMap.get(cohort) || { cohort, ...emptyRecord });
1468
+ }
1469
+ }
1470
+ }
1471
+
1472
+ return result;
1473
+ }
1474
+
1475
+ /**
1476
+ * Get analytics for entire month, broken down by days
1477
+ * @param {string} resourceName - Resource name
1478
+ * @param {string} field - Field name
1479
+ * @param {string} month - Month in YYYY-MM format
1480
+ * @param {Object} options - Options
1481
+ * @param {boolean} options.fillGaps - Fill missing days with zeros (default: false)
1482
+ * @returns {Promise<Array>} Daily analytics for the month
1483
+ */
1484
+ async getMonthByDay(resourceName, field, month, options = {}) {
1485
+ // month format: '2025-10'
1486
+ const year = parseInt(month.substring(0, 4));
1487
+ const monthNum = parseInt(month.substring(5, 7));
1488
+
1489
+ // Get first and last day of month
1490
+ const firstDay = new Date(year, monthNum - 1, 1);
1491
+ const lastDay = new Date(year, monthNum, 0);
1492
+
1493
+ const startDate = firstDay.toISOString().substring(0, 10);
1494
+ const endDate = lastDay.toISOString().substring(0, 10);
1495
+
1496
+ const data = await this.getAnalytics(resourceName, field, {
1497
+ period: 'day',
1498
+ startDate,
1499
+ endDate
1500
+ });
1501
+
1502
+ if (options.fillGaps) {
1503
+ return this._fillGaps(data, 'day', startDate, endDate);
1504
+ }
1505
+
1506
+ return data;
1507
+ }
1508
+
1509
+ /**
1510
+ * Get analytics for entire day, broken down by hours
1511
+ * @param {string} resourceName - Resource name
1512
+ * @param {string} field - Field name
1513
+ * @param {string} date - Date in YYYY-MM-DD format
1514
+ * @param {Object} options - Options
1515
+ * @param {boolean} options.fillGaps - Fill missing hours with zeros (default: false)
1516
+ * @returns {Promise<Array>} Hourly analytics for the day
1517
+ */
1518
+ async getDayByHour(resourceName, field, date, options = {}) {
1519
+ // date format: '2025-10-09'
1520
+ const data = await this.getAnalytics(resourceName, field, {
1521
+ period: 'hour',
1522
+ date
1523
+ });
1524
+
1525
+ if (options.fillGaps) {
1526
+ return this._fillGaps(data, 'hour', date, date);
1527
+ }
1528
+
1529
+ return data;
1530
+ }
1531
+
1532
+ /**
1533
+ * Get analytics for last N days, broken down by days
1534
+ * @param {string} resourceName - Resource name
1535
+ * @param {string} field - Field name
1536
+ * @param {number} days - Number of days to look back (default: 7)
1537
+ * @param {Object} options - Options
1538
+ * @param {boolean} options.fillGaps - Fill missing days with zeros (default: false)
1539
+ * @returns {Promise<Array>} Daily analytics
1540
+ */
1541
+ async getLastNDays(resourceName, field, days = 7, options = {}) {
1542
+ const dates = Array.from({ length: days }, (_, i) => {
1543
+ const date = new Date();
1544
+ date.setDate(date.getDate() - i);
1545
+ return date.toISOString().substring(0, 10);
1546
+ }).reverse();
1547
+
1548
+ const data = await this.getAnalytics(resourceName, field, {
1549
+ period: 'day',
1550
+ startDate: dates[0],
1551
+ endDate: dates[dates.length - 1]
1552
+ });
1553
+
1554
+ if (options.fillGaps) {
1555
+ return this._fillGaps(data, 'day', dates[0], dates[dates.length - 1]);
1556
+ }
1557
+
1558
+ return data;
1559
+ }
1560
+
1561
+ /**
1562
+ * Get analytics for entire year, broken down by months
1563
+ * @param {string} resourceName - Resource name
1564
+ * @param {string} field - Field name
1565
+ * @param {number} year - Year (e.g., 2025)
1566
+ * @param {Object} options - Options
1567
+ * @param {boolean} options.fillGaps - Fill missing months with zeros (default: false)
1568
+ * @returns {Promise<Array>} Monthly analytics for the year
1569
+ */
1570
+ async getYearByMonth(resourceName, field, year, options = {}) {
1571
+ const data = await this.getAnalytics(resourceName, field, {
1572
+ period: 'month',
1573
+ year
1574
+ });
1575
+
1576
+ if (options.fillGaps) {
1577
+ const startDate = `${year}-01`;
1578
+ const endDate = `${year}-12`;
1579
+ return this._fillGaps(data, 'month', startDate, endDate);
1580
+ }
1581
+
1582
+ return data;
1583
+ }
1584
+
1585
+ /**
1586
+ * Get analytics for entire month, broken down by hours
1587
+ * @param {string} resourceName - Resource name
1588
+ * @param {string} field - Field name
1589
+ * @param {string} month - Month in YYYY-MM format (or 'last' for previous month)
1590
+ * @param {Object} options - Options
1591
+ * @param {boolean} options.fillGaps - Fill missing hours with zeros (default: false)
1592
+ * @returns {Promise<Array>} Hourly analytics for the month (up to 24*31=744 records)
1593
+ */
1594
+ async getMonthByHour(resourceName, field, month, options = {}) {
1595
+ // month format: '2025-10' or 'last'
1596
+ let year, monthNum;
1597
+
1598
+ if (month === 'last') {
1599
+ const now = new Date();
1600
+ now.setMonth(now.getMonth() - 1);
1601
+ year = now.getFullYear();
1602
+ monthNum = now.getMonth() + 1;
1603
+ } else {
1604
+ year = parseInt(month.substring(0, 4));
1605
+ monthNum = parseInt(month.substring(5, 7));
1606
+ }
1607
+
1608
+ // Get first and last day of month
1609
+ const firstDay = new Date(year, monthNum - 1, 1);
1610
+ const lastDay = new Date(year, monthNum, 0);
1611
+
1612
+ const startDate = firstDay.toISOString().substring(0, 10);
1613
+ const endDate = lastDay.toISOString().substring(0, 10);
1614
+
1615
+ const data = await this.getAnalytics(resourceName, field, {
1616
+ period: 'hour',
1617
+ startDate,
1618
+ endDate
1619
+ });
1620
+
1621
+ if (options.fillGaps) {
1622
+ return this._fillGaps(data, 'hour', startDate, endDate);
1623
+ }
1624
+
1625
+ return data;
1626
+ }
1627
+
1628
+ /**
1629
+ * Get top records by volume
1630
+ * @param {string} resourceName - Resource name
1631
+ * @param {string} field - Field name
1632
+ * @param {Object} options - Query options
1633
+ * @returns {Promise<Array>} Top records
1634
+ */
1635
+ async getTopRecords(resourceName, field, options = {}) {
1636
+ if (!this.transactionResource) {
1637
+ throw new Error('Transaction resource not initialized');
1638
+ }
1639
+
1640
+ const { period = 'day', date, metric = 'transactionCount', limit = 10 } = options;
1641
+
1642
+ // Get all transactions for the period
1643
+ const [ok, err, transactions] = await tryFn(() =>
1644
+ this.transactionResource.list()
1645
+ );
1646
+
1647
+ if (!ok || !transactions) {
1648
+ return [];
1649
+ }
1650
+
1651
+ // Filter by date
1652
+ let filtered = transactions;
1653
+ if (date) {
1654
+ if (period === 'hour') {
1655
+ filtered = transactions.filter(t => t.cohortHour && t.cohortHour.startsWith(date));
1656
+ } else if (period === 'day') {
1657
+ filtered = transactions.filter(t => t.cohortDate === date);
1658
+ } else if (period === 'month') {
1659
+ filtered = transactions.filter(t => t.cohortMonth && t.cohortMonth.startsWith(date));
1660
+ }
1661
+ }
1662
+
1663
+ // Group by originalId
1664
+ const byRecord = {};
1665
+ for (const txn of filtered) {
1666
+ const recordId = txn.originalId;
1667
+ if (!byRecord[recordId]) {
1668
+ byRecord[recordId] = { count: 0, sum: 0 };
1669
+ }
1670
+ byRecord[recordId].count++;
1671
+ byRecord[recordId].sum += txn.value;
1672
+ }
1673
+
1674
+ // Convert to array and sort
1675
+ const records = Object.entries(byRecord).map(([recordId, stats]) => ({
1676
+ recordId,
1677
+ count: stats.count,
1678
+ sum: stats.sum
1679
+ }));
1680
+
1681
+ // Sort by metric
1682
+ records.sort((a, b) => {
1683
+ if (metric === 'transactionCount') {
1684
+ return b.count - a.count;
1685
+ } else if (metric === 'totalValue') {
1686
+ return b.sum - a.sum;
1687
+ }
1688
+ return 0;
1689
+ });
1690
+
1691
+ // Limit results
1692
+ return records.slice(0, limit);
1693
+ }
1010
1694
  }
1011
1695
 
1012
1696
  export default EventualConsistencyPlugin;