s3db.js 11.1.0 → 11.2.2
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/SECURITY.md +76 -0
- package/dist/s3db-cli.js +55029 -0
- package/dist/s3db.cjs.js +195 -7
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.es.js +195 -7
- package/dist/s3db.es.js.map +1 -1
- package/package.json +2 -4
- package/src/plugins/eventual-consistency/analytics.js +164 -2
- package/src/plugins/eventual-consistency/config.js +4 -1
- package/src/plugins/eventual-consistency/consolidation.js +38 -4
- package/src/plugins/eventual-consistency/install.js +23 -1
- package/src/plugins/eventual-consistency/utils.js +64 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "s3db.js",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.2.2",
|
|
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",
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"mcp/server.js",
|
|
56
56
|
"README.md",
|
|
57
57
|
"PLUGINS.md",
|
|
58
|
+
"SECURITY.md",
|
|
58
59
|
"UNLICENSE"
|
|
59
60
|
],
|
|
60
61
|
"dependencies": {
|
|
@@ -141,10 +142,7 @@
|
|
|
141
142
|
"test:full": "pnpm run test:js && pnpm run test:ts",
|
|
142
143
|
"benchmark": "node benchmark-compression.js",
|
|
143
144
|
"benchmark:partitions": "node docs/benchmarks/partitions-matrix.js",
|
|
144
|
-
"version": "echo 'Use pnpm run release v<version> instead of npm version'",
|
|
145
145
|
"release:check": "./scripts/pre-release-check.sh",
|
|
146
|
-
"release:prepare": "pnpm run build:binaries && echo 'Binaries ready for GitHub release'",
|
|
147
|
-
"release": "./scripts/release.sh",
|
|
148
146
|
"validate:types": "pnpm run test:ts && echo 'TypeScript definitions are valid!'",
|
|
149
147
|
"test:ts:runtime": "tsx tests/typescript/types-runtime-simple.ts"
|
|
150
148
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import tryFn from "../../concerns/try-fn.js";
|
|
7
|
-
import { groupByCohort } from "./utils.js";
|
|
7
|
+
import { groupByCohort, ensureCohortHours } from "./utils.js";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Update analytics with consolidated transactions
|
|
@@ -459,8 +459,14 @@ export async function getAnalytics(resourceName, field, options, fieldHandlers)
|
|
|
459
459
|
throw new Error('Analytics not enabled for this plugin');
|
|
460
460
|
}
|
|
461
461
|
|
|
462
|
-
const { period = 'day', date, startDate, endDate, month, year, breakdown = false } = options;
|
|
462
|
+
const { period = 'day', date, startDate, endDate, month, year, breakdown = false, recordId } = options;
|
|
463
463
|
|
|
464
|
+
// ✅ FIX BUG #1: If recordId is specified, fetch from transactions directly
|
|
465
|
+
if (recordId) {
|
|
466
|
+
return await getAnalyticsForRecord(resourceName, field, recordId, options, handler);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Original behavior: global analytics from pre-aggregated data
|
|
464
470
|
const [ok, err, allAnalytics] = await tryFn(() =>
|
|
465
471
|
handler.analyticsResource.list()
|
|
466
472
|
);
|
|
@@ -511,6 +517,156 @@ export async function getAnalytics(resourceName, field, options, fieldHandlers)
|
|
|
511
517
|
}));
|
|
512
518
|
}
|
|
513
519
|
|
|
520
|
+
/**
|
|
521
|
+
* Get analytics for a specific record from transactions
|
|
522
|
+
* ✅ FIX BUG #1: Filter by recordId
|
|
523
|
+
*
|
|
524
|
+
* @param {string} resourceName - Resource name
|
|
525
|
+
* @param {string} field - Field name
|
|
526
|
+
* @param {string} recordId - Record ID to filter by
|
|
527
|
+
* @param {Object} options - Query options
|
|
528
|
+
* @param {Object} handler - Field handler
|
|
529
|
+
* @returns {Promise<Array>} Analytics data for specific record
|
|
530
|
+
* @private
|
|
531
|
+
*/
|
|
532
|
+
async function getAnalyticsForRecord(resourceName, field, recordId, options, handler) {
|
|
533
|
+
const { period = 'day', date, startDate, endDate, month, year } = options;
|
|
534
|
+
|
|
535
|
+
// Fetch transactions for this specific record
|
|
536
|
+
// Note: We need both applied: true and applied: false transactions for analytics
|
|
537
|
+
// Same format as used in consolidation.js - the query() method auto-selects the partition
|
|
538
|
+
const [okTrue, errTrue, appliedTransactions] = await tryFn(() =>
|
|
539
|
+
handler.transactionResource.query({
|
|
540
|
+
originalId: recordId,
|
|
541
|
+
applied: true
|
|
542
|
+
})
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
const [okFalse, errFalse, pendingTransactions] = await tryFn(() =>
|
|
546
|
+
handler.transactionResource.query({
|
|
547
|
+
originalId: recordId,
|
|
548
|
+
applied: false
|
|
549
|
+
})
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
// Combine both applied and pending transactions
|
|
553
|
+
let allTransactions = [
|
|
554
|
+
...(okTrue && appliedTransactions ? appliedTransactions : []),
|
|
555
|
+
...(okFalse && pendingTransactions ? pendingTransactions : [])
|
|
556
|
+
];
|
|
557
|
+
|
|
558
|
+
if (allTransactions.length === 0) {
|
|
559
|
+
return [];
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ✅ FIX BUG #2: Ensure all transactions have cohortHour calculated
|
|
563
|
+
// This handles legacy data that may be missing cohortHour
|
|
564
|
+
allTransactions = ensureCohortHours(allTransactions, handler.config?.cohort?.timezone || 'UTC', false);
|
|
565
|
+
|
|
566
|
+
// Filter transactions by temporal range
|
|
567
|
+
let filtered = allTransactions;
|
|
568
|
+
|
|
569
|
+
if (date) {
|
|
570
|
+
if (period === 'hour') {
|
|
571
|
+
// Match all hours of the date
|
|
572
|
+
filtered = filtered.filter(t => t.cohortHour && t.cohortHour.startsWith(date));
|
|
573
|
+
} else if (period === 'day') {
|
|
574
|
+
filtered = filtered.filter(t => t.cohortDate === date);
|
|
575
|
+
} else if (period === 'month') {
|
|
576
|
+
filtered = filtered.filter(t => t.cohortMonth && t.cohortMonth.startsWith(date));
|
|
577
|
+
}
|
|
578
|
+
} else if (startDate && endDate) {
|
|
579
|
+
if (period === 'hour') {
|
|
580
|
+
filtered = filtered.filter(t => t.cohortHour && t.cohortHour >= startDate && t.cohortHour <= endDate);
|
|
581
|
+
} else if (period === 'day') {
|
|
582
|
+
filtered = filtered.filter(t => t.cohortDate && t.cohortDate >= startDate && t.cohortDate <= endDate);
|
|
583
|
+
} else if (period === 'month') {
|
|
584
|
+
filtered = filtered.filter(t => t.cohortMonth && t.cohortMonth >= startDate && t.cohortMonth <= endDate);
|
|
585
|
+
}
|
|
586
|
+
} else if (month) {
|
|
587
|
+
if (period === 'hour') {
|
|
588
|
+
filtered = filtered.filter(t => t.cohortHour && t.cohortHour.startsWith(month));
|
|
589
|
+
} else if (period === 'day') {
|
|
590
|
+
filtered = filtered.filter(t => t.cohortDate && t.cohortDate.startsWith(month));
|
|
591
|
+
}
|
|
592
|
+
} else if (year) {
|
|
593
|
+
if (period === 'hour') {
|
|
594
|
+
filtered = filtered.filter(t => t.cohortHour && t.cohortHour.startsWith(String(year)));
|
|
595
|
+
} else if (period === 'day') {
|
|
596
|
+
filtered = filtered.filter(t => t.cohortDate && t.cohortDate.startsWith(String(year)));
|
|
597
|
+
} else if (period === 'month') {
|
|
598
|
+
filtered = filtered.filter(t => t.cohortMonth && t.cohortMonth.startsWith(String(year)));
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Aggregate transactions by cohort
|
|
603
|
+
const cohortField = period === 'hour' ? 'cohortHour' : period === 'day' ? 'cohortDate' : 'cohortMonth';
|
|
604
|
+
const aggregated = aggregateTransactionsByCohort(filtered, cohortField);
|
|
605
|
+
|
|
606
|
+
return aggregated;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Aggregate transactions by cohort field
|
|
611
|
+
* ✅ Helper for BUG #1 fix
|
|
612
|
+
*
|
|
613
|
+
* @param {Array} transactions - Transactions to aggregate
|
|
614
|
+
* @param {string} cohortField - Cohort field name ('cohortHour', 'cohortDate', 'cohortMonth')
|
|
615
|
+
* @returns {Array} Aggregated analytics
|
|
616
|
+
* @private
|
|
617
|
+
*/
|
|
618
|
+
function aggregateTransactionsByCohort(transactions, cohortField) {
|
|
619
|
+
const groups = {};
|
|
620
|
+
|
|
621
|
+
for (const txn of transactions) {
|
|
622
|
+
const cohort = txn[cohortField];
|
|
623
|
+
if (!cohort) continue;
|
|
624
|
+
|
|
625
|
+
if (!groups[cohort]) {
|
|
626
|
+
groups[cohort] = {
|
|
627
|
+
cohort,
|
|
628
|
+
count: 0,
|
|
629
|
+
sum: 0,
|
|
630
|
+
min: Infinity,
|
|
631
|
+
max: -Infinity,
|
|
632
|
+
recordCount: new Set(),
|
|
633
|
+
operations: {}
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const group = groups[cohort];
|
|
638
|
+
const signedValue = txn.operation === 'sub' ? -txn.value : txn.value;
|
|
639
|
+
|
|
640
|
+
group.count++;
|
|
641
|
+
group.sum += signedValue;
|
|
642
|
+
group.min = Math.min(group.min, signedValue);
|
|
643
|
+
group.max = Math.max(group.max, signedValue);
|
|
644
|
+
group.recordCount.add(txn.originalId);
|
|
645
|
+
|
|
646
|
+
// Track operation breakdown
|
|
647
|
+
const op = txn.operation;
|
|
648
|
+
if (!group.operations[op]) {
|
|
649
|
+
group.operations[op] = { count: 0, sum: 0 };
|
|
650
|
+
}
|
|
651
|
+
group.operations[op].count++;
|
|
652
|
+
group.operations[op].sum += signedValue;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Convert to array and finalize
|
|
656
|
+
return Object.values(groups)
|
|
657
|
+
.map(g => ({
|
|
658
|
+
cohort: g.cohort,
|
|
659
|
+
count: g.count,
|
|
660
|
+
sum: g.sum,
|
|
661
|
+
avg: g.sum / g.count,
|
|
662
|
+
min: g.min === Infinity ? 0 : g.min,
|
|
663
|
+
max: g.max === -Infinity ? 0 : g.max,
|
|
664
|
+
recordCount: g.recordCount.size,
|
|
665
|
+
operations: g.operations
|
|
666
|
+
}))
|
|
667
|
+
.sort((a, b) => a.cohort.localeCompare(b.cohort));
|
|
668
|
+
}
|
|
669
|
+
|
|
514
670
|
/**
|
|
515
671
|
* Get analytics for entire month, broken down by days
|
|
516
672
|
*
|
|
@@ -587,7 +743,9 @@ export async function getLastNDays(resourceName, field, days, options, fieldHand
|
|
|
587
743
|
return date.toISOString().substring(0, 10);
|
|
588
744
|
}).reverse();
|
|
589
745
|
|
|
746
|
+
// ✅ FIX BUG #1: Pass through recordId and other options
|
|
590
747
|
const data = await getAnalytics(resourceName, field, {
|
|
748
|
+
...options, // ✅ Include all options (recordId, etc.)
|
|
591
749
|
period: 'day',
|
|
592
750
|
startDate: dates[0],
|
|
593
751
|
endDate: dates[dates.length - 1]
|
|
@@ -942,7 +1100,9 @@ export async function getLastNHours(resourceName, field, hours = 24, options, fi
|
|
|
942
1100
|
const startHour = hoursAgo.toISOString().substring(0, 13); // YYYY-MM-DDTHH
|
|
943
1101
|
const endHour = now.toISOString().substring(0, 13);
|
|
944
1102
|
|
|
1103
|
+
// ✅ FIX BUG #1: Pass through recordId and other options
|
|
945
1104
|
const data = await getAnalytics(resourceName, field, {
|
|
1105
|
+
...options, // ✅ Include all options (recordId, etc.)
|
|
946
1106
|
period: 'hour',
|
|
947
1107
|
startDate: startHour,
|
|
948
1108
|
endDate: endHour
|
|
@@ -1023,7 +1183,9 @@ export async function getLastNMonths(resourceName, field, months = 12, options,
|
|
|
1023
1183
|
const startDate = monthsAgo.toISOString().substring(0, 7); // YYYY-MM
|
|
1024
1184
|
const endDate = now.toISOString().substring(0, 7);
|
|
1025
1185
|
|
|
1186
|
+
// ✅ FIX BUG #1: Pass through recordId and other options
|
|
1026
1187
|
const data = await getAnalytics(resourceName, field, {
|
|
1188
|
+
...options, // ✅ Include all options (recordId, etc.)
|
|
1027
1189
|
period: 'month',
|
|
1028
1190
|
startDate,
|
|
1029
1191
|
endDate
|
|
@@ -47,9 +47,12 @@ export function createConfig(options, detectedTimezone) {
|
|
|
47
47
|
autoConsolidate: consolidation.auto !== false,
|
|
48
48
|
mode: consolidation.mode || 'async',
|
|
49
49
|
|
|
50
|
-
// ✅
|
|
50
|
+
// ✅ Performance tuning - Mark applied concurrency (default 50, up from 10)
|
|
51
51
|
markAppliedConcurrency: consolidation.markAppliedConcurrency ?? 50,
|
|
52
52
|
|
|
53
|
+
// ✅ Performance tuning - Recalculate concurrency (default 50, up from 10)
|
|
54
|
+
recalculateConcurrency: consolidation.recalculateConcurrency ?? 50,
|
|
55
|
+
|
|
53
56
|
// Late arrivals
|
|
54
57
|
lateArrivalStrategy: lateArrivals.strategy || 'warn',
|
|
55
58
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import tryFn from "../../concerns/try-fn.js";
|
|
7
7
|
import { PromisePool } from "@supercharge/promise-pool";
|
|
8
8
|
import { idGenerator } from "../../concerns/id.js";
|
|
9
|
-
import { getCohortInfo, createSyntheticSetTransaction } from "./utils.js";
|
|
9
|
+
import { getCohortInfo, createSyntheticSetTransaction, ensureCohortHour } from "./utils.js";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Start consolidation timer for a handler
|
|
@@ -612,12 +612,43 @@ export async function consolidateRecord(
|
|
|
612
612
|
.for(transactionsToUpdate)
|
|
613
613
|
.withConcurrency(markAppliedConcurrency) // ✅ Configurável e maior!
|
|
614
614
|
.process(async (txn) => {
|
|
615
|
+
// ✅ FIX BUG #3: Ensure cohort fields exist before marking as applied
|
|
616
|
+
// This handles legacy transactions missing cohortHour, cohortDate, etc.
|
|
617
|
+
const txnWithCohorts = ensureCohortHour(txn, config.cohort.timezone, false);
|
|
618
|
+
|
|
619
|
+
// Build update data with applied flag
|
|
620
|
+
const updateData = { applied: true };
|
|
621
|
+
|
|
622
|
+
// Add missing cohort fields if they were calculated
|
|
623
|
+
if (txnWithCohorts.cohortHour && !txn.cohortHour) {
|
|
624
|
+
updateData.cohortHour = txnWithCohorts.cohortHour;
|
|
625
|
+
}
|
|
626
|
+
if (txnWithCohorts.cohortDate && !txn.cohortDate) {
|
|
627
|
+
updateData.cohortDate = txnWithCohorts.cohortDate;
|
|
628
|
+
}
|
|
629
|
+
if (txnWithCohorts.cohortWeek && !txn.cohortWeek) {
|
|
630
|
+
updateData.cohortWeek = txnWithCohorts.cohortWeek;
|
|
631
|
+
}
|
|
632
|
+
if (txnWithCohorts.cohortMonth && !txn.cohortMonth) {
|
|
633
|
+
updateData.cohortMonth = txnWithCohorts.cohortMonth;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Handle null value field (legacy data might have null)
|
|
637
|
+
if (txn.value === null || txn.value === undefined) {
|
|
638
|
+
updateData.value = 1; // Default to 1 for backward compatibility
|
|
639
|
+
}
|
|
640
|
+
|
|
615
641
|
const [ok, err] = await tryFn(() =>
|
|
616
|
-
transactionResource.update(txn.id,
|
|
642
|
+
transactionResource.update(txn.id, updateData)
|
|
617
643
|
);
|
|
618
644
|
|
|
619
645
|
if (!ok && config.verbose) {
|
|
620
|
-
console.warn(
|
|
646
|
+
console.warn(
|
|
647
|
+
`[EventualConsistency] Failed to mark transaction ${txn.id} as applied:`,
|
|
648
|
+
err?.message,
|
|
649
|
+
'Update data:',
|
|
650
|
+
updateData
|
|
651
|
+
);
|
|
621
652
|
}
|
|
622
653
|
|
|
623
654
|
return ok;
|
|
@@ -917,9 +948,12 @@ export async function recalculateRecord(
|
|
|
917
948
|
// Exclude anchor transactions (they should always be applied)
|
|
918
949
|
const transactionsToReset = allTransactions.filter(txn => txn.source !== 'anchor');
|
|
919
950
|
|
|
951
|
+
// ✅ OPTIMIZATION: Use higher concurrency for recalculate (default 50 vs 10)
|
|
952
|
+
const recalculateConcurrency = config.recalculateConcurrency || 50;
|
|
953
|
+
|
|
920
954
|
const { results, errors } = await PromisePool
|
|
921
955
|
.for(transactionsToReset)
|
|
922
|
-
.withConcurrency(
|
|
956
|
+
.withConcurrency(recalculateConcurrency)
|
|
923
957
|
.process(async (txn) => {
|
|
924
958
|
const [ok, err] = await tryFn(() =>
|
|
925
959
|
transactionResource.update(txn.id, { applied: false })
|
|
@@ -100,7 +100,7 @@ export async function completeFieldSetup(handler, database, config, plugin) {
|
|
|
100
100
|
operation: 'string|required',
|
|
101
101
|
timestamp: 'string|required',
|
|
102
102
|
cohortDate: 'string|required',
|
|
103
|
-
cohortHour: 'string|
|
|
103
|
+
cohortHour: 'string|optional', // ✅ FIX BUG #2: Changed from required to optional for migration compatibility
|
|
104
104
|
cohortWeek: 'string|optional',
|
|
105
105
|
cohortMonth: 'string|optional',
|
|
106
106
|
source: 'string|optional',
|
|
@@ -173,6 +173,28 @@ async function createAnalyticsResource(handler, database, resourceName, fieldNam
|
|
|
173
173
|
},
|
|
174
174
|
behavior: 'body-overflow',
|
|
175
175
|
timestamps: false,
|
|
176
|
+
asyncPartitions: true,
|
|
177
|
+
// ✅ Multi-attribute partitions for optimal analytics query performance
|
|
178
|
+
partitions: {
|
|
179
|
+
// Query by period (hour/day/week/month)
|
|
180
|
+
byPeriod: {
|
|
181
|
+
fields: { period: 'string' }
|
|
182
|
+
},
|
|
183
|
+
// Query by period + cohort (e.g., all hour records for specific hours)
|
|
184
|
+
byPeriodCohort: {
|
|
185
|
+
fields: {
|
|
186
|
+
period: 'string',
|
|
187
|
+
cohort: 'string'
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
// Query by field + period (e.g., all daily analytics for clicks field)
|
|
191
|
+
byFieldPeriod: {
|
|
192
|
+
fields: {
|
|
193
|
+
field: 'string',
|
|
194
|
+
period: 'string'
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
},
|
|
176
198
|
createdBy: 'EventualConsistencyPlugin'
|
|
177
199
|
})
|
|
178
200
|
);
|
|
@@ -355,3 +355,67 @@ export function groupByCohort(transactions, cohortField) {
|
|
|
355
355
|
}
|
|
356
356
|
return groups;
|
|
357
357
|
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Ensure transaction has cohortHour field
|
|
361
|
+
* ✅ FIX BUG #2: Calculate cohortHour from timestamp if missing
|
|
362
|
+
*
|
|
363
|
+
* @param {Object} transaction - Transaction to check/fix
|
|
364
|
+
* @param {string} timezone - Timezone to use for cohort calculation
|
|
365
|
+
* @param {boolean} verbose - Whether to log warnings
|
|
366
|
+
* @returns {Object} Transaction with cohortHour populated
|
|
367
|
+
*/
|
|
368
|
+
export function ensureCohortHour(transaction, timezone = 'UTC', verbose = false) {
|
|
369
|
+
// If cohortHour already exists, return as-is
|
|
370
|
+
if (transaction.cohortHour) {
|
|
371
|
+
return transaction;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Calculate cohortHour from timestamp
|
|
375
|
+
if (transaction.timestamp) {
|
|
376
|
+
const date = new Date(transaction.timestamp);
|
|
377
|
+
const cohortInfo = getCohortInfo(date, timezone, verbose);
|
|
378
|
+
|
|
379
|
+
if (verbose) {
|
|
380
|
+
console.log(
|
|
381
|
+
`[EventualConsistency] Transaction ${transaction.id} missing cohortHour, ` +
|
|
382
|
+
`calculated from timestamp: ${cohortInfo.hour}`
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Add cohortHour (and other cohort fields if missing)
|
|
387
|
+
transaction.cohortHour = cohortInfo.hour;
|
|
388
|
+
|
|
389
|
+
if (!transaction.cohortWeek) {
|
|
390
|
+
transaction.cohortWeek = cohortInfo.week;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (!transaction.cohortMonth) {
|
|
394
|
+
transaction.cohortMonth = cohortInfo.month;
|
|
395
|
+
}
|
|
396
|
+
} else if (verbose) {
|
|
397
|
+
console.warn(
|
|
398
|
+
`[EventualConsistency] Transaction ${transaction.id} missing both cohortHour and timestamp, ` +
|
|
399
|
+
`cannot calculate cohort`
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return transaction;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Ensure all transactions in array have cohortHour
|
|
408
|
+
* ✅ FIX BUG #2: Batch version of ensureCohortHour
|
|
409
|
+
*
|
|
410
|
+
* @param {Array} transactions - Transactions to check/fix
|
|
411
|
+
* @param {string} timezone - Timezone to use for cohort calculation
|
|
412
|
+
* @param {boolean} verbose - Whether to log warnings
|
|
413
|
+
* @returns {Array} Transactions with cohortHour populated
|
|
414
|
+
*/
|
|
415
|
+
export function ensureCohortHours(transactions, timezone = 'UTC', verbose = false) {
|
|
416
|
+
if (!transactions || !Array.isArray(transactions)) {
|
|
417
|
+
return transactions;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return transactions.map(txn => ensureCohortHour(txn, timezone, verbose));
|
|
421
|
+
}
|