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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s3db.js",
3
- "version": "11.1.0",
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
- // ✅ NOVO: Performance tuning - Mark applied concurrency (default 50, antes era 10 hardcoded)
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, { applied: true })
642
+ transactionResource.update(txn.id, updateData)
617
643
  );
618
644
 
619
645
  if (!ok && config.verbose) {
620
- console.warn(`[EventualConsistency] Failed to mark transaction ${txn.id} as applied:`, err?.message);
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(10)
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|required',
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
+ }