s3db.js 9.3.0 → 10.0.1

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.
@@ -0,0 +1,1012 @@
1
+ import Plugin from "./plugin.class.js";
2
+ import tryFn from "../concerns/try-fn.js";
3
+ import { idGenerator } from "../concerns/id.js";
4
+ import { PromisePool } from "@supercharge/promise-pool";
5
+
6
+ export class EventualConsistencyPlugin extends Plugin {
7
+ constructor(options = {}) {
8
+ super(options);
9
+
10
+ // Validate required options
11
+ if (!options.resource) {
12
+ throw new Error("EventualConsistencyPlugin requires 'resource' option");
13
+ }
14
+ if (!options.field) {
15
+ throw new Error("EventualConsistencyPlugin requires 'field' option");
16
+ }
17
+
18
+ // Auto-detect timezone from environment or system
19
+ const detectedTimezone = this._detectTimezone();
20
+
21
+ this.config = {
22
+ resource: options.resource,
23
+ field: options.field,
24
+ cohort: {
25
+ timezone: options.cohort?.timezone || detectedTimezone
26
+ },
27
+ reducer: options.reducer || ((transactions) => {
28
+ // Default reducer: sum all increments from a base value
29
+ let baseValue = 0;
30
+
31
+ for (const t of transactions) {
32
+ if (t.operation === 'set') {
33
+ baseValue = t.value;
34
+ } else if (t.operation === 'add') {
35
+ baseValue += t.value;
36
+ } else if (t.operation === 'sub') {
37
+ baseValue -= t.value;
38
+ }
39
+ }
40
+
41
+ return baseValue;
42
+ }),
43
+ consolidationInterval: options.consolidationInterval ?? 300, // 5 minutes (in seconds)
44
+ consolidationConcurrency: options.consolidationConcurrency || 5,
45
+ consolidationWindow: options.consolidationWindow || 24, // Hours to look back for pending transactions (watermark)
46
+ autoConsolidate: options.autoConsolidate !== false,
47
+ lateArrivalStrategy: options.lateArrivalStrategy || 'warn', // 'ignore', 'warn', 'process'
48
+ batchTransactions: options.batchTransactions || false, // CAUTION: Not safe in distributed environments! Loses data on container crash
49
+ batchSize: options.batchSize || 100,
50
+ mode: options.mode || 'async', // 'async' or 'sync'
51
+ lockTimeout: options.lockTimeout || 300, // 5 minutes (in seconds, configurable)
52
+ transactionRetention: options.transactionRetention || 30, // Days to keep applied transactions
53
+ gcInterval: options.gcInterval || 86400, // 24 hours (in seconds)
54
+ verbose: options.verbose || false
55
+ };
56
+
57
+ this.transactionResource = null;
58
+ this.targetResource = null;
59
+ this.consolidationTimer = null;
60
+ this.gcTimer = null; // Garbage collection timer
61
+ this.pendingTransactions = new Map(); // Cache for batching
62
+
63
+ // Warn about batching in distributed environments
64
+ if (this.config.batchTransactions && !this.config.verbose) {
65
+ console.warn(
66
+ `[EventualConsistency] WARNING: batchTransactions is enabled. ` +
67
+ `This stores transactions in memory and will lose data if container crashes. ` +
68
+ `Not recommended for distributed/production environments. ` +
69
+ `Set verbose: true to suppress this warning.`
70
+ );
71
+ }
72
+
73
+ // Log detected timezone if verbose
74
+ if (this.config.verbose && !options.cohort?.timezone) {
75
+ console.log(
76
+ `[EventualConsistency] Auto-detected timezone: ${this.config.cohort.timezone} ` +
77
+ `(from ${process.env.TZ ? 'TZ env var' : 'system Intl API'})`
78
+ );
79
+ }
80
+ }
81
+
82
+ async onSetup() {
83
+ // Try to get the target resource
84
+ this.targetResource = this.database.resources[this.config.resource];
85
+
86
+ if (!this.targetResource) {
87
+ // Resource doesn't exist yet - defer setup
88
+ this.deferredSetup = true;
89
+ this.watchForResource();
90
+ return;
91
+ }
92
+
93
+ // Resource exists - continue with setup
94
+ await this.completeSetup();
95
+ }
96
+
97
+ watchForResource() {
98
+ // Monitor for resource creation using database hooks
99
+ const hookCallback = async ({ resource, config }) => {
100
+ // Check if this is the resource we're waiting for
101
+ if (config.name === this.config.resource && this.deferredSetup) {
102
+ this.targetResource = resource;
103
+ this.deferredSetup = false;
104
+ await this.completeSetup();
105
+ }
106
+ };
107
+
108
+ this.database.addHook('afterCreateResource', hookCallback);
109
+ }
110
+
111
+ async completeSetup() {
112
+ if (!this.targetResource) return;
113
+
114
+ // Create transaction resource with partitions (includes field name to support multiple fields)
115
+ const transactionResourceName = `${this.config.resource}_transactions_${this.config.field}`;
116
+ const partitionConfig = this.createPartitionConfig();
117
+
118
+ const [ok, err, transactionResource] = await tryFn(() =>
119
+ this.database.createResource({
120
+ name: transactionResourceName,
121
+ attributes: {
122
+ id: 'string|required',
123
+ originalId: 'string|required',
124
+ field: 'string|required',
125
+ value: 'number|required',
126
+ operation: 'string|required', // 'set', 'add', or 'sub'
127
+ timestamp: 'string|required',
128
+ cohortDate: 'string|required', // For daily partitioning
129
+ cohortHour: 'string|required', // For hourly partitioning
130
+ cohortMonth: 'string|optional', // For monthly partitioning
131
+ source: 'string|optional',
132
+ applied: 'boolean|optional' // Track if transaction was applied
133
+ },
134
+ behavior: 'body-overflow',
135
+ timestamps: true,
136
+ partitions: partitionConfig,
137
+ asyncPartitions: true // Use async partitions for better performance
138
+ })
139
+ );
140
+
141
+ if (!ok && !this.database.resources[transactionResourceName]) {
142
+ throw new Error(`Failed to create transaction resource: ${err?.message}`);
143
+ }
144
+
145
+ this.transactionResource = ok ? transactionResource : this.database.resources[transactionResourceName];
146
+
147
+ // Create lock resource for atomic consolidation
148
+ const lockResourceName = `${this.config.resource}_consolidation_locks_${this.config.field}`;
149
+ const [lockOk, lockErr, lockResource] = await tryFn(() =>
150
+ this.database.createResource({
151
+ name: lockResourceName,
152
+ attributes: {
153
+ id: 'string|required',
154
+ lockedAt: 'number|required',
155
+ workerId: 'string|optional'
156
+ },
157
+ behavior: 'body-only',
158
+ timestamps: false
159
+ })
160
+ );
161
+
162
+ if (!lockOk && !this.database.resources[lockResourceName]) {
163
+ throw new Error(`Failed to create lock resource: ${lockErr?.message}`);
164
+ }
165
+
166
+ this.lockResource = lockOk ? lockResource : this.database.resources[lockResourceName];
167
+
168
+ // Add helper methods to the resource
169
+ this.addHelperMethods();
170
+
171
+ // Setup consolidation if enabled
172
+ if (this.config.autoConsolidate) {
173
+ this.startConsolidationTimer();
174
+ }
175
+
176
+ // Setup garbage collection timer
177
+ this.startGarbageCollectionTimer();
178
+ }
179
+
180
+ async onStart() {
181
+ // Don't start if we're waiting for the resource
182
+ if (this.deferredSetup) {
183
+ return;
184
+ }
185
+
186
+ // Plugin is ready
187
+ this.emit('eventual-consistency.started', {
188
+ resource: this.config.resource,
189
+ field: this.config.field,
190
+ cohort: this.config.cohort
191
+ });
192
+ }
193
+
194
+ async onStop() {
195
+ // Stop consolidation timer
196
+ if (this.consolidationTimer) {
197
+ clearInterval(this.consolidationTimer);
198
+ this.consolidationTimer = null;
199
+ }
200
+
201
+ // Stop garbage collection timer
202
+ if (this.gcTimer) {
203
+ clearInterval(this.gcTimer);
204
+ this.gcTimer = null;
205
+ }
206
+
207
+ // Flush pending transactions
208
+ await this.flushPendingTransactions();
209
+
210
+ this.emit('eventual-consistency.stopped', {
211
+ resource: this.config.resource,
212
+ field: this.config.field
213
+ });
214
+ }
215
+
216
+ createPartitionConfig() {
217
+ // Create hourly, daily and monthly partitions for transactions
218
+ const partitions = {
219
+ byHour: {
220
+ fields: {
221
+ cohortHour: 'string'
222
+ }
223
+ },
224
+ byDay: {
225
+ fields: {
226
+ cohortDate: 'string'
227
+ }
228
+ },
229
+ byMonth: {
230
+ fields: {
231
+ cohortMonth: 'string'
232
+ }
233
+ }
234
+ };
235
+
236
+ return partitions;
237
+ }
238
+
239
+ /**
240
+ * Auto-detect timezone from environment or system
241
+ * @private
242
+ */
243
+ _detectTimezone() {
244
+ // 1. Try TZ environment variable (common in Docker/K8s)
245
+ if (process.env.TZ) {
246
+ return process.env.TZ;
247
+ }
248
+
249
+ // 2. Try Intl API (works in Node.js and browsers)
250
+ try {
251
+ const systemTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
252
+ if (systemTimezone) {
253
+ return systemTimezone;
254
+ }
255
+ } catch (err) {
256
+ // Intl API not available or failed
257
+ }
258
+
259
+ // 3. Fallback to UTC
260
+ return 'UTC';
261
+ }
262
+
263
+ /**
264
+ * Helper method to resolve field and plugin from arguments
265
+ * Supports both single-field (field, value) and multi-field (field, value) signatures
266
+ * @private
267
+ */
268
+ _resolveFieldAndPlugin(resource, fieldOrValue, value) {
269
+ const hasMultipleFields = Object.keys(resource._eventualConsistencyPlugins).length > 1;
270
+
271
+ // If multiple fields exist and only 2 params given, throw error
272
+ if (hasMultipleFields && value === undefined) {
273
+ throw new Error(`Multiple fields have eventual consistency. Please specify the field explicitly.`);
274
+ }
275
+
276
+ // Handle both signatures: method(id, value) and method(id, field, value)
277
+ const field = value !== undefined ? fieldOrValue : this.config.field;
278
+ const actualValue = value !== undefined ? value : fieldOrValue;
279
+ const fieldPlugin = resource._eventualConsistencyPlugins[field];
280
+
281
+ if (!fieldPlugin) {
282
+ throw new Error(`No eventual consistency plugin found for field "${field}"`);
283
+ }
284
+
285
+ return { field, value: actualValue, plugin: fieldPlugin };
286
+ }
287
+
288
+ /**
289
+ * Helper method to perform atomic consolidation in sync mode
290
+ * @private
291
+ */
292
+ async _syncModeConsolidate(id, field) {
293
+ // consolidateRecord already has distributed locking, so it's atomic
294
+ const consolidatedValue = await this.consolidateRecord(id);
295
+ await this.targetResource.update(id, {
296
+ [field]: consolidatedValue
297
+ });
298
+ return consolidatedValue;
299
+ }
300
+
301
+ /**
302
+ * Create synthetic 'set' transaction from current value
303
+ * @private
304
+ */
305
+ _createSyntheticSetTransaction(currentValue) {
306
+ return {
307
+ id: '__synthetic__',
308
+ operation: 'set',
309
+ value: currentValue,
310
+ timestamp: new Date(0).toISOString(),
311
+ synthetic: true
312
+ };
313
+ }
314
+
315
+ addHelperMethods() {
316
+ const resource = this.targetResource;
317
+ const defaultField = this.config.field;
318
+ const plugin = this;
319
+
320
+ // Store all plugins by field name for this resource
321
+ if (!resource._eventualConsistencyPlugins) {
322
+ resource._eventualConsistencyPlugins = {};
323
+ }
324
+ resource._eventualConsistencyPlugins[defaultField] = plugin;
325
+
326
+ // Add method to set value (replaces current value)
327
+ resource.set = async (id, fieldOrValue, value) => {
328
+ const { field, value: actualValue, plugin: fieldPlugin } =
329
+ plugin._resolveFieldAndPlugin(resource, fieldOrValue, value);
330
+
331
+ // Create set transaction
332
+ await fieldPlugin.createTransaction({
333
+ originalId: id,
334
+ operation: 'set',
335
+ value: actualValue,
336
+ source: 'set'
337
+ });
338
+
339
+ // In sync mode, immediately consolidate and update (atomic with locking)
340
+ if (fieldPlugin.config.mode === 'sync') {
341
+ return await fieldPlugin._syncModeConsolidate(id, field);
342
+ }
343
+
344
+ return actualValue;
345
+ };
346
+
347
+ // Add method to increment value
348
+ resource.add = async (id, fieldOrAmount, amount) => {
349
+ const { field, value: actualAmount, plugin: fieldPlugin } =
350
+ plugin._resolveFieldAndPlugin(resource, fieldOrAmount, amount);
351
+
352
+ // Create add transaction
353
+ await fieldPlugin.createTransaction({
354
+ originalId: id,
355
+ operation: 'add',
356
+ value: actualAmount,
357
+ source: 'add'
358
+ });
359
+
360
+ // In sync mode, immediately consolidate and update (atomic with locking)
361
+ if (fieldPlugin.config.mode === 'sync') {
362
+ return await fieldPlugin._syncModeConsolidate(id, field);
363
+ }
364
+
365
+ // In async mode, return expected value (for user feedback)
366
+ const currentValue = await fieldPlugin.getConsolidatedValue(id);
367
+ return currentValue + actualAmount;
368
+ };
369
+
370
+ // Add method to decrement value
371
+ resource.sub = async (id, fieldOrAmount, amount) => {
372
+ const { field, value: actualAmount, plugin: fieldPlugin } =
373
+ plugin._resolveFieldAndPlugin(resource, fieldOrAmount, amount);
374
+
375
+ // Create sub transaction
376
+ await fieldPlugin.createTransaction({
377
+ originalId: id,
378
+ operation: 'sub',
379
+ value: actualAmount,
380
+ source: 'sub'
381
+ });
382
+
383
+ // In sync mode, immediately consolidate and update (atomic with locking)
384
+ if (fieldPlugin.config.mode === 'sync') {
385
+ return await fieldPlugin._syncModeConsolidate(id, field);
386
+ }
387
+
388
+ // In async mode, return expected value (for user feedback)
389
+ const currentValue = await fieldPlugin.getConsolidatedValue(id);
390
+ return currentValue - actualAmount;
391
+ };
392
+
393
+ // Add method to manually trigger consolidation
394
+ resource.consolidate = async (id, field) => {
395
+ // Check if there are multiple fields with eventual consistency
396
+ const hasMultipleFields = Object.keys(resource._eventualConsistencyPlugins).length > 1;
397
+
398
+ // If multiple fields exist and no field given, throw error
399
+ if (hasMultipleFields && !field) {
400
+ throw new Error(`Multiple fields have eventual consistency. Please specify the field: consolidate(id, field)`);
401
+ }
402
+
403
+ // Handle both signatures: consolidate(id) and consolidate(id, field)
404
+ const actualField = field || defaultField;
405
+ const fieldPlugin = resource._eventualConsistencyPlugins[actualField];
406
+
407
+ if (!fieldPlugin) {
408
+ throw new Error(`No eventual consistency plugin found for field "${actualField}"`);
409
+ }
410
+
411
+ return await fieldPlugin.consolidateRecord(id);
412
+ };
413
+
414
+ // Add method to get consolidated value without applying
415
+ resource.getConsolidatedValue = async (id, fieldOrOptions, options) => {
416
+ // Handle both signatures: getConsolidatedValue(id, options) and getConsolidatedValue(id, field, options)
417
+ if (typeof fieldOrOptions === 'string') {
418
+ const field = fieldOrOptions;
419
+ const fieldPlugin = resource._eventualConsistencyPlugins[field] || plugin;
420
+ return await fieldPlugin.getConsolidatedValue(id, options || {});
421
+ } else {
422
+ return await plugin.getConsolidatedValue(id, fieldOrOptions || {});
423
+ }
424
+ };
425
+ }
426
+
427
+ async createTransaction(data) {
428
+ const now = new Date();
429
+ const cohortInfo = this.getCohortInfo(now);
430
+
431
+ // Check for late arrivals (transaction older than watermark)
432
+ const watermarkMs = this.config.consolidationWindow * 60 * 60 * 1000;
433
+ const watermarkTime = now.getTime() - watermarkMs;
434
+ const cohortHourDate = new Date(cohortInfo.hour + ':00:00Z'); // Parse cohortHour back to date
435
+
436
+ if (cohortHourDate.getTime() < watermarkTime) {
437
+ // Late arrival detected!
438
+ const hoursLate = Math.floor((now.getTime() - cohortHourDate.getTime()) / (60 * 60 * 1000));
439
+
440
+ if (this.config.lateArrivalStrategy === 'ignore') {
441
+ if (this.config.verbose) {
442
+ console.warn(
443
+ `[EventualConsistency] Late arrival ignored: transaction for ${cohortInfo.hour} ` +
444
+ `is ${hoursLate}h late (watermark: ${this.config.consolidationWindow}h)`
445
+ );
446
+ }
447
+ // Don't create transaction
448
+ return null;
449
+ } else if (this.config.lateArrivalStrategy === 'warn') {
450
+ console.warn(
451
+ `[EventualConsistency] Late arrival detected: transaction for ${cohortInfo.hour} ` +
452
+ `is ${hoursLate}h late (watermark: ${this.config.consolidationWindow}h). ` +
453
+ `Processing anyway, but consolidation may not pick it up.`
454
+ );
455
+ }
456
+ // 'process' strategy: continue normally
457
+ }
458
+
459
+ const transaction = {
460
+ id: idGenerator(), // Use nanoid for guaranteed uniqueness
461
+ originalId: data.originalId,
462
+ field: this.config.field,
463
+ value: data.value || 0,
464
+ operation: data.operation || 'set',
465
+ timestamp: now.toISOString(),
466
+ cohortDate: cohortInfo.date,
467
+ cohortHour: cohortInfo.hour,
468
+ cohortMonth: cohortInfo.month,
469
+ source: data.source || 'unknown',
470
+ applied: false
471
+ };
472
+
473
+ // Batch transactions if configured
474
+ if (this.config.batchTransactions) {
475
+ this.pendingTransactions.set(transaction.id, transaction);
476
+
477
+ // Flush if batch size reached
478
+ if (this.pendingTransactions.size >= this.config.batchSize) {
479
+ await this.flushPendingTransactions();
480
+ }
481
+ } else {
482
+ await this.transactionResource.insert(transaction);
483
+ }
484
+
485
+ return transaction;
486
+ }
487
+
488
+ async flushPendingTransactions() {
489
+ if (this.pendingTransactions.size === 0) return;
490
+
491
+ const transactions = Array.from(this.pendingTransactions.values());
492
+
493
+ try {
494
+ // Insert all pending transactions in parallel
495
+ await Promise.all(
496
+ transactions.map(transaction =>
497
+ this.transactionResource.insert(transaction)
498
+ )
499
+ );
500
+
501
+ // Only clear after successful inserts (prevents data loss on crashes)
502
+ this.pendingTransactions.clear();
503
+ } catch (error) {
504
+ // Keep pending transactions for retry on next flush
505
+ console.error('Failed to flush pending transactions:', error);
506
+ throw error;
507
+ }
508
+ }
509
+
510
+ getCohortInfo(date) {
511
+ const tz = this.config.cohort.timezone;
512
+
513
+ // Simple timezone offset calculation (can be enhanced with a library)
514
+ const offset = this.getTimezoneOffset(tz);
515
+ const localDate = new Date(date.getTime() + offset);
516
+
517
+ const year = localDate.getFullYear();
518
+ const month = String(localDate.getMonth() + 1).padStart(2, '0');
519
+ const day = String(localDate.getDate()).padStart(2, '0');
520
+ const hour = String(localDate.getHours()).padStart(2, '0');
521
+
522
+ return {
523
+ date: `${year}-${month}-${day}`,
524
+ hour: `${year}-${month}-${day}T${hour}`, // ISO-like format for hour partition
525
+ month: `${year}-${month}`
526
+ };
527
+ }
528
+
529
+ getTimezoneOffset(timezone) {
530
+ // Try to calculate offset using Intl API (handles DST automatically)
531
+ try {
532
+ const now = new Date();
533
+
534
+ // Get UTC time
535
+ const utcDate = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' }));
536
+
537
+ // Get time in target timezone
538
+ const tzDate = new Date(now.toLocaleString('en-US', { timeZone: timezone }));
539
+
540
+ // Calculate offset in milliseconds
541
+ return tzDate.getTime() - utcDate.getTime();
542
+ } catch (err) {
543
+ // Intl API failed, fallback to manual offsets (without DST support)
544
+ const offsets = {
545
+ 'UTC': 0,
546
+ 'America/New_York': -5 * 3600000,
547
+ 'America/Chicago': -6 * 3600000,
548
+ 'America/Denver': -7 * 3600000,
549
+ 'America/Los_Angeles': -8 * 3600000,
550
+ 'America/Sao_Paulo': -3 * 3600000,
551
+ 'Europe/London': 0,
552
+ 'Europe/Paris': 1 * 3600000,
553
+ 'Europe/Berlin': 1 * 3600000,
554
+ 'Asia/Tokyo': 9 * 3600000,
555
+ 'Asia/Shanghai': 8 * 3600000,
556
+ 'Australia/Sydney': 10 * 3600000
557
+ };
558
+
559
+ if (this.config.verbose && !offsets[timezone]) {
560
+ console.warn(
561
+ `[EventualConsistency] Unknown timezone '${timezone}', using UTC. ` +
562
+ `Consider using a valid IANA timezone (e.g., 'America/New_York')`
563
+ );
564
+ }
565
+
566
+ return offsets[timezone] || 0;
567
+ }
568
+ }
569
+
570
+ startConsolidationTimer() {
571
+ const intervalMs = this.config.consolidationInterval * 1000; // Convert seconds to ms
572
+
573
+ this.consolidationTimer = setInterval(async () => {
574
+ await this.runConsolidation();
575
+ }, intervalMs);
576
+ }
577
+
578
+ async runConsolidation() {
579
+ try {
580
+ // Query unapplied transactions from recent cohorts (last 24 hours by default)
581
+ // This uses hourly partition for O(1) performance instead of full scan
582
+ const now = new Date();
583
+ const hoursToCheck = this.config.consolidationWindow || 24; // Configurable lookback window (in hours)
584
+ const cohortHours = [];
585
+
586
+ for (let i = 0; i < hoursToCheck; i++) {
587
+ const date = new Date(now.getTime() - (i * 60 * 60 * 1000)); // Subtract hours
588
+ const cohortInfo = this.getCohortInfo(date);
589
+ cohortHours.push(cohortInfo.hour);
590
+ }
591
+
592
+ // Query transactions by partition for each hour (parallel for speed)
593
+ const transactionsByHour = await Promise.all(
594
+ cohortHours.map(async (cohortHour) => {
595
+ const [ok, err, txns] = await tryFn(() =>
596
+ this.transactionResource.query({
597
+ cohortHour,
598
+ applied: false
599
+ })
600
+ );
601
+ return ok ? txns : [];
602
+ })
603
+ );
604
+
605
+ // Flatten all transactions
606
+ const transactions = transactionsByHour.flat();
607
+
608
+ if (transactions.length === 0) {
609
+ if (this.config.verbose) {
610
+ console.log(`[EventualConsistency] No pending transactions to consolidate`);
611
+ }
612
+ return;
613
+ }
614
+
615
+ // Get unique originalIds
616
+ const uniqueIds = [...new Set(transactions.map(t => t.originalId))];
617
+
618
+ // Consolidate each record in parallel with concurrency limit
619
+ const { results, errors } = await PromisePool
620
+ .for(uniqueIds)
621
+ .withConcurrency(this.config.consolidationConcurrency)
622
+ .process(async (id) => {
623
+ return await this.consolidateRecord(id);
624
+ });
625
+
626
+ if (errors && errors.length > 0) {
627
+ console.error(`Consolidation completed with ${errors.length} errors:`, errors);
628
+ }
629
+
630
+ this.emit('eventual-consistency.consolidated', {
631
+ resource: this.config.resource,
632
+ field: this.config.field,
633
+ recordCount: uniqueIds.length,
634
+ successCount: results.length,
635
+ errorCount: errors.length
636
+ });
637
+ } catch (error) {
638
+ console.error('Consolidation error:', error);
639
+ this.emit('eventual-consistency.consolidation-error', error);
640
+ }
641
+ }
642
+
643
+ async consolidateRecord(originalId) {
644
+ // Clean up stale locks before attempting to acquire
645
+ await this.cleanupStaleLocks();
646
+
647
+ // Acquire distributed lock to prevent concurrent consolidation
648
+ const lockId = `lock-${originalId}`;
649
+ const [lockAcquired, lockErr, lock] = await tryFn(() =>
650
+ this.lockResource.insert({
651
+ id: lockId,
652
+ lockedAt: Date.now(),
653
+ workerId: process.pid ? String(process.pid) : 'unknown'
654
+ })
655
+ );
656
+
657
+ // If lock couldn't be acquired, another worker is consolidating
658
+ if (!lockAcquired) {
659
+ if (this.config.verbose) {
660
+ console.log(`[EventualConsistency] Lock for ${originalId} already held, skipping`);
661
+ }
662
+ // Get current value and return (another worker will consolidate)
663
+ const [recordOk, recordErr, record] = await tryFn(() =>
664
+ this.targetResource.get(originalId)
665
+ );
666
+ return (recordOk && record) ? (record[this.config.field] || 0) : 0;
667
+ }
668
+
669
+ try {
670
+ // Get the current record value first
671
+ const [recordOk, recordErr, record] = await tryFn(() =>
672
+ this.targetResource.get(originalId)
673
+ );
674
+
675
+ const currentValue = (recordOk && record) ? (record[this.config.field] || 0) : 0;
676
+
677
+ // Get all transactions for this record
678
+ const [ok, err, transactions] = await tryFn(() =>
679
+ this.transactionResource.query({
680
+ originalId,
681
+ applied: false
682
+ })
683
+ );
684
+
685
+ if (!ok || !transactions || transactions.length === 0) {
686
+ return currentValue;
687
+ }
688
+
689
+ // Sort transactions by timestamp
690
+ transactions.sort((a, b) =>
691
+ new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
692
+ );
693
+
694
+ // If there's a current value and no 'set' operations, prepend a synthetic set transaction
695
+ const hasSetOperation = transactions.some(t => t.operation === 'set');
696
+ if (currentValue !== 0 && !hasSetOperation) {
697
+ transactions.unshift(this._createSyntheticSetTransaction(currentValue));
698
+ }
699
+
700
+ // Apply reducer to get consolidated value
701
+ const consolidatedValue = this.config.reducer(transactions);
702
+
703
+ // Update the original record
704
+ const [updateOk, updateErr] = await tryFn(() =>
705
+ this.targetResource.update(originalId, {
706
+ [this.config.field]: consolidatedValue
707
+ })
708
+ );
709
+
710
+ if (updateOk) {
711
+ // Mark transactions as applied (skip synthetic ones) - use PromisePool for controlled concurrency
712
+ const transactionsToUpdate = transactions.filter(txn => txn.id !== '__synthetic__');
713
+
714
+ const { results, errors } = await PromisePool
715
+ .for(transactionsToUpdate)
716
+ .withConcurrency(10) // Limit parallel updates
717
+ .process(async (txn) => {
718
+ const [ok, err] = await tryFn(() =>
719
+ this.transactionResource.update(txn.id, { applied: true })
720
+ );
721
+
722
+ if (!ok && this.config.verbose) {
723
+ console.warn(`[EventualConsistency] Failed to mark transaction ${txn.id} as applied:`, err?.message);
724
+ }
725
+
726
+ return ok;
727
+ });
728
+
729
+ if (errors && errors.length > 0 && this.config.verbose) {
730
+ console.warn(`[EventualConsistency] ${errors.length} transactions failed to mark as applied`);
731
+ }
732
+ }
733
+
734
+ return consolidatedValue;
735
+ } finally {
736
+ // Always release the lock
737
+ const [lockReleased, lockReleaseErr] = await tryFn(() => this.lockResource.delete(lockId));
738
+
739
+ if (!lockReleased && this.config.verbose) {
740
+ console.warn(`[EventualConsistency] Failed to release lock ${lockId}:`, lockReleaseErr?.message);
741
+ }
742
+ }
743
+ }
744
+
745
+ async getConsolidatedValue(originalId, options = {}) {
746
+ const includeApplied = options.includeApplied || false;
747
+ const startDate = options.startDate;
748
+ const endDate = options.endDate;
749
+
750
+ // Build query
751
+ const query = { originalId };
752
+ if (!includeApplied) {
753
+ query.applied = false;
754
+ }
755
+
756
+ // Get transactions
757
+ const [ok, err, transactions] = await tryFn(() =>
758
+ this.transactionResource.query(query)
759
+ );
760
+
761
+ if (!ok || !transactions || transactions.length === 0) {
762
+ // If no transactions, check if record exists and return its current value
763
+ const [recordOk, recordErr, record] = await tryFn(() =>
764
+ this.targetResource.get(originalId)
765
+ );
766
+
767
+ if (recordOk && record) {
768
+ return record[this.config.field] || 0;
769
+ }
770
+
771
+ return 0;
772
+ }
773
+
774
+ // Filter by date range if specified
775
+ let filtered = transactions;
776
+ if (startDate || endDate) {
777
+ filtered = transactions.filter(t => {
778
+ const timestamp = new Date(t.timestamp);
779
+ if (startDate && timestamp < new Date(startDate)) return false;
780
+ if (endDate && timestamp > new Date(endDate)) return false;
781
+ return true;
782
+ });
783
+ }
784
+
785
+ // Get current value from record
786
+ const [recordOk, recordErr, record] = await tryFn(() =>
787
+ this.targetResource.get(originalId)
788
+ );
789
+ const currentValue = (recordOk && record) ? (record[this.config.field] || 0) : 0;
790
+
791
+ // Check if there's a 'set' operation in filtered transactions
792
+ const hasSetOperation = filtered.some(t => t.operation === 'set');
793
+
794
+ // If current value exists and no 'set', prepend synthetic set transaction
795
+ if (currentValue !== 0 && !hasSetOperation) {
796
+ filtered.unshift(this._createSyntheticSetTransaction(currentValue));
797
+ }
798
+
799
+ // Sort by timestamp
800
+ filtered.sort((a, b) =>
801
+ new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
802
+ );
803
+
804
+ // Apply reducer
805
+ return this.config.reducer(filtered);
806
+ }
807
+
808
+ // Helper method to get cohort statistics
809
+ async getCohortStats(cohortDate) {
810
+ const [ok, err, transactions] = await tryFn(() =>
811
+ this.transactionResource.query({
812
+ cohortDate
813
+ })
814
+ );
815
+
816
+ if (!ok) return null;
817
+
818
+ const stats = {
819
+ date: cohortDate,
820
+ transactionCount: transactions.length,
821
+ totalValue: 0,
822
+ byOperation: { set: 0, add: 0, sub: 0 },
823
+ byOriginalId: {}
824
+ };
825
+
826
+ for (const txn of transactions) {
827
+ stats.totalValue += txn.value || 0;
828
+ stats.byOperation[txn.operation] = (stats.byOperation[txn.operation] || 0) + 1;
829
+
830
+ if (!stats.byOriginalId[txn.originalId]) {
831
+ stats.byOriginalId[txn.originalId] = {
832
+ count: 0,
833
+ value: 0
834
+ };
835
+ }
836
+ stats.byOriginalId[txn.originalId].count++;
837
+ stats.byOriginalId[txn.originalId].value += txn.value || 0;
838
+ }
839
+
840
+ return stats;
841
+ }
842
+
843
+ /**
844
+ * Clean up stale locks that exceed the configured timeout
845
+ * Uses distributed locking to prevent multiple containers from cleaning simultaneously
846
+ */
847
+ async cleanupStaleLocks() {
848
+ const now = Date.now();
849
+ const lockTimeoutMs = this.config.lockTimeout * 1000; // Convert seconds to ms
850
+ const cutoffTime = now - lockTimeoutMs;
851
+
852
+ // Acquire distributed lock for cleanup operation
853
+ const cleanupLockId = `lock-cleanup-${this.config.resource}-${this.config.field}`;
854
+ const [lockAcquired] = await tryFn(() =>
855
+ this.lockResource.insert({
856
+ id: cleanupLockId,
857
+ lockedAt: Date.now(),
858
+ workerId: process.pid ? String(process.pid) : 'unknown'
859
+ })
860
+ );
861
+
862
+ // If another container is already cleaning, skip
863
+ if (!lockAcquired) {
864
+ if (this.config.verbose) {
865
+ console.log(`[EventualConsistency] Lock cleanup already running in another container`);
866
+ }
867
+ return;
868
+ }
869
+
870
+ try {
871
+ // Get all locks
872
+ const [ok, err, locks] = await tryFn(() => this.lockResource.list());
873
+
874
+ if (!ok || !locks || locks.length === 0) return;
875
+
876
+ // Find stale locks (excluding the cleanup lock itself)
877
+ const staleLocks = locks.filter(lock =>
878
+ lock.id !== cleanupLockId && lock.lockedAt < cutoffTime
879
+ );
880
+
881
+ if (staleLocks.length === 0) return;
882
+
883
+ if (this.config.verbose) {
884
+ console.log(`[EventualConsistency] Cleaning up ${staleLocks.length} stale locks`);
885
+ }
886
+
887
+ // Delete stale locks using PromisePool
888
+ const { results, errors } = await PromisePool
889
+ .for(staleLocks)
890
+ .withConcurrency(5)
891
+ .process(async (lock) => {
892
+ const [deleted] = await tryFn(() => this.lockResource.delete(lock.id));
893
+ return deleted;
894
+ });
895
+
896
+ if (errors && errors.length > 0 && this.config.verbose) {
897
+ console.warn(`[EventualConsistency] ${errors.length} stale locks failed to delete`);
898
+ }
899
+ } catch (error) {
900
+ if (this.config.verbose) {
901
+ console.warn(`[EventualConsistency] Error cleaning up stale locks:`, error.message);
902
+ }
903
+ } finally {
904
+ // Always release cleanup lock
905
+ await tryFn(() => this.lockResource.delete(cleanupLockId));
906
+ }
907
+ }
908
+
909
+ /**
910
+ * Start garbage collection timer for old applied transactions
911
+ */
912
+ startGarbageCollectionTimer() {
913
+ const gcIntervalMs = this.config.gcInterval * 1000; // Convert seconds to ms
914
+
915
+ this.gcTimer = setInterval(async () => {
916
+ await this.runGarbageCollection();
917
+ }, gcIntervalMs);
918
+ }
919
+
920
+ /**
921
+ * Delete old applied transactions based on retention policy
922
+ * Uses distributed locking to prevent multiple containers from running GC simultaneously
923
+ */
924
+ async runGarbageCollection() {
925
+ // Acquire distributed lock for GC operation
926
+ const gcLockId = `lock-gc-${this.config.resource}-${this.config.field}`;
927
+ const [lockAcquired] = await tryFn(() =>
928
+ this.lockResource.insert({
929
+ id: gcLockId,
930
+ lockedAt: Date.now(),
931
+ workerId: process.pid ? String(process.pid) : 'unknown'
932
+ })
933
+ );
934
+
935
+ // If another container is already running GC, skip
936
+ if (!lockAcquired) {
937
+ if (this.config.verbose) {
938
+ console.log(`[EventualConsistency] GC already running in another container`);
939
+ }
940
+ return;
941
+ }
942
+
943
+ try {
944
+ const now = Date.now();
945
+ const retentionMs = this.config.transactionRetention * 24 * 60 * 60 * 1000; // Days to ms
946
+ const cutoffDate = new Date(now - retentionMs);
947
+ const cutoffIso = cutoffDate.toISOString();
948
+
949
+ if (this.config.verbose) {
950
+ console.log(`[EventualConsistency] Running GC for transactions older than ${cutoffIso} (${this.config.transactionRetention} days)`);
951
+ }
952
+
953
+ // Query old applied transactions
954
+ const cutoffMonth = cutoffDate.toISOString().substring(0, 7); // YYYY-MM
955
+
956
+ const [ok, err, oldTransactions] = await tryFn(() =>
957
+ this.transactionResource.query({
958
+ applied: true,
959
+ timestamp: { '<': cutoffIso }
960
+ })
961
+ );
962
+
963
+ if (!ok) {
964
+ if (this.config.verbose) {
965
+ console.warn(`[EventualConsistency] GC failed to query transactions:`, err?.message);
966
+ }
967
+ return;
968
+ }
969
+
970
+ if (!oldTransactions || oldTransactions.length === 0) {
971
+ if (this.config.verbose) {
972
+ console.log(`[EventualConsistency] No old transactions to clean up`);
973
+ }
974
+ return;
975
+ }
976
+
977
+ if (this.config.verbose) {
978
+ console.log(`[EventualConsistency] Deleting ${oldTransactions.length} old transactions`);
979
+ }
980
+
981
+ // Delete old transactions using PromisePool
982
+ const { results, errors } = await PromisePool
983
+ .for(oldTransactions)
984
+ .withConcurrency(10)
985
+ .process(async (txn) => {
986
+ const [deleted] = await tryFn(() => this.transactionResource.delete(txn.id));
987
+ return deleted;
988
+ });
989
+
990
+ if (this.config.verbose) {
991
+ console.log(`[EventualConsistency] GC completed: ${results.length} deleted, ${errors.length} errors`);
992
+ }
993
+
994
+ this.emit('eventual-consistency.gc-completed', {
995
+ resource: this.config.resource,
996
+ field: this.config.field,
997
+ deletedCount: results.length,
998
+ errorCount: errors.length
999
+ });
1000
+ } catch (error) {
1001
+ if (this.config.verbose) {
1002
+ console.warn(`[EventualConsistency] GC error:`, error.message);
1003
+ }
1004
+ this.emit('eventual-consistency.gc-error', error);
1005
+ } finally {
1006
+ // Always release GC lock
1007
+ await tryFn(() => this.lockResource.delete(gcLockId));
1008
+ }
1009
+ }
1010
+ }
1011
+
1012
+ export default EventualConsistencyPlugin;