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.
@@ -0,0 +1,455 @@
1
+ /**
2
+ * EventualConsistencyPlugin - Main export
3
+ * Provides eventually consistent counters using transaction log pattern
4
+ * @module eventual-consistency
5
+ */
6
+
7
+ import Plugin from "../plugin.class.js";
8
+ import { createConfig, validateResourcesConfig, logConfigWarnings, logInitialization } from "./config.js";
9
+ import { detectTimezone, getCohortInfo, createFieldHandler } from "./utils.js";
10
+ import { createPartitionConfig } from "./partitions.js";
11
+ import { createTransaction } from "./transactions.js";
12
+ import {
13
+ consolidateRecord,
14
+ getConsolidatedValue,
15
+ getCohortStats,
16
+ recalculateRecord,
17
+ runConsolidation
18
+ } from "./consolidation.js";
19
+ import { runGarbageCollection } from "./garbage-collection.js";
20
+ import { updateAnalytics, getAnalytics, getMonthByDay, getDayByHour, getLastNDays, getYearByMonth, getMonthByHour, getTopRecords } from "./analytics.js";
21
+ import { onSetup, onStart, onStop, watchForResource, completeFieldSetup } from "./setup.js";
22
+
23
+ export class EventualConsistencyPlugin extends Plugin {
24
+ constructor(options = {}) {
25
+ super(options);
26
+
27
+ // Validate resources structure
28
+ validateResourcesConfig(options.resources);
29
+
30
+ // Auto-detect timezone
31
+ const detectedTimezone = detectTimezone();
32
+ const timezoneAutoDetected = !options.cohort?.timezone;
33
+
34
+ // Create shared configuration
35
+ this.config = createConfig(options, detectedTimezone);
36
+
37
+ // Create field handlers map
38
+ this.fieldHandlers = new Map(); // Map<resourceName, Map<fieldName, handler>>
39
+
40
+ // Parse resources configuration
41
+ for (const [resourceName, fields] of Object.entries(options.resources)) {
42
+ const resourceHandlers = new Map();
43
+ for (const fieldName of fields) {
44
+ // Create a field handler for each resource/field combination
45
+ resourceHandlers.set(fieldName, createFieldHandler(resourceName, fieldName));
46
+ }
47
+ this.fieldHandlers.set(resourceName, resourceHandlers);
48
+ }
49
+
50
+ // Log warnings and initialization
51
+ logConfigWarnings(this.config);
52
+ logInitialization(this.config, this.fieldHandlers, timezoneAutoDetected);
53
+ }
54
+
55
+ /**
56
+ * Setup hook - create resources and register helpers
57
+ */
58
+ async onSetup() {
59
+ await onSetup(
60
+ this.database,
61
+ this.fieldHandlers,
62
+ (handler) => completeFieldSetup(handler, this.database, this.config, this),
63
+ (resourceName) => watchForResource(resourceName, this.database, this.fieldHandlers,
64
+ (handler) => completeFieldSetup(handler, this.database, this.config, this))
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Start hook - begin timers and emit events
70
+ */
71
+ async onStart() {
72
+ await onStart(
73
+ this.fieldHandlers,
74
+ this.config,
75
+ (handler, resourceName, fieldName) => this._runConsolidationForHandler(handler, resourceName, fieldName),
76
+ (handler, resourceName, fieldName) => this._runGarbageCollectionForHandler(handler, resourceName, fieldName),
77
+ (event, data) => this.emit(event, data)
78
+ );
79
+ }
80
+
81
+ /**
82
+ * Stop hook - stop timers and flush pending
83
+ */
84
+ async onStop() {
85
+ await onStop(
86
+ this.fieldHandlers,
87
+ (event, data) => this.emit(event, data)
88
+ );
89
+ }
90
+
91
+ /**
92
+ * Create partition configuration
93
+ * @returns {Object} Partition configuration
94
+ */
95
+ createPartitionConfig() {
96
+ return createPartitionConfig();
97
+ }
98
+
99
+ /**
100
+ * Get cohort information for a date
101
+ * @param {Date} date - Date to get cohort info for
102
+ * @returns {Object} Cohort information
103
+ */
104
+ getCohortInfo(date) {
105
+ return getCohortInfo(date, this.config.cohort.timezone, this.config.verbose);
106
+ }
107
+
108
+ /**
109
+ * Create a transaction for a field handler
110
+ * @param {Object} handler - Field handler
111
+ * @param {Object} data - Transaction data
112
+ * @returns {Promise<Object|null>} Created transaction
113
+ */
114
+ async createTransaction(handler, data) {
115
+ return await createTransaction(handler, data, this.config);
116
+ }
117
+
118
+ /**
119
+ * Consolidate a single record (internal method)
120
+ * This is used internally by consolidation timers and helper methods
121
+ * @private
122
+ */
123
+ async consolidateRecord(originalId) {
124
+ return await consolidateRecord(
125
+ originalId,
126
+ this.transactionResource,
127
+ this.targetResource,
128
+ this.lockResource,
129
+ this.analyticsResource,
130
+ (transactions) => this.updateAnalytics(transactions),
131
+ this.config
132
+ );
133
+ }
134
+
135
+ /**
136
+ * Get consolidated value without applying (internal method)
137
+ * @private
138
+ */
139
+ async getConsolidatedValue(originalId, options = {}) {
140
+ return await getConsolidatedValue(
141
+ originalId,
142
+ options,
143
+ this.transactionResource,
144
+ this.targetResource,
145
+ this.config
146
+ );
147
+ }
148
+
149
+ /**
150
+ * Get cohort statistics
151
+ * @param {string} cohortDate - Cohort date
152
+ * @returns {Promise<Object|null>} Cohort statistics
153
+ */
154
+ async getCohortStats(cohortDate) {
155
+ return await getCohortStats(cohortDate, this.transactionResource);
156
+ }
157
+
158
+ /**
159
+ * Recalculate from scratch (internal method)
160
+ * @private
161
+ */
162
+ async recalculateRecord(originalId) {
163
+ return await recalculateRecord(
164
+ originalId,
165
+ this.transactionResource,
166
+ this.targetResource,
167
+ this.lockResource,
168
+ (id) => this.consolidateRecord(id),
169
+ this.config
170
+ );
171
+ }
172
+
173
+ /**
174
+ * Update analytics
175
+ * @private
176
+ */
177
+ async updateAnalytics(transactions) {
178
+ return await updateAnalytics(transactions, this.analyticsResource, this.config);
179
+ }
180
+
181
+ /**
182
+ * Helper method for sync mode consolidation
183
+ * @private
184
+ */
185
+ async _syncModeConsolidate(handler, id, field) {
186
+ // Temporarily set config for legacy methods
187
+ const oldResource = this.config.resource;
188
+ const oldField = this.config.field;
189
+ const oldTransactionResource = this.transactionResource;
190
+ const oldTargetResource = this.targetResource;
191
+ const oldLockResource = this.lockResource;
192
+ const oldAnalyticsResource = this.analyticsResource;
193
+
194
+ this.config.resource = handler.resource;
195
+ this.config.field = handler.field;
196
+ this.transactionResource = handler.transactionResource;
197
+ this.targetResource = handler.targetResource;
198
+ this.lockResource = handler.lockResource;
199
+ this.analyticsResource = handler.analyticsResource;
200
+
201
+ const result = await this.consolidateRecord(id);
202
+
203
+ // Restore
204
+ this.config.resource = oldResource;
205
+ this.config.field = oldField;
206
+ this.transactionResource = oldTransactionResource;
207
+ this.targetResource = oldTargetResource;
208
+ this.lockResource = oldLockResource;
209
+ this.analyticsResource = oldAnalyticsResource;
210
+
211
+ return result;
212
+ }
213
+
214
+ /**
215
+ * Helper method for consolidate with handler
216
+ * @private
217
+ */
218
+ async _consolidateWithHandler(handler, id) {
219
+ const oldResource = this.config.resource;
220
+ const oldField = this.config.field;
221
+ const oldTransactionResource = this.transactionResource;
222
+ const oldTargetResource = this.targetResource;
223
+ const oldLockResource = this.lockResource;
224
+ const oldAnalyticsResource = this.analyticsResource;
225
+
226
+ this.config.resource = handler.resource;
227
+ this.config.field = handler.field;
228
+ this.transactionResource = handler.transactionResource;
229
+ this.targetResource = handler.targetResource;
230
+ this.lockResource = handler.lockResource;
231
+ this.analyticsResource = handler.analyticsResource;
232
+
233
+ const result = await this.consolidateRecord(id);
234
+
235
+ this.config.resource = oldResource;
236
+ this.config.field = oldField;
237
+ this.transactionResource = oldTransactionResource;
238
+ this.targetResource = oldTargetResource;
239
+ this.lockResource = oldLockResource;
240
+ this.analyticsResource = oldAnalyticsResource;
241
+
242
+ return result;
243
+ }
244
+
245
+ /**
246
+ * Helper method for getConsolidatedValue with handler
247
+ * @private
248
+ */
249
+ async _getConsolidatedValueWithHandler(handler, id, options) {
250
+ const oldResource = this.config.resource;
251
+ const oldField = this.config.field;
252
+ const oldTransactionResource = this.transactionResource;
253
+ const oldTargetResource = this.targetResource;
254
+
255
+ this.config.resource = handler.resource;
256
+ this.config.field = handler.field;
257
+ this.transactionResource = handler.transactionResource;
258
+ this.targetResource = handler.targetResource;
259
+
260
+ const result = await this.getConsolidatedValue(id, options);
261
+
262
+ this.config.resource = oldResource;
263
+ this.config.field = oldField;
264
+ this.transactionResource = oldTransactionResource;
265
+ this.targetResource = oldTargetResource;
266
+
267
+ return result;
268
+ }
269
+
270
+ /**
271
+ * Helper method for recalculate with handler
272
+ * @private
273
+ */
274
+ async _recalculateWithHandler(handler, id) {
275
+ const oldResource = this.config.resource;
276
+ const oldField = this.config.field;
277
+ const oldTransactionResource = this.transactionResource;
278
+ const oldTargetResource = this.targetResource;
279
+ const oldLockResource = this.lockResource;
280
+ const oldAnalyticsResource = this.analyticsResource;
281
+
282
+ this.config.resource = handler.resource;
283
+ this.config.field = handler.field;
284
+ this.transactionResource = handler.transactionResource;
285
+ this.targetResource = handler.targetResource;
286
+ this.lockResource = handler.lockResource;
287
+ this.analyticsResource = handler.analyticsResource;
288
+
289
+ const result = await this.recalculateRecord(id);
290
+
291
+ this.config.resource = oldResource;
292
+ this.config.field = oldField;
293
+ this.transactionResource = oldTransactionResource;
294
+ this.targetResource = oldTargetResource;
295
+ this.lockResource = oldLockResource;
296
+ this.analyticsResource = oldAnalyticsResource;
297
+
298
+ return result;
299
+ }
300
+
301
+ /**
302
+ * Run consolidation for a handler
303
+ * @private
304
+ */
305
+ async _runConsolidationForHandler(handler, resourceName, fieldName) {
306
+ const oldResource = this.config.resource;
307
+ const oldField = this.config.field;
308
+ const oldTransactionResource = this.transactionResource;
309
+ const oldTargetResource = this.targetResource;
310
+ const oldLockResource = this.lockResource;
311
+ const oldAnalyticsResource = this.analyticsResource;
312
+
313
+ this.config.resource = resourceName;
314
+ this.config.field = fieldName;
315
+ this.transactionResource = handler.transactionResource;
316
+ this.targetResource = handler.targetResource;
317
+ this.lockResource = handler.lockResource;
318
+ this.analyticsResource = handler.analyticsResource;
319
+
320
+ try {
321
+ await runConsolidation(
322
+ this.transactionResource,
323
+ (id) => this.consolidateRecord(id),
324
+ (event, data) => this.emit(event, data),
325
+ this.config
326
+ );
327
+ } finally {
328
+ this.config.resource = oldResource;
329
+ this.config.field = oldField;
330
+ this.transactionResource = oldTransactionResource;
331
+ this.targetResource = oldTargetResource;
332
+ this.lockResource = oldLockResource;
333
+ this.analyticsResource = oldAnalyticsResource;
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Run garbage collection for a handler
339
+ * @private
340
+ */
341
+ async _runGarbageCollectionForHandler(handler, resourceName, fieldName) {
342
+ const oldResource = this.config.resource;
343
+ const oldField = this.config.field;
344
+ const oldTransactionResource = this.transactionResource;
345
+ const oldTargetResource = this.targetResource;
346
+ const oldLockResource = this.lockResource;
347
+
348
+ this.config.resource = resourceName;
349
+ this.config.field = fieldName;
350
+ this.transactionResource = handler.transactionResource;
351
+ this.targetResource = handler.targetResource;
352
+ this.lockResource = handler.lockResource;
353
+
354
+ try {
355
+ await runGarbageCollection(
356
+ this.transactionResource,
357
+ this.lockResource,
358
+ this.config,
359
+ (event, data) => this.emit(event, data)
360
+ );
361
+ } finally {
362
+ this.config.resource = oldResource;
363
+ this.config.field = oldField;
364
+ this.transactionResource = oldTransactionResource;
365
+ this.targetResource = oldTargetResource;
366
+ this.lockResource = oldLockResource;
367
+ }
368
+ }
369
+
370
+ // Public Analytics API
371
+
372
+ /**
373
+ * Get analytics for a specific period
374
+ * @param {string} resourceName - Resource name
375
+ * @param {string} field - Field name
376
+ * @param {Object} options - Query options
377
+ * @returns {Promise<Array>} Analytics data
378
+ */
379
+ async getAnalytics(resourceName, field, options = {}) {
380
+ return await getAnalytics(resourceName, field, options, this.fieldHandlers);
381
+ }
382
+
383
+ /**
384
+ * Get analytics for entire month, broken down by days
385
+ * @param {string} resourceName - Resource name
386
+ * @param {string} field - Field name
387
+ * @param {string} month - Month in YYYY-MM format
388
+ * @param {Object} options - Options
389
+ * @returns {Promise<Array>} Daily analytics for the month
390
+ */
391
+ async getMonthByDay(resourceName, field, month, options = {}) {
392
+ return await getMonthByDay(resourceName, field, month, options, this.fieldHandlers);
393
+ }
394
+
395
+ /**
396
+ * Get analytics for entire day, broken down by hours
397
+ * @param {string} resourceName - Resource name
398
+ * @param {string} field - Field name
399
+ * @param {string} date - Date in YYYY-MM-DD format
400
+ * @param {Object} options - Options
401
+ * @returns {Promise<Array>} Hourly analytics for the day
402
+ */
403
+ async getDayByHour(resourceName, field, date, options = {}) {
404
+ return await getDayByHour(resourceName, field, date, options, this.fieldHandlers);
405
+ }
406
+
407
+ /**
408
+ * Get analytics for last N days, broken down by days
409
+ * @param {string} resourceName - Resource name
410
+ * @param {string} field - Field name
411
+ * @param {number} days - Number of days to look back (default: 7)
412
+ * @param {Object} options - Options
413
+ * @returns {Promise<Array>} Daily analytics
414
+ */
415
+ async getLastNDays(resourceName, field, days = 7, options = {}) {
416
+ return await getLastNDays(resourceName, field, days, options, this.fieldHandlers);
417
+ }
418
+
419
+ /**
420
+ * Get analytics for entire year, broken down by months
421
+ * @param {string} resourceName - Resource name
422
+ * @param {string} field - Field name
423
+ * @param {number} year - Year (e.g., 2025)
424
+ * @param {Object} options - Options
425
+ * @returns {Promise<Array>} Monthly analytics for the year
426
+ */
427
+ async getYearByMonth(resourceName, field, year, options = {}) {
428
+ return await getYearByMonth(resourceName, field, year, options, this.fieldHandlers);
429
+ }
430
+
431
+ /**
432
+ * Get analytics for entire month, broken down by hours
433
+ * @param {string} resourceName - Resource name
434
+ * @param {string} field - Field name
435
+ * @param {string} month - Month in YYYY-MM format (or 'last' for previous month)
436
+ * @param {Object} options - Options
437
+ * @returns {Promise<Array>} Hourly analytics for the month
438
+ */
439
+ async getMonthByHour(resourceName, field, month, options = {}) {
440
+ return await getMonthByHour(resourceName, field, month, options, this.fieldHandlers);
441
+ }
442
+
443
+ /**
444
+ * Get top records by volume
445
+ * @param {string} resourceName - Resource name
446
+ * @param {string} field - Field name
447
+ * @param {Object} options - Query options
448
+ * @returns {Promise<Array>} Top records
449
+ */
450
+ async getTopRecords(resourceName, field, options = {}) {
451
+ return await getTopRecords(resourceName, field, options, this.fieldHandlers);
452
+ }
453
+ }
454
+
455
+ export default EventualConsistencyPlugin;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Distributed locking for EventualConsistencyPlugin
3
+ * @module eventual-consistency/locks
4
+ */
5
+
6
+ import tryFn from "../../concerns/try-fn.js";
7
+ import { PromisePool } from "@supercharge/promise-pool";
8
+
9
+ /**
10
+ * Clean up stale locks that exceed the configured timeout
11
+ * Uses distributed locking to prevent multiple containers from cleaning simultaneously
12
+ *
13
+ * @param {Object} lockResource - Lock resource instance
14
+ * @param {Object} config - Plugin configuration
15
+ * @returns {Promise<void>}
16
+ */
17
+ export async function cleanupStaleLocks(lockResource, config) {
18
+ const now = Date.now();
19
+ const lockTimeoutMs = config.lockTimeout * 1000; // Convert seconds to ms
20
+ const cutoffTime = now - lockTimeoutMs;
21
+
22
+ // Acquire distributed lock for cleanup operation
23
+ const cleanupLockId = `lock-cleanup-${config.resource}-${config.field}`;
24
+ const [lockAcquired] = await tryFn(() =>
25
+ lockResource.insert({
26
+ id: cleanupLockId,
27
+ lockedAt: Date.now(),
28
+ workerId: process.pid ? String(process.pid) : 'unknown'
29
+ })
30
+ );
31
+
32
+ // If another container is already cleaning, skip
33
+ if (!lockAcquired) {
34
+ if (config.verbose) {
35
+ console.log(`[EventualConsistency] Lock cleanup already running in another container`);
36
+ }
37
+ return;
38
+ }
39
+
40
+ try {
41
+ // Get all locks
42
+ const [ok, err, locks] = await tryFn(() => lockResource.list());
43
+
44
+ if (!ok || !locks || locks.length === 0) return;
45
+
46
+ // Find stale locks (excluding the cleanup lock itself)
47
+ const staleLocks = locks.filter(lock =>
48
+ lock.id !== cleanupLockId && lock.lockedAt < cutoffTime
49
+ );
50
+
51
+ if (staleLocks.length === 0) return;
52
+
53
+ if (config.verbose) {
54
+ console.log(`[EventualConsistency] Cleaning up ${staleLocks.length} stale locks`);
55
+ }
56
+
57
+ // Delete stale locks using PromisePool
58
+ const { results, errors } = await PromisePool
59
+ .for(staleLocks)
60
+ .withConcurrency(5)
61
+ .process(async (lock) => {
62
+ const [deleted] = await tryFn(() => lockResource.delete(lock.id));
63
+ return deleted;
64
+ });
65
+
66
+ if (errors && errors.length > 0 && config.verbose) {
67
+ console.warn(`[EventualConsistency] ${errors.length} stale locks failed to delete`);
68
+ }
69
+ } catch (error) {
70
+ if (config.verbose) {
71
+ console.warn(`[EventualConsistency] Error cleaning up stale locks:`, error.message);
72
+ }
73
+ } finally {
74
+ // Always release cleanup lock
75
+ await tryFn(() => lockResource.delete(cleanupLockId));
76
+ }
77
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Partition configuration for EventualConsistencyPlugin
3
+ * @module eventual-consistency/partitions
4
+ */
5
+
6
+ /**
7
+ * Create partition configuration for transaction resources
8
+ * This defines how transactions are organized in S3 for O(1) query performance
9
+ *
10
+ * @returns {Object} Partition configuration
11
+ */
12
+ export function createPartitionConfig() {
13
+ // Create partitions for transactions
14
+ const partitions = {
15
+ // Composite partition by originalId + applied status
16
+ // This is THE MOST CRITICAL optimization for consolidation!
17
+ // Why: Consolidation always queries { originalId, applied: false }
18
+ // Without this: Reads ALL transactions (applied + pending) and filters manually
19
+ // With this: Reads ONLY pending transactions - can be 1000x faster!
20
+ byOriginalIdAndApplied: {
21
+ fields: {
22
+ originalId: 'string',
23
+ applied: 'boolean'
24
+ }
25
+ },
26
+ // Partition by time cohorts for batch consolidation across many records
27
+ byHour: {
28
+ fields: {
29
+ cohortHour: 'string'
30
+ }
31
+ },
32
+ byDay: {
33
+ fields: {
34
+ cohortDate: 'string'
35
+ }
36
+ },
37
+ byMonth: {
38
+ fields: {
39
+ cohortMonth: 'string'
40
+ }
41
+ }
42
+ };
43
+
44
+ return partitions;
45
+ }