s3db.js 10.0.3 → 10.0.4

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.3",
3
+ "version": "10.0.4",
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",
@@ -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,507 @@ 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
+ * Get analytics for entire month, broken down by days
1407
+ * @param {string} resourceName - Resource name
1408
+ * @param {string} field - Field name
1409
+ * @param {string} month - Month in YYYY-MM format
1410
+ * @returns {Promise<Array>} Daily analytics for the month
1411
+ */
1412
+ async getMonthByDay(resourceName, field, month) {
1413
+ // month format: '2025-10'
1414
+ const year = parseInt(month.substring(0, 4));
1415
+ const monthNum = parseInt(month.substring(5, 7));
1416
+
1417
+ // Get first and last day of month
1418
+ const firstDay = new Date(year, monthNum - 1, 1);
1419
+ const lastDay = new Date(year, monthNum, 0);
1420
+
1421
+ const startDate = firstDay.toISOString().substring(0, 10);
1422
+ const endDate = lastDay.toISOString().substring(0, 10);
1423
+
1424
+ return await this.getAnalytics(resourceName, field, {
1425
+ period: 'day',
1426
+ startDate,
1427
+ endDate
1428
+ });
1429
+ }
1430
+
1431
+ /**
1432
+ * Get analytics for entire day, broken down by hours
1433
+ * @param {string} resourceName - Resource name
1434
+ * @param {string} field - Field name
1435
+ * @param {string} date - Date in YYYY-MM-DD format
1436
+ * @returns {Promise<Array>} Hourly analytics for the day
1437
+ */
1438
+ async getDayByHour(resourceName, field, date) {
1439
+ // date format: '2025-10-09'
1440
+ return await this.getAnalytics(resourceName, field, {
1441
+ period: 'hour',
1442
+ date
1443
+ });
1444
+ }
1445
+
1446
+ /**
1447
+ * Get analytics for last N days, broken down by days
1448
+ * @param {string} resourceName - Resource name
1449
+ * @param {string} field - Field name
1450
+ * @param {number} days - Number of days to look back (default: 7)
1451
+ * @returns {Promise<Array>} Daily analytics
1452
+ */
1453
+ async getLastNDays(resourceName, field, days = 7) {
1454
+ const dates = Array.from({ length: days }, (_, i) => {
1455
+ const date = new Date();
1456
+ date.setDate(date.getDate() - i);
1457
+ return date.toISOString().substring(0, 10);
1458
+ }).reverse();
1459
+
1460
+ return await this.getAnalytics(resourceName, field, {
1461
+ period: 'day',
1462
+ startDate: dates[0],
1463
+ endDate: dates[dates.length - 1]
1464
+ });
1465
+ }
1466
+
1467
+ /**
1468
+ * Get analytics for entire year, broken down by months
1469
+ * @param {string} resourceName - Resource name
1470
+ * @param {string} field - Field name
1471
+ * @param {number} year - Year (e.g., 2025)
1472
+ * @returns {Promise<Array>} Monthly analytics for the year
1473
+ */
1474
+ async getYearByMonth(resourceName, field, year) {
1475
+ return await this.getAnalytics(resourceName, field, {
1476
+ period: 'month',
1477
+ year
1478
+ });
1479
+ }
1480
+
1481
+ /**
1482
+ * Get analytics for entire month, broken down by hours
1483
+ * @param {string} resourceName - Resource name
1484
+ * @param {string} field - Field name
1485
+ * @param {string} month - Month in YYYY-MM format (or 'last' for previous month)
1486
+ * @returns {Promise<Array>} Hourly analytics for the month (up to 24*31=744 records)
1487
+ */
1488
+ async getMonthByHour(resourceName, field, month) {
1489
+ // month format: '2025-10' or 'last'
1490
+ let year, monthNum;
1491
+
1492
+ if (month === 'last') {
1493
+ const now = new Date();
1494
+ now.setMonth(now.getMonth() - 1);
1495
+ year = now.getFullYear();
1496
+ monthNum = now.getMonth() + 1;
1497
+ } else {
1498
+ year = parseInt(month.substring(0, 4));
1499
+ monthNum = parseInt(month.substring(5, 7));
1500
+ }
1501
+
1502
+ // Get first and last day of month
1503
+ const firstDay = new Date(year, monthNum - 1, 1);
1504
+ const lastDay = new Date(year, monthNum, 0);
1505
+
1506
+ const startDate = firstDay.toISOString().substring(0, 10);
1507
+ const endDate = lastDay.toISOString().substring(0, 10);
1508
+
1509
+ return await this.getAnalytics(resourceName, field, {
1510
+ period: 'hour',
1511
+ startDate,
1512
+ endDate
1513
+ });
1514
+ }
1515
+
1516
+ /**
1517
+ * Get top records by volume
1518
+ * @param {string} resourceName - Resource name
1519
+ * @param {string} field - Field name
1520
+ * @param {Object} options - Query options
1521
+ * @returns {Promise<Array>} Top records
1522
+ */
1523
+ async getTopRecords(resourceName, field, options = {}) {
1524
+ if (!this.transactionResource) {
1525
+ throw new Error('Transaction resource not initialized');
1526
+ }
1527
+
1528
+ const { period = 'day', date, metric = 'transactionCount', limit = 10 } = options;
1529
+
1530
+ // Get all transactions for the period
1531
+ const [ok, err, transactions] = await tryFn(() =>
1532
+ this.transactionResource.list()
1533
+ );
1534
+
1535
+ if (!ok || !transactions) {
1536
+ return [];
1537
+ }
1538
+
1539
+ // Filter by date
1540
+ let filtered = transactions;
1541
+ if (date) {
1542
+ if (period === 'hour') {
1543
+ filtered = transactions.filter(t => t.cohortHour && t.cohortHour.startsWith(date));
1544
+ } else if (period === 'day') {
1545
+ filtered = transactions.filter(t => t.cohortDate === date);
1546
+ } else if (period === 'month') {
1547
+ filtered = transactions.filter(t => t.cohortMonth && t.cohortMonth.startsWith(date));
1548
+ }
1549
+ }
1550
+
1551
+ // Group by originalId
1552
+ const byRecord = {};
1553
+ for (const txn of filtered) {
1554
+ const recordId = txn.originalId;
1555
+ if (!byRecord[recordId]) {
1556
+ byRecord[recordId] = { count: 0, sum: 0 };
1557
+ }
1558
+ byRecord[recordId].count++;
1559
+ byRecord[recordId].sum += txn.value;
1560
+ }
1561
+
1562
+ // Convert to array and sort
1563
+ const records = Object.entries(byRecord).map(([recordId, stats]) => ({
1564
+ recordId,
1565
+ count: stats.count,
1566
+ sum: stats.sum
1567
+ }));
1568
+
1569
+ // Sort by metric
1570
+ records.sort((a, b) => {
1571
+ if (metric === 'transactionCount') {
1572
+ return b.count - a.count;
1573
+ } else if (metric === 'totalValue') {
1574
+ return b.sum - a.sum;
1575
+ }
1576
+ return 0;
1577
+ });
1578
+
1579
+ // Limit results
1580
+ return records.slice(0, limit);
1581
+ }
1010
1582
  }
1011
1583
 
1012
1584
  export default EventualConsistencyPlugin;