s3db.js 10.0.16 → 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.
@@ -54,7 +54,14 @@ export class EventualConsistencyPlugin extends Plugin {
54
54
  metrics: options.analyticsConfig?.metrics || ['count', 'sum', 'avg', 'min', 'max'],
55
55
  rollupStrategy: options.analyticsConfig?.rollupStrategy || 'incremental',
56
56
  retentionDays: options.analyticsConfig?.retentionDays || 365
57
- }
57
+ },
58
+ // Checkpoint configuration for high-volume scenarios
59
+ enableCheckpoints: options.enableCheckpoints !== false, // Default: true
60
+ checkpointStrategy: options.checkpointStrategy || 'hourly', // 'hourly', 'daily', 'manual', 'disabled'
61
+ checkpointRetention: options.checkpointRetention || 90, // Days to keep checkpoints
62
+ checkpointThreshold: options.checkpointThreshold || 1000, // Min transactions before creating checkpoint
63
+ deleteConsolidatedTransactions: options.deleteConsolidatedTransactions !== false, // Delete transactions after checkpoint
64
+ autoCheckpoint: options.autoCheckpoint !== false // Auto-create checkpoints for old cohorts
58
65
  };
59
66
 
60
67
  // Create field handlers map
@@ -116,6 +123,7 @@ export class EventualConsistencyPlugin extends Plugin {
116
123
  targetResource: null,
117
124
  analyticsResource: null,
118
125
  lockResource: null,
126
+ checkpointResource: null, // NEW: Checkpoint resource for high-volume optimization
119
127
  consolidationTimer: null,
120
128
  gcTimer: null,
121
129
  pendingTransactions: new Map(),
@@ -371,8 +379,20 @@ export class EventualConsistencyPlugin extends Plugin {
371
379
  }
372
380
 
373
381
  createPartitionConfig() {
374
- // Create hourly, daily and monthly partitions for transactions
382
+ // Create partitions for transactions
375
383
  const partitions = {
384
+ // Composite partition by originalId + applied status
385
+ // This is THE MOST CRITICAL optimization for consolidation!
386
+ // Why: Consolidation always queries { originalId, applied: false }
387
+ // Without this: Reads ALL transactions (applied + pending) and filters manually
388
+ // With this: Reads ONLY pending transactions - can be 1000x faster!
389
+ byOriginalIdAndApplied: {
390
+ fields: {
391
+ originalId: 'string',
392
+ applied: 'boolean'
393
+ }
394
+ },
395
+ // Partition by time cohorts for batch consolidation across many records
376
396
  byHour: {
377
397
  fields: {
378
398
  cohortHour: 'string'
@@ -730,6 +750,50 @@ export class EventualConsistencyPlugin extends Plugin {
730
750
 
731
751
  return result;
732
752
  };
753
+
754
+ // Add method to recalculate from scratch
755
+ // Signature: recalculate(id, field)
756
+ resource.recalculate = async (id, field) => {
757
+ if (!field) {
758
+ throw new Error(`Field parameter is required: recalculate(id, field)`);
759
+ }
760
+
761
+ const handler = resource._eventualConsistencyPlugins[field];
762
+
763
+ if (!handler) {
764
+ const availableFields = Object.keys(resource._eventualConsistencyPlugins).join(', ');
765
+ throw new Error(
766
+ `No eventual consistency plugin found for field "${field}". ` +
767
+ `Available fields: ${availableFields}`
768
+ );
769
+ }
770
+
771
+ // Temporarily set config for legacy methods
772
+ const oldResource = plugin.config.resource;
773
+ const oldField = plugin.config.field;
774
+ const oldTransactionResource = plugin.transactionResource;
775
+ const oldTargetResource = plugin.targetResource;
776
+ const oldLockResource = plugin.lockResource;
777
+ const oldAnalyticsResource = plugin.analyticsResource;
778
+
779
+ plugin.config.resource = handler.resource;
780
+ plugin.config.field = handler.field;
781
+ plugin.transactionResource = handler.transactionResource;
782
+ plugin.targetResource = handler.targetResource;
783
+ plugin.lockResource = handler.lockResource;
784
+ plugin.analyticsResource = handler.analyticsResource;
785
+
786
+ const result = await plugin.recalculateRecord(id);
787
+
788
+ plugin.config.resource = oldResource;
789
+ plugin.config.field = oldField;
790
+ plugin.transactionResource = oldTransactionResource;
791
+ plugin.targetResource = oldTargetResource;
792
+ plugin.lockResource = oldLockResource;
793
+ plugin.analyticsResource = oldAnalyticsResource;
794
+
795
+ return result;
796
+ };
733
797
  }
734
798
 
735
799
  async createTransaction(handler, data) {
@@ -1493,6 +1557,135 @@ export class EventualConsistencyPlugin extends Plugin {
1493
1557
  return stats;
1494
1558
  }
1495
1559
 
1560
+ /**
1561
+ * Recalculate from scratch by resetting all transactions to pending
1562
+ * This is useful for debugging, recovery, or when you want to recompute everything
1563
+ * @param {string} originalId - The ID of the record to recalculate
1564
+ * @returns {Promise<number>} The recalculated value
1565
+ */
1566
+ async recalculateRecord(originalId) {
1567
+ // Clean up stale locks before attempting to acquire
1568
+ await this.cleanupStaleLocks();
1569
+
1570
+ // Acquire distributed lock to prevent concurrent operations
1571
+ const lockId = `lock-recalculate-${originalId}`;
1572
+ const [lockAcquired, lockErr, lock] = await tryFn(() =>
1573
+ this.lockResource.insert({
1574
+ id: lockId,
1575
+ lockedAt: Date.now(),
1576
+ workerId: process.pid ? String(process.pid) : 'unknown'
1577
+ })
1578
+ );
1579
+
1580
+ // If lock couldn't be acquired, another worker is operating on this record
1581
+ if (!lockAcquired) {
1582
+ if (this.config.verbose) {
1583
+ console.log(`[EventualConsistency] Recalculate lock for ${originalId} already held, skipping`);
1584
+ }
1585
+ throw new Error(`Cannot recalculate ${originalId}: lock already held by another worker`);
1586
+ }
1587
+
1588
+ try {
1589
+ if (this.config.verbose) {
1590
+ console.log(
1591
+ `[EventualConsistency] ${this.config.resource}.${this.config.field} - ` +
1592
+ `Starting recalculation for ${originalId} (resetting all transactions to pending)`
1593
+ );
1594
+ }
1595
+
1596
+ // Get ALL transactions for this record (both applied and pending)
1597
+ const [allOk, allErr, allTransactions] = await tryFn(() =>
1598
+ this.transactionResource.query({
1599
+ originalId
1600
+ })
1601
+ );
1602
+
1603
+ if (!allOk || !allTransactions || allTransactions.length === 0) {
1604
+ if (this.config.verbose) {
1605
+ console.log(
1606
+ `[EventualConsistency] ${this.config.resource}.${this.config.field} - ` +
1607
+ `No transactions found for ${originalId}, nothing to recalculate`
1608
+ );
1609
+ }
1610
+ return 0;
1611
+ }
1612
+
1613
+ if (this.config.verbose) {
1614
+ console.log(
1615
+ `[EventualConsistency] ${this.config.resource}.${this.config.field} - ` +
1616
+ `Found ${allTransactions.length} total transactions for ${originalId}, marking all as pending...`
1617
+ );
1618
+ }
1619
+
1620
+ // Mark ALL transactions as pending (applied: false)
1621
+ // Exclude anchor transactions (they should always be applied)
1622
+ const transactionsToReset = allTransactions.filter(txn => txn.source !== 'anchor');
1623
+
1624
+ const { results, errors } = await PromisePool
1625
+ .for(transactionsToReset)
1626
+ .withConcurrency(10)
1627
+ .process(async (txn) => {
1628
+ const [ok, err] = await tryFn(() =>
1629
+ this.transactionResource.update(txn.id, { applied: false })
1630
+ );
1631
+
1632
+ if (!ok && this.config.verbose) {
1633
+ console.warn(`[EventualConsistency] Failed to reset transaction ${txn.id}:`, err?.message);
1634
+ }
1635
+
1636
+ return ok;
1637
+ });
1638
+
1639
+ if (errors && errors.length > 0) {
1640
+ console.warn(
1641
+ `[EventualConsistency] ${this.config.resource}.${this.config.field} - ` +
1642
+ `Failed to reset ${errors.length} transactions during recalculation`
1643
+ );
1644
+ }
1645
+
1646
+ if (this.config.verbose) {
1647
+ console.log(
1648
+ `[EventualConsistency] ${this.config.resource}.${this.config.field} - ` +
1649
+ `Reset ${results.length} transactions to pending, now resetting record value and running consolidation...`
1650
+ );
1651
+ }
1652
+
1653
+ // Reset the record's field value to 0 to prevent double-counting
1654
+ // This ensures consolidation starts fresh without using the old value as an anchor
1655
+ const [resetOk, resetErr] = await tryFn(() =>
1656
+ this.targetResource.update(originalId, {
1657
+ [this.config.field]: 0
1658
+ })
1659
+ );
1660
+
1661
+ if (!resetOk && this.config.verbose) {
1662
+ console.warn(
1663
+ `[EventualConsistency] ${this.config.resource}.${this.config.field} - ` +
1664
+ `Failed to reset record value for ${originalId}: ${resetErr?.message}`
1665
+ );
1666
+ }
1667
+
1668
+ // Now run normal consolidation which will process all pending transactions
1669
+ const consolidatedValue = await this.consolidateRecord(originalId);
1670
+
1671
+ if (this.config.verbose) {
1672
+ console.log(
1673
+ `[EventualConsistency] ${this.config.resource}.${this.config.field} - ` +
1674
+ `Recalculation complete for ${originalId}: final value = ${consolidatedValue}`
1675
+ );
1676
+ }
1677
+
1678
+ return consolidatedValue;
1679
+ } finally {
1680
+ // Always release the lock
1681
+ const [lockReleased, lockReleaseErr] = await tryFn(() => this.lockResource.delete(lockId));
1682
+
1683
+ if (!lockReleased && this.config.verbose) {
1684
+ console.warn(`[EventualConsistency] Failed to release recalculate lock ${lockId}:`, lockReleaseErr?.message);
1685
+ }
1686
+ }
1687
+ }
1688
+
1496
1689
  /**
1497
1690
  * Clean up stale locks that exceed the configured timeout
1498
1691
  * Uses distributed locking to prevent multiple containers from cleaning simultaneously
@@ -7,7 +7,7 @@ export * from './audit.plugin.js'
7
7
  export * from './backup.plugin.js'
8
8
  export * from './cache.plugin.js'
9
9
  export * from './costs.plugin.js'
10
- export * from './eventual-consistency.plugin.js'
10
+ export * from './eventual-consistency/index.js'
11
11
  export * from './fulltext.plugin.js'
12
12
  export * from './metrics.plugin.js'
13
13
  export * from './queue-consumer.plugin.js'