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/dist/s3db.cjs.js +1758 -1574
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.es.js +1758 -1574
- package/dist/s3db.es.js.map +1 -1
- package/package.json +1 -1
- package/src/plugins/eventual-consistency/analytics.js +668 -0
- package/src/plugins/eventual-consistency/config.js +120 -0
- package/src/plugins/eventual-consistency/consolidation.js +770 -0
- package/src/plugins/eventual-consistency/garbage-collection.js +126 -0
- package/src/plugins/eventual-consistency/helpers.js +179 -0
- package/src/plugins/eventual-consistency/index.js +455 -0
- package/src/plugins/eventual-consistency/locks.js +77 -0
- package/src/plugins/eventual-consistency/partitions.js +45 -0
- package/src/plugins/eventual-consistency/setup.js +298 -0
- package/src/plugins/eventual-consistency/transactions.js +119 -0
- package/src/plugins/eventual-consistency/utils.js +182 -0
- package/src/plugins/eventual-consistency.plugin.js +216 -52
- package/src/plugins/index.js +1 -1
package/package.json
CHANGED
|
@@ -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
|
+
}
|