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.
- package/dist/s3db-cli.js +0 -0
- package/dist/s3db.cjs.js +549 -2
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.es.js +549 -2
- package/dist/s3db.es.js.map +1 -1
- package/mcp/README.md +1728 -0
- package/package.json +24 -22
- package/src/plugins/eventual-consistency.plugin.js +686 -2
|
@@ -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;
|