s3db.js 11.2.0 → 11.2.3

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.
@@ -1209,3 +1209,148 @@ export async function getLastNMonths(resourceName, field, months = 12, options,
1209
1209
 
1210
1210
  return data;
1211
1211
  }
1212
+
1213
+ /**
1214
+ * Get raw transaction events for custom aggregation
1215
+ *
1216
+ * This method provides direct access to the underlying transaction events,
1217
+ * allowing developers to perform custom aggregations beyond the pre-built analytics.
1218
+ * Useful for complex queries, custom metrics, or when you need the raw event data.
1219
+ *
1220
+ * @param {string} resourceName - Resource name
1221
+ * @param {string} field - Field name
1222
+ * @param {Object} options - Query options
1223
+ * @param {string} options.recordId - Filter by specific record ID
1224
+ * @param {string} options.startDate - Start date filter (YYYY-MM-DD or YYYY-MM-DDTHH)
1225
+ * @param {string} options.endDate - End date filter (YYYY-MM-DD or YYYY-MM-DDTHH)
1226
+ * @param {string} options.cohortDate - Filter by cohort date (YYYY-MM-DD)
1227
+ * @param {string} options.cohortHour - Filter by cohort hour (YYYY-MM-DDTHH)
1228
+ * @param {string} options.cohortMonth - Filter by cohort month (YYYY-MM)
1229
+ * @param {boolean} options.applied - Filter by applied status (true/false/undefined for both)
1230
+ * @param {string} options.operation - Filter by operation type ('add', 'sub', 'set')
1231
+ * @param {number} options.limit - Maximum number of events to return
1232
+ * @param {Object} fieldHandlers - Field handlers map
1233
+ * @returns {Promise<Array>} Raw transaction events
1234
+ *
1235
+ * @example
1236
+ * // Get all events for a specific record
1237
+ * const events = await plugin.getRawEvents('wallets', 'balance', {
1238
+ * recordId: 'wallet1'
1239
+ * });
1240
+ *
1241
+ * @example
1242
+ * // Get events for a specific time range
1243
+ * const events = await plugin.getRawEvents('wallets', 'balance', {
1244
+ * startDate: '2025-10-01',
1245
+ * endDate: '2025-10-31'
1246
+ * });
1247
+ *
1248
+ * @example
1249
+ * // Get only pending (unapplied) transactions
1250
+ * const pending = await plugin.getRawEvents('wallets', 'balance', {
1251
+ * applied: false
1252
+ * });
1253
+ */
1254
+ export async function getRawEvents(resourceName, field, options, fieldHandlers) {
1255
+ // Get handler for this resource/field combination
1256
+ const resourceHandlers = fieldHandlers.get(resourceName);
1257
+ if (!resourceHandlers) {
1258
+ throw new Error(`No eventual consistency configured for resource: ${resourceName}`);
1259
+ }
1260
+
1261
+ const handler = resourceHandlers.get(field);
1262
+ if (!handler) {
1263
+ throw new Error(`No eventual consistency configured for field: ${resourceName}.${field}`);
1264
+ }
1265
+
1266
+ if (!handler.transactionResource) {
1267
+ throw new Error('Transaction resource not initialized');
1268
+ }
1269
+
1270
+ const {
1271
+ recordId,
1272
+ startDate,
1273
+ endDate,
1274
+ cohortDate,
1275
+ cohortHour,
1276
+ cohortMonth,
1277
+ applied,
1278
+ operation,
1279
+ limit
1280
+ } = options;
1281
+
1282
+ // Build query object for partition-based filtering
1283
+ const query = {};
1284
+
1285
+ // Filter by recordId (uses partition if available)
1286
+ if (recordId !== undefined) {
1287
+ query.originalId = recordId;
1288
+ }
1289
+
1290
+ // Filter by applied status (uses partition)
1291
+ if (applied !== undefined) {
1292
+ query.applied = applied;
1293
+ }
1294
+
1295
+ // Fetch transactions using partition-aware query
1296
+ const [ok, err, allTransactions] = await tryFn(() =>
1297
+ handler.transactionResource.query(query)
1298
+ );
1299
+
1300
+ if (!ok || !allTransactions) {
1301
+ return [];
1302
+ }
1303
+
1304
+ // Ensure all transactions have cohort fields
1305
+ let filtered = allTransactions;
1306
+
1307
+ // Filter by operation type
1308
+ if (operation !== undefined) {
1309
+ filtered = filtered.filter(t => t.operation === operation);
1310
+ }
1311
+
1312
+ // Filter by temporal fields (these are in-memory filters after partition query)
1313
+ if (cohortDate) {
1314
+ filtered = filtered.filter(t => t.cohortDate === cohortDate);
1315
+ }
1316
+
1317
+ if (cohortHour) {
1318
+ filtered = filtered.filter(t => t.cohortHour === cohortHour);
1319
+ }
1320
+
1321
+ if (cohortMonth) {
1322
+ filtered = filtered.filter(t => t.cohortMonth === cohortMonth);
1323
+ }
1324
+
1325
+ if (startDate && endDate) {
1326
+ // Determine which cohort field to use based on format
1327
+ const isHourly = startDate.length > 10; // YYYY-MM-DDTHH vs YYYY-MM-DD
1328
+ const cohortField = isHourly ? 'cohortHour' : 'cohortDate';
1329
+
1330
+ filtered = filtered.filter(t =>
1331
+ t[cohortField] && t[cohortField] >= startDate && t[cohortField] <= endDate
1332
+ );
1333
+ } else if (startDate) {
1334
+ const isHourly = startDate.length > 10;
1335
+ const cohortField = isHourly ? 'cohortHour' : 'cohortDate';
1336
+ filtered = filtered.filter(t => t[cohortField] && t[cohortField] >= startDate);
1337
+ } else if (endDate) {
1338
+ const isHourly = endDate.length > 10;
1339
+ const cohortField = isHourly ? 'cohortHour' : 'cohortDate';
1340
+ filtered = filtered.filter(t => t[cohortField] && t[cohortField] <= endDate);
1341
+ }
1342
+
1343
+ // Sort by timestamp (newest first by default)
1344
+ filtered.sort((a, b) => {
1345
+ const aTime = new Date(a.timestamp || a.createdAt).getTime();
1346
+ const bTime = new Date(b.timestamp || b.createdAt).getTime();
1347
+ return bTime - aTime;
1348
+ });
1349
+
1350
+ // Apply limit
1351
+ if (limit && limit > 0) {
1352
+ filtered = filtered.slice(0, limit);
1353
+ }
1354
+
1355
+ return filtered;
1356
+ }
@@ -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 })
@@ -17,7 +17,7 @@ import {
17
17
  runConsolidation
18
18
  } from "./consolidation.js";
19
19
  import { runGarbageCollection } from "./garbage-collection.js";
20
- import { updateAnalytics, getAnalytics, getMonthByDay, getDayByHour, getLastNDays, getYearByMonth, getYearByWeek, getMonthByWeek, getMonthByHour, getTopRecords, getYearByDay, getWeekByDay, getWeekByHour, getLastNHours, getLastNWeeks, getLastNMonths } from "./analytics.js";
20
+ import { updateAnalytics, getAnalytics, getMonthByDay, getDayByHour, getLastNDays, getYearByMonth, getYearByWeek, getMonthByWeek, getMonthByHour, getTopRecords, getYearByDay, getWeekByDay, getWeekByHour, getLastNHours, getLastNWeeks, getLastNMonths, getRawEvents } from "./analytics.js";
21
21
  import { onInstall, onStart, onStop, watchForResource, completeFieldSetup } from "./install.js";
22
22
 
23
23
  export class EventualConsistencyPlugin extends Plugin {
@@ -531,6 +531,208 @@ export class EventualConsistencyPlugin extends Plugin {
531
531
  async getLastNMonths(resourceName, field, months = 12, options = {}) {
532
532
  return await getLastNMonths(resourceName, field, months, options, this.fieldHandlers);
533
533
  }
534
+
535
+ /**
536
+ * Get raw transaction events for custom aggregation
537
+ *
538
+ * This method provides direct access to the underlying transaction events,
539
+ * allowing developers to perform custom aggregations beyond the pre-built analytics.
540
+ * Useful for complex queries, custom metrics, or when you need the raw event data.
541
+ *
542
+ * @param {string} resourceName - Resource name
543
+ * @param {string} field - Field name
544
+ * @param {Object} options - Query options
545
+ * @param {string} options.recordId - Filter by specific record ID
546
+ * @param {string} options.startDate - Start date filter (YYYY-MM-DD or YYYY-MM-DDTHH)
547
+ * @param {string} options.endDate - End date filter (YYYY-MM-DD or YYYY-MM-DDTHH)
548
+ * @param {string} options.cohortDate - Filter by cohort date (YYYY-MM-DD)
549
+ * @param {string} options.cohortHour - Filter by cohort hour (YYYY-MM-DDTHH)
550
+ * @param {string} options.cohortMonth - Filter by cohort month (YYYY-MM)
551
+ * @param {boolean} options.applied - Filter by applied status (true/false/undefined for both)
552
+ * @param {string} options.operation - Filter by operation type ('add', 'sub', 'set')
553
+ * @param {number} options.limit - Maximum number of events to return
554
+ * @returns {Promise<Array>} Raw transaction events
555
+ *
556
+ * @example
557
+ * // Get all events for a specific record
558
+ * const events = await plugin.getRawEvents('wallets', 'balance', {
559
+ * recordId: 'wallet1'
560
+ * });
561
+ *
562
+ * @example
563
+ * // Get events for a specific time range
564
+ * const events = await plugin.getRawEvents('wallets', 'balance', {
565
+ * startDate: '2025-10-01',
566
+ * endDate: '2025-10-31'
567
+ * });
568
+ *
569
+ * @example
570
+ * // Get only pending (unapplied) transactions
571
+ * const pending = await plugin.getRawEvents('wallets', 'balance', {
572
+ * applied: false
573
+ * });
574
+ */
575
+ async getRawEvents(resourceName, field, options = {}) {
576
+ return await getRawEvents(resourceName, field, options, this.fieldHandlers);
577
+ }
578
+
579
+ /**
580
+ * Get diagnostics information about the plugin state
581
+ *
582
+ * This method provides comprehensive diagnostic information about the EventualConsistencyPlugin,
583
+ * including configured resources, field handlers, timers, and overall health status.
584
+ * Useful for debugging initialization issues, configuration problems, or runtime errors.
585
+ *
586
+ * @param {Object} options - Diagnostic options
587
+ * @param {string} options.resourceName - Optional: limit diagnostics to specific resource
588
+ * @param {string} options.field - Optional: limit diagnostics to specific field
589
+ * @param {boolean} options.includeStats - Include transaction statistics (default: false)
590
+ * @returns {Promise<Object>} Diagnostic information
591
+ *
592
+ * @example
593
+ * // Get overall plugin diagnostics
594
+ * const diagnostics = await plugin.getDiagnostics();
595
+ * console.log(diagnostics);
596
+ *
597
+ * @example
598
+ * // Get diagnostics for specific resource/field with stats
599
+ * const diagnostics = await plugin.getDiagnostics({
600
+ * resourceName: 'wallets',
601
+ * field: 'balance',
602
+ * includeStats: true
603
+ * });
604
+ */
605
+ async getDiagnostics(options = {}) {
606
+ const { resourceName, field, includeStats = false } = options;
607
+
608
+ const diagnostics = {
609
+ plugin: {
610
+ name: 'EventualConsistencyPlugin',
611
+ initialized: this.database !== null && this.database !== undefined,
612
+ verbose: this.config.verbose || false,
613
+ timezone: this.config.cohort?.timezone || 'UTC',
614
+ consolidation: {
615
+ mode: this.config.consolidation?.mode || 'timer',
616
+ interval: this.config.consolidation?.interval || 60000,
617
+ batchSize: this.config.consolidation?.batchSize || 100
618
+ },
619
+ garbageCollection: {
620
+ enabled: this.config.garbageCollection?.enabled !== false,
621
+ retentionDays: this.config.garbageCollection?.retentionDays || 30,
622
+ interval: this.config.garbageCollection?.interval || 3600000
623
+ }
624
+ },
625
+ resources: [],
626
+ errors: [],
627
+ warnings: []
628
+ };
629
+
630
+ // Iterate through configured resources
631
+ for (const [resName, resourceHandlers] of this.fieldHandlers.entries()) {
632
+ // Skip if filtering by resource and this isn't it
633
+ if (resourceName && resName !== resourceName) {
634
+ continue;
635
+ }
636
+
637
+ const resourceDiag = {
638
+ name: resName,
639
+ fields: []
640
+ };
641
+
642
+ for (const [fieldName, handler] of resourceHandlers.entries()) {
643
+ // Skip if filtering by field and this isn't it
644
+ if (field && fieldName !== field) {
645
+ continue;
646
+ }
647
+
648
+ const fieldDiag = {
649
+ name: fieldName,
650
+ type: handler.type || 'counter',
651
+ analyticsEnabled: handler.analyticsResource !== null && handler.analyticsResource !== undefined,
652
+ resources: {
653
+ transaction: handler.transactionResource?.name || null,
654
+ target: handler.targetResource?.name || null,
655
+ analytics: handler.analyticsResource?.name || null
656
+ },
657
+ timers: {
658
+ consolidation: handler.consolidationTimer !== null && handler.consolidationTimer !== undefined,
659
+ garbageCollection: handler.garbageCollectionTimer !== null && handler.garbageCollectionTimer !== undefined
660
+ }
661
+ };
662
+
663
+ // Check for common issues
664
+ if (!handler.transactionResource) {
665
+ diagnostics.errors.push({
666
+ resource: resName,
667
+ field: fieldName,
668
+ issue: 'Missing transaction resource',
669
+ suggestion: 'Ensure plugin is installed and resources are created after plugin installation'
670
+ });
671
+ }
672
+
673
+ if (!handler.targetResource) {
674
+ diagnostics.warnings.push({
675
+ resource: resName,
676
+ field: fieldName,
677
+ issue: 'Missing target resource',
678
+ suggestion: 'Target resource may not have been created yet'
679
+ });
680
+ }
681
+
682
+ if (handler.analyticsResource && !handler.analyticsResource.name) {
683
+ diagnostics.errors.push({
684
+ resource: resName,
685
+ field: fieldName,
686
+ issue: 'Invalid analytics resource',
687
+ suggestion: 'Analytics resource exists but has no name - possible initialization failure'
688
+ });
689
+ }
690
+
691
+ // Include statistics if requested
692
+ if (includeStats && handler.transactionResource) {
693
+ try {
694
+ const [okPending, errPending, pendingTxns] = await handler.transactionResource.query({ applied: false }).catch(() => [false, null, []]);
695
+ const [okApplied, errApplied, appliedTxns] = await handler.transactionResource.query({ applied: true }).catch(() => [false, null, []]);
696
+
697
+ fieldDiag.stats = {
698
+ pendingTransactions: okPending ? (pendingTxns?.length || 0) : 'error',
699
+ appliedTransactions: okApplied ? (appliedTxns?.length || 0) : 'error',
700
+ totalTransactions: (okPending && okApplied) ? ((pendingTxns?.length || 0) + (appliedTxns?.length || 0)) : 'error'
701
+ };
702
+
703
+ if (handler.analyticsResource) {
704
+ const [okAnalytics, errAnalytics, analyticsRecords] = await handler.analyticsResource.list().catch(() => [false, null, []]);
705
+ fieldDiag.stats.analyticsRecords = okAnalytics ? (analyticsRecords?.length || 0) : 'error';
706
+ }
707
+ } catch (error) {
708
+ diagnostics.warnings.push({
709
+ resource: resName,
710
+ field: fieldName,
711
+ issue: 'Failed to fetch statistics',
712
+ error: error.message
713
+ });
714
+ }
715
+ }
716
+
717
+ resourceDiag.fields.push(fieldDiag);
718
+ }
719
+
720
+ if (resourceDiag.fields.length > 0) {
721
+ diagnostics.resources.push(resourceDiag);
722
+ }
723
+ }
724
+
725
+ // Overall health check
726
+ diagnostics.health = {
727
+ status: diagnostics.errors.length === 0 ? (diagnostics.warnings.length === 0 ? 'healthy' : 'warning') : 'error',
728
+ totalResources: diagnostics.resources.length,
729
+ totalFields: diagnostics.resources.reduce((sum, r) => sum + r.fields.length, 0),
730
+ errorCount: diagnostics.errors.length,
731
+ warningCount: diagnostics.warnings.length
732
+ };
733
+
734
+ return diagnostics;
735
+ }
534
736
  }
535
737
 
536
738
  export default EventualConsistencyPlugin;
@@ -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
  );
@@ -134,6 +134,7 @@ export class Resource extends AsyncEventEmitter {
134
134
  idGenerator: customIdGenerator,
135
135
  idSize = 22,
136
136
  versioningEnabled = false,
137
+ strictValidation = true,
137
138
  events = {},
138
139
  asyncEvents = true,
139
140
  asyncPartitions = true,
@@ -149,6 +150,7 @@ export class Resource extends AsyncEventEmitter {
149
150
  this.parallelism = parallelism;
150
151
  this.passphrase = passphrase ?? 'secret';
151
152
  this.versioningEnabled = versioningEnabled;
153
+ this.strictValidation = strictValidation;
152
154
 
153
155
  // Configure async events mode
154
156
  this.setAsyncMode(asyncEvents);
@@ -476,9 +478,14 @@ export class Resource extends AsyncEventEmitter {
476
478
 
477
479
  /**
478
480
  * Validate that all partition fields exist in current resource attributes
479
- * @throws {Error} If partition fields don't exist in current schema
481
+ * @throws {Error} If partition fields don't exist in current schema (only when strictValidation is true)
480
482
  */
481
483
  validatePartitions() {
484
+ // Skip validation if strictValidation is disabled
485
+ if (!this.strictValidation) {
486
+ return;
487
+ }
488
+
482
489
  if (!this.config.partitions) {
483
490
  return; // No partitions to validate
484
491
  }