s3db.js 10.0.12 → 10.0.13

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": "10.0.12",
3
+ "version": "10.0.13",
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",
@@ -131,11 +131,11 @@
131
131
  "build:binaries": "./scripts/scripts/build-binaries.sh",
132
132
  "dev": "rollup -c -w",
133
133
  "test": "pnpm run test:js && pnpm run test:ts",
134
- "test:js": "node --no-warnings --experimental-vm-modules node_modules/jest/bin/jest.js --testTimeout=10000",
134
+ "test:js": "node --no-warnings --experimental-vm-modules node_modules/jest/bin/jest.js",
135
135
  "test:ts": "tsc --noEmit --project tests/typescript/tsconfig.json",
136
- "test:coverage": "node --no-warnings --experimental-vm-modules node_modules/jest/bin/jest.js --detectOpenHandles --coverage --runInBand",
137
- "test:quick": "node --no-warnings --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand --testTimeout=10000",
138
- "test:plugins": "node --no-warnings --experimental-vm-modules node_modules/jest/bin/jest.js tests/plugins/ --runInBand --testTimeout=60000",
136
+ "test:coverage": "node --no-warnings --experimental-vm-modules node_modules/jest/bin/jest.js --coverage --maxWorkers=1",
137
+ "test:serial": "node --no-warnings --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand",
138
+ "test:plugins": "node --no-warnings --experimental-vm-modules node_modules/jest/bin/jest.js tests/plugins/ --testTimeout=60000",
139
139
  "test:full": "pnpm run test:js && pnpm run test:ts",
140
140
  "benchmark": "node benchmark-compression.js",
141
141
  "version": "echo 'Use pnpm run release v<version> instead of npm version'",
@@ -317,10 +317,20 @@ export class EventualConsistencyPlugin extends Plugin {
317
317
  }
318
318
 
319
319
  async onStart() {
320
- // Emit events for all field handlers
320
+ // Start timers and emit events for all field handlers
321
321
  for (const [resourceName, fieldHandlers] of this.fieldHandlers) {
322
322
  for (const [fieldName, handler] of fieldHandlers) {
323
323
  if (!handler.deferredSetup) {
324
+ // Start auto-consolidation timer if enabled
325
+ if (this.config.autoConsolidate && this.config.mode === 'async') {
326
+ this.startConsolidationTimerForHandler(handler, resourceName, fieldName);
327
+ }
328
+
329
+ // Start garbage collection timer
330
+ if (this.config.transactionRetention && this.config.transactionRetention > 0) {
331
+ this.startGarbageCollectionTimerForHandler(handler, resourceName, fieldName);
332
+ }
333
+
324
334
  this.emit('eventual-consistency.started', {
325
335
  resource: resourceName,
326
336
  field: fieldName,
@@ -467,20 +477,57 @@ export class EventualConsistencyPlugin extends Plugin {
467
477
  // Add method to set value (replaces current value)
468
478
  // Signature: set(id, field, value)
469
479
  resource.set = async (id, field, value) => {
470
- const { plugin: fieldPlugin } =
480
+ const { plugin: handler } =
471
481
  plugin._resolveFieldAndPlugin(resource, field, value);
472
482
 
473
- // Create set transaction
474
- await fieldPlugin.createTransaction(fieldPlugin, {
483
+ // Create transaction inline
484
+ const now = new Date();
485
+ const cohortInfo = plugin.getCohortInfo(now);
486
+
487
+ const transaction = {
488
+ id: idGenerator(),
475
489
  originalId: id,
476
- operation: 'set',
490
+ field: handler.field,
477
491
  value: value,
478
- source: 'set'
479
- });
492
+ operation: 'set',
493
+ timestamp: now.toISOString(),
494
+ cohortDate: cohortInfo.date,
495
+ cohortHour: cohortInfo.hour,
496
+ cohortMonth: cohortInfo.month,
497
+ source: 'set',
498
+ applied: false
499
+ };
480
500
 
481
- // In sync mode, immediately consolidate and update (atomic with locking)
482
- if (fieldPlugin.config.mode === 'sync') {
483
- return await fieldPlugin._syncModeConsolidate(fieldPlugin, id, field);
501
+ await handler.transactionResource.insert(transaction);
502
+
503
+ // In sync mode, immediately consolidate
504
+ if (plugin.config.mode === 'sync') {
505
+ // Temporarily set config for legacy methods
506
+ const oldResource = plugin.config.resource;
507
+ const oldField = plugin.config.field;
508
+ const oldTransactionResource = plugin.transactionResource;
509
+ const oldTargetResource = plugin.targetResource;
510
+ const oldLockResource = plugin.lockResource;
511
+ const oldAnalyticsResource = plugin.analyticsResource;
512
+
513
+ plugin.config.resource = handler.resource;
514
+ plugin.config.field = handler.field;
515
+ plugin.transactionResource = handler.transactionResource;
516
+ plugin.targetResource = handler.targetResource;
517
+ plugin.lockResource = handler.lockResource;
518
+ plugin.analyticsResource = handler.analyticsResource;
519
+
520
+ const result = await plugin._syncModeConsolidate(id, field);
521
+
522
+ // Restore
523
+ plugin.config.resource = oldResource;
524
+ plugin.config.field = oldField;
525
+ plugin.transactionResource = oldTransactionResource;
526
+ plugin.targetResource = oldTargetResource;
527
+ plugin.lockResource = oldLockResource;
528
+ plugin.analyticsResource = oldAnalyticsResource;
529
+
530
+ return result;
484
531
  }
485
532
 
486
533
  return value;
@@ -489,48 +536,120 @@ export class EventualConsistencyPlugin extends Plugin {
489
536
  // Add method to increment value
490
537
  // Signature: add(id, field, amount)
491
538
  resource.add = async (id, field, amount) => {
492
- const { plugin: fieldPlugin } =
539
+ const { plugin: handler } =
493
540
  plugin._resolveFieldAndPlugin(resource, field, amount);
494
541
 
495
- // Create add transaction
496
- await fieldPlugin.createTransaction(fieldPlugin, {
542
+ // Create transaction inline
543
+ const now = new Date();
544
+ const cohortInfo = plugin.getCohortInfo(now);
545
+
546
+ const transaction = {
547
+ id: idGenerator(),
497
548
  originalId: id,
498
- operation: 'add',
549
+ field: handler.field,
499
550
  value: amount,
500
- source: 'add'
501
- });
551
+ operation: 'add',
552
+ timestamp: now.toISOString(),
553
+ cohortDate: cohortInfo.date,
554
+ cohortHour: cohortInfo.hour,
555
+ cohortMonth: cohortInfo.month,
556
+ source: 'add',
557
+ applied: false
558
+ };
502
559
 
503
- // In sync mode, immediately consolidate and update (atomic with locking)
504
- if (fieldPlugin.config.mode === 'sync') {
505
- return await fieldPlugin._syncModeConsolidate(fieldPlugin, id, field);
506
- }
560
+ await handler.transactionResource.insert(transaction);
507
561
 
508
- // In async mode, return expected value (for user feedback)
509
- const currentValue = await fieldPlugin.getConsolidatedValue(fieldPlugin, id);
562
+ // In sync mode, immediately consolidate
563
+ if (plugin.config.mode === 'sync') {
564
+ const oldResource = plugin.config.resource;
565
+ const oldField = plugin.config.field;
566
+ const oldTransactionResource = plugin.transactionResource;
567
+ const oldTargetResource = plugin.targetResource;
568
+ const oldLockResource = plugin.lockResource;
569
+ const oldAnalyticsResource = plugin.analyticsResource;
570
+
571
+ plugin.config.resource = handler.resource;
572
+ plugin.config.field = handler.field;
573
+ plugin.transactionResource = handler.transactionResource;
574
+ plugin.targetResource = handler.targetResource;
575
+ plugin.lockResource = handler.lockResource;
576
+ plugin.analyticsResource = handler.analyticsResource;
577
+
578
+ const result = await plugin._syncModeConsolidate(id, field);
579
+
580
+ plugin.config.resource = oldResource;
581
+ plugin.config.field = oldField;
582
+ plugin.transactionResource = oldTransactionResource;
583
+ plugin.targetResource = oldTargetResource;
584
+ plugin.lockResource = oldLockResource;
585
+ plugin.analyticsResource = oldAnalyticsResource;
586
+
587
+ return result;
588
+ }
589
+
590
+ // Async mode - return current value (optimistic)
591
+ const [ok, err, record] = await tryFn(() => handler.targetResource.get(id));
592
+ const currentValue = (ok && record) ? (record[field] || 0) : 0;
510
593
  return currentValue + amount;
511
594
  };
512
595
 
513
596
  // Add method to decrement value
514
597
  // Signature: sub(id, field, amount)
515
598
  resource.sub = async (id, field, amount) => {
516
- const { plugin: fieldPlugin } =
599
+ const { plugin: handler } =
517
600
  plugin._resolveFieldAndPlugin(resource, field, amount);
518
601
 
519
- // Create sub transaction
520
- await fieldPlugin.createTransaction(fieldPlugin, {
602
+ // Create transaction inline
603
+ const now = new Date();
604
+ const cohortInfo = plugin.getCohortInfo(now);
605
+
606
+ const transaction = {
607
+ id: idGenerator(),
521
608
  originalId: id,
522
- operation: 'sub',
609
+ field: handler.field,
523
610
  value: amount,
524
- source: 'sub'
525
- });
611
+ operation: 'sub',
612
+ timestamp: now.toISOString(),
613
+ cohortDate: cohortInfo.date,
614
+ cohortHour: cohortInfo.hour,
615
+ cohortMonth: cohortInfo.month,
616
+ source: 'sub',
617
+ applied: false
618
+ };
526
619
 
527
- // In sync mode, immediately consolidate and update (atomic with locking)
528
- if (fieldPlugin.config.mode === 'sync') {
529
- return await fieldPlugin._syncModeConsolidate(fieldPlugin, id, field);
530
- }
620
+ await handler.transactionResource.insert(transaction);
531
621
 
532
- // In async mode, return expected value (for user feedback)
533
- const currentValue = await fieldPlugin.getConsolidatedValue(fieldPlugin, id);
622
+ // In sync mode, immediately consolidate
623
+ if (plugin.config.mode === 'sync') {
624
+ const oldResource = plugin.config.resource;
625
+ const oldField = plugin.config.field;
626
+ const oldTransactionResource = plugin.transactionResource;
627
+ const oldTargetResource = plugin.targetResource;
628
+ const oldLockResource = plugin.lockResource;
629
+ const oldAnalyticsResource = plugin.analyticsResource;
630
+
631
+ plugin.config.resource = handler.resource;
632
+ plugin.config.field = handler.field;
633
+ plugin.transactionResource = handler.transactionResource;
634
+ plugin.targetResource = handler.targetResource;
635
+ plugin.lockResource = handler.lockResource;
636
+ plugin.analyticsResource = handler.analyticsResource;
637
+
638
+ const result = await plugin._syncModeConsolidate(id, field);
639
+
640
+ plugin.config.resource = oldResource;
641
+ plugin.config.field = oldField;
642
+ plugin.transactionResource = oldTransactionResource;
643
+ plugin.targetResource = oldTargetResource;
644
+ plugin.lockResource = oldLockResource;
645
+ plugin.analyticsResource = oldAnalyticsResource;
646
+
647
+ return result;
648
+ }
649
+
650
+ // Async mode - return current value (optimistic)
651
+ const [ok, err, record] = await tryFn(() => handler.targetResource.get(id));
652
+ const currentValue = (ok && record) ? (record[field] || 0) : 0;
534
653
  return currentValue - amount;
535
654
  };
536
655
 
@@ -541,9 +660,9 @@ export class EventualConsistencyPlugin extends Plugin {
541
660
  throw new Error(`Field parameter is required: consolidate(id, field)`);
542
661
  }
543
662
 
544
- const fieldPlugin = resource._eventualConsistencyPlugins[field];
663
+ const handler = resource._eventualConsistencyPlugins[field];
545
664
 
546
- if (!fieldPlugin) {
665
+ if (!handler) {
547
666
  const availableFields = Object.keys(resource._eventualConsistencyPlugins).join(', ');
548
667
  throw new Error(
549
668
  `No eventual consistency plugin found for field "${field}". ` +
@@ -551,15 +670,39 @@ export class EventualConsistencyPlugin extends Plugin {
551
670
  );
552
671
  }
553
672
 
554
- return await fieldPlugin.consolidateRecord(fieldPlugin, id);
673
+ // Temporarily set config for legacy methods
674
+ const oldResource = plugin.config.resource;
675
+ const oldField = plugin.config.field;
676
+ const oldTransactionResource = plugin.transactionResource;
677
+ const oldTargetResource = plugin.targetResource;
678
+ const oldLockResource = plugin.lockResource;
679
+ const oldAnalyticsResource = plugin.analyticsResource;
680
+
681
+ plugin.config.resource = handler.resource;
682
+ plugin.config.field = handler.field;
683
+ plugin.transactionResource = handler.transactionResource;
684
+ plugin.targetResource = handler.targetResource;
685
+ plugin.lockResource = handler.lockResource;
686
+ plugin.analyticsResource = handler.analyticsResource;
687
+
688
+ const result = await plugin.consolidateRecord(id);
689
+
690
+ plugin.config.resource = oldResource;
691
+ plugin.config.field = oldField;
692
+ plugin.transactionResource = oldTransactionResource;
693
+ plugin.targetResource = oldTargetResource;
694
+ plugin.lockResource = oldLockResource;
695
+ plugin.analyticsResource = oldAnalyticsResource;
696
+
697
+ return result;
555
698
  };
556
699
 
557
700
  // Add method to get consolidated value without applying
558
701
  // Signature: getConsolidatedValue(id, field, options)
559
702
  resource.getConsolidatedValue = async (id, field, options = {}) => {
560
- const fieldPlugin = resource._eventualConsistencyPlugins[field];
703
+ const handler = resource._eventualConsistencyPlugins[field];
561
704
 
562
- if (!fieldPlugin) {
705
+ if (!handler) {
563
706
  const availableFields = Object.keys(resource._eventualConsistencyPlugins).join(', ');
564
707
  throw new Error(
565
708
  `No eventual consistency plugin found for field "${field}". ` +
@@ -567,7 +710,25 @@ export class EventualConsistencyPlugin extends Plugin {
567
710
  );
568
711
  }
569
712
 
570
- return await fieldPlugin.getConsolidatedValue(fieldPlugin, id, options);
713
+ // Temporarily set config for legacy methods
714
+ const oldResource = plugin.config.resource;
715
+ const oldField = plugin.config.field;
716
+ const oldTransactionResource = plugin.transactionResource;
717
+ const oldTargetResource = plugin.targetResource;
718
+
719
+ plugin.config.resource = handler.resource;
720
+ plugin.config.field = handler.field;
721
+ plugin.transactionResource = handler.transactionResource;
722
+ plugin.targetResource = handler.targetResource;
723
+
724
+ const result = await plugin.getConsolidatedValue(id, options);
725
+
726
+ plugin.config.resource = oldResource;
727
+ plugin.config.field = oldField;
728
+ plugin.transactionResource = oldTransactionResource;
729
+ plugin.targetResource = oldTargetResource;
730
+
731
+ return result;
571
732
  };
572
733
  }
573
734
 
@@ -746,6 +907,52 @@ export class EventualConsistencyPlugin extends Plugin {
746
907
  }, intervalMs);
747
908
  }
748
909
 
910
+ startConsolidationTimerForHandler(handler, resourceName, fieldName) {
911
+ const intervalMs = this.config.consolidationInterval * 1000; // Convert seconds to ms
912
+
913
+ if (this.config.verbose) {
914
+ const nextRun = new Date(Date.now() + intervalMs);
915
+ console.log(
916
+ `[EventualConsistency] ${resourceName}.${fieldName} - ` +
917
+ `Consolidation timer started. Next run at ${nextRun.toISOString()} ` +
918
+ `(every ${this.config.consolidationInterval}s)`
919
+ );
920
+ }
921
+
922
+ handler.consolidationTimer = setInterval(async () => {
923
+ await this.runConsolidationForHandler(handler, resourceName, fieldName);
924
+ }, intervalMs);
925
+ }
926
+
927
+ async runConsolidationForHandler(handler, resourceName, fieldName) {
928
+ // Temporarily swap config to use this handler
929
+ const oldResource = this.config.resource;
930
+ const oldField = this.config.field;
931
+ const oldTransactionResource = this.transactionResource;
932
+ const oldTargetResource = this.targetResource;
933
+ const oldLockResource = this.lockResource;
934
+ const oldAnalyticsResource = this.analyticsResource;
935
+
936
+ this.config.resource = resourceName;
937
+ this.config.field = fieldName;
938
+ this.transactionResource = handler.transactionResource;
939
+ this.targetResource = handler.targetResource;
940
+ this.lockResource = handler.lockResource;
941
+ this.analyticsResource = handler.analyticsResource;
942
+
943
+ try {
944
+ await this.runConsolidation();
945
+ } finally {
946
+ // Restore
947
+ this.config.resource = oldResource;
948
+ this.config.field = oldField;
949
+ this.transactionResource = oldTransactionResource;
950
+ this.targetResource = oldTargetResource;
951
+ this.lockResource = oldLockResource;
952
+ this.analyticsResource = oldAnalyticsResource;
953
+ }
954
+ }
955
+
749
956
  async runConsolidation() {
750
957
  const startTime = Date.now();
751
958
 
@@ -1035,7 +1242,7 @@ export class EventualConsistencyPlugin extends Plugin {
1035
1242
  }
1036
1243
 
1037
1244
  // Invalidate cache for this record after consolidation
1038
- if (this.targetResource.cache && typeof this.targetResource.cache.delete === 'function') {
1245
+ if (this.targetResource && this.targetResource.cache && typeof this.targetResource.cache.delete === 'function') {
1039
1246
  try {
1040
1247
  const cacheKey = await this.targetResource.cacheKeyFor({ id: originalId });
1041
1248
  await this.targetResource.cache.delete(cacheKey);
@@ -1244,6 +1451,40 @@ export class EventualConsistencyPlugin extends Plugin {
1244
1451
  }, gcIntervalMs);
1245
1452
  }
1246
1453
 
1454
+ startGarbageCollectionTimerForHandler(handler, resourceName, fieldName) {
1455
+ const gcIntervalMs = this.config.gcInterval * 1000; // Convert seconds to ms
1456
+
1457
+ handler.gcTimer = setInterval(async () => {
1458
+ await this.runGarbageCollectionForHandler(handler, resourceName, fieldName);
1459
+ }, gcIntervalMs);
1460
+ }
1461
+
1462
+ async runGarbageCollectionForHandler(handler, resourceName, fieldName) {
1463
+ // Temporarily swap config to use this handler
1464
+ const oldResource = this.config.resource;
1465
+ const oldField = this.config.field;
1466
+ const oldTransactionResource = this.transactionResource;
1467
+ const oldTargetResource = this.targetResource;
1468
+ const oldLockResource = this.lockResource;
1469
+
1470
+ this.config.resource = resourceName;
1471
+ this.config.field = fieldName;
1472
+ this.transactionResource = handler.transactionResource;
1473
+ this.targetResource = handler.targetResource;
1474
+ this.lockResource = handler.lockResource;
1475
+
1476
+ try {
1477
+ await this.runGarbageCollection();
1478
+ } finally {
1479
+ // Restore
1480
+ this.config.resource = oldResource;
1481
+ this.config.field = oldField;
1482
+ this.transactionResource = oldTransactionResource;
1483
+ this.targetResource = oldTargetResource;
1484
+ this.lockResource = oldLockResource;
1485
+ }
1486
+ }
1487
+
1247
1488
  /**
1248
1489
  * Delete old applied transactions based on retention policy
1249
1490
  * Uses distributed locking to prevent multiple containers from running GC simultaneously
@@ -1634,14 +1875,25 @@ export class EventualConsistencyPlugin extends Plugin {
1634
1875
  * @returns {Promise<Array>} Analytics data
1635
1876
  */
1636
1877
  async getAnalytics(resourceName, field, options = {}) {
1637
- if (!this.analyticsResource) {
1878
+ // Get handler for this resource/field combination
1879
+ const fieldHandlers = this.fieldHandlers.get(resourceName);
1880
+ if (!fieldHandlers) {
1881
+ throw new Error(`No eventual consistency configured for resource: ${resourceName}`);
1882
+ }
1883
+
1884
+ const handler = fieldHandlers.get(field);
1885
+ if (!handler) {
1886
+ throw new Error(`No eventual consistency configured for field: ${resourceName}.${field}`);
1887
+ }
1888
+
1889
+ if (!handler.analyticsResource) {
1638
1890
  throw new Error('Analytics not enabled for this plugin');
1639
1891
  }
1640
1892
 
1641
1893
  const { period = 'day', date, startDate, endDate, month, year, breakdown = false } = options;
1642
1894
 
1643
1895
  const [ok, err, allAnalytics] = await tryFn(() =>
1644
- this.analyticsResource.list()
1896
+ handler.analyticsResource.list()
1645
1897
  );
1646
1898
 
1647
1899
  if (!ok || !allAnalytics) {
@@ -1921,7 +2173,18 @@ export class EventualConsistencyPlugin extends Plugin {
1921
2173
  * @returns {Promise<Array>} Top records
1922
2174
  */
1923
2175
  async getTopRecords(resourceName, field, options = {}) {
1924
- if (!this.transactionResource) {
2176
+ // Get handler for this resource/field combination
2177
+ const fieldHandlers = this.fieldHandlers.get(resourceName);
2178
+ if (!fieldHandlers) {
2179
+ throw new Error(`No eventual consistency configured for resource: ${resourceName}`);
2180
+ }
2181
+
2182
+ const handler = fieldHandlers.get(field);
2183
+ if (!handler) {
2184
+ throw new Error(`No eventual consistency configured for field: ${resourceName}.${field}`);
2185
+ }
2186
+
2187
+ if (!handler.transactionResource) {
1925
2188
  throw new Error('Transaction resource not initialized');
1926
2189
  }
1927
2190
 
@@ -1929,7 +2192,7 @@ export class EventualConsistencyPlugin extends Plugin {
1929
2192
 
1930
2193
  // Get all transactions for the period
1931
2194
  const [ok, err, transactions] = await tryFn(() =>
1932
- this.transactionResource.list()
2195
+ handler.transactionResource.list()
1933
2196
  );
1934
2197
 
1935
2198
  if (!ok || !transactions) {