s3db.js 9.2.2 → 10.0.0

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.
@@ -98,7 +98,7 @@ export class BackupPlugin extends Plugin {
98
98
  include: options.include || null,
99
99
  exclude: options.exclude || [],
100
100
  backupMetadataResource: options.backupMetadataResource || 'backup_metadata',
101
- tempDir: options.tempDir || './tmp/backups',
101
+ tempDir: options.tempDir || '/tmp/s3db/backups',
102
102
  verbose: options.verbose || false,
103
103
  onBackupStart: options.onBackupStart || null,
104
104
  onBackupComplete: options.onBackupComplete || null,
@@ -0,0 +1,609 @@
1
+ import Plugin from "./plugin.class.js";
2
+ import tryFn from "../concerns/try-fn.js";
3
+
4
+ export class EventualConsistencyPlugin extends Plugin {
5
+ constructor(options = {}) {
6
+ super(options);
7
+
8
+ // Validate required options
9
+ if (!options.resource) {
10
+ throw new Error("EventualConsistencyPlugin requires 'resource' option");
11
+ }
12
+ if (!options.field) {
13
+ throw new Error("EventualConsistencyPlugin requires 'field' option");
14
+ }
15
+
16
+ this.config = {
17
+ resource: options.resource,
18
+ field: options.field,
19
+ cohort: {
20
+ interval: options.cohort?.interval || '24h',
21
+ timezone: options.cohort?.timezone || 'UTC',
22
+ ...options.cohort
23
+ },
24
+ reducer: options.reducer || ((transactions) => {
25
+ // Default reducer: sum all increments from a base value
26
+ let baseValue = 0;
27
+
28
+ for (const t of transactions) {
29
+ if (t.operation === 'set') {
30
+ baseValue = t.value;
31
+ } else if (t.operation === 'add') {
32
+ baseValue += t.value;
33
+ } else if (t.operation === 'sub') {
34
+ baseValue -= t.value;
35
+ }
36
+ }
37
+
38
+ return baseValue;
39
+ }),
40
+ consolidationInterval: options.consolidationInterval || 3600000, // 1 hour default
41
+ autoConsolidate: options.autoConsolidate !== false,
42
+ batchTransactions: options.batchTransactions || false,
43
+ batchSize: options.batchSize || 100,
44
+ mode: options.mode || 'async', // 'async' or 'sync'
45
+ ...options
46
+ };
47
+
48
+ this.transactionResource = null;
49
+ this.targetResource = null;
50
+ this.consolidationTimer = null;
51
+ this.pendingTransactions = new Map(); // Cache for batching
52
+ }
53
+
54
+ async onSetup() {
55
+ // Try to get the target resource
56
+ this.targetResource = this.database.resources[this.config.resource];
57
+
58
+ if (!this.targetResource) {
59
+ // Resource doesn't exist yet - defer setup
60
+ this.deferredSetup = true;
61
+ this.watchForResource();
62
+ return;
63
+ }
64
+
65
+ // Resource exists - continue with setup
66
+ await this.completeSetup();
67
+ }
68
+
69
+ watchForResource() {
70
+ // Monitor for resource creation using database hooks
71
+ const hookCallback = async ({ resource, config }) => {
72
+ // Check if this is the resource we're waiting for
73
+ if (config.name === this.config.resource && this.deferredSetup) {
74
+ this.targetResource = resource;
75
+ this.deferredSetup = false;
76
+ await this.completeSetup();
77
+ }
78
+ };
79
+
80
+ this.database.addHook('afterCreateResource', hookCallback);
81
+ }
82
+
83
+ async completeSetup() {
84
+ if (!this.targetResource) return;
85
+
86
+ // Create transaction resource with partitions (includes field name to support multiple fields)
87
+ const transactionResourceName = `${this.config.resource}_transactions_${this.config.field}`;
88
+ const partitionConfig = this.createPartitionConfig();
89
+
90
+ const [ok, err, transactionResource] = await tryFn(() =>
91
+ this.database.createResource({
92
+ name: transactionResourceName,
93
+ attributes: {
94
+ id: 'string|required',
95
+ originalId: 'string|required',
96
+ field: 'string|required',
97
+ value: 'number|required',
98
+ operation: 'string|required', // 'set', 'add', or 'sub'
99
+ timestamp: 'string|required',
100
+ cohortDate: 'string|required', // For partitioning
101
+ cohortMonth: 'string|optional', // For monthly partitioning
102
+ source: 'string|optional',
103
+ applied: 'boolean|optional' // Track if transaction was applied
104
+ },
105
+ behavior: 'body-overflow',
106
+ timestamps: true,
107
+ partitions: partitionConfig,
108
+ asyncPartitions: true // Use async partitions for better performance
109
+ })
110
+ );
111
+
112
+ if (!ok && !this.database.resources[transactionResourceName]) {
113
+ throw new Error(`Failed to create transaction resource: ${err?.message}`);
114
+ }
115
+
116
+ this.transactionResource = ok ? transactionResource : this.database.resources[transactionResourceName];
117
+
118
+ // Add helper methods to the resource
119
+ this.addHelperMethods();
120
+
121
+ // Setup consolidation if enabled
122
+ if (this.config.autoConsolidate) {
123
+ this.startConsolidationTimer();
124
+ }
125
+ }
126
+
127
+ async onStart() {
128
+ // Don't start if we're waiting for the resource
129
+ if (this.deferredSetup) {
130
+ return;
131
+ }
132
+
133
+ // Plugin is ready
134
+ this.emit('eventual-consistency.started', {
135
+ resource: this.config.resource,
136
+ field: this.config.field,
137
+ cohort: this.config.cohort
138
+ });
139
+ }
140
+
141
+ async onStop() {
142
+ // Stop consolidation timer
143
+ if (this.consolidationTimer) {
144
+ clearInterval(this.consolidationTimer);
145
+ this.consolidationTimer = null;
146
+ }
147
+
148
+ // Flush pending transactions
149
+ await this.flushPendingTransactions();
150
+
151
+ this.emit('eventual-consistency.stopped', {
152
+ resource: this.config.resource,
153
+ field: this.config.field
154
+ });
155
+ }
156
+
157
+ createPartitionConfig() {
158
+ // Always create both daily and monthly partitions for transactions
159
+ const partitions = {
160
+ byDay: {
161
+ fields: {
162
+ cohortDate: 'string'
163
+ }
164
+ },
165
+ byMonth: {
166
+ fields: {
167
+ cohortMonth: 'string'
168
+ }
169
+ }
170
+ };
171
+
172
+ return partitions;
173
+ }
174
+
175
+ addHelperMethods() {
176
+ const resource = this.targetResource;
177
+ const defaultField = this.config.field;
178
+ const plugin = this;
179
+
180
+ // Store all plugins by field name for this resource
181
+ if (!resource._eventualConsistencyPlugins) {
182
+ resource._eventualConsistencyPlugins = {};
183
+ }
184
+ resource._eventualConsistencyPlugins[defaultField] = plugin;
185
+
186
+ // Add method to set value (replaces current value)
187
+ resource.set = async (id, fieldOrValue, value) => {
188
+ // Check if there are multiple fields with eventual consistency
189
+ const hasMultipleFields = Object.keys(resource._eventualConsistencyPlugins).length > 1;
190
+
191
+ // If multiple fields exist and only 2 params given, throw error
192
+ if (hasMultipleFields && value === undefined) {
193
+ throw new Error(`Multiple fields have eventual consistency. Please specify the field: set(id, field, value)`);
194
+ }
195
+
196
+ // Handle both signatures: set(id, value) and set(id, field, value)
197
+ const field = value !== undefined ? fieldOrValue : defaultField;
198
+ const actualValue = value !== undefined ? value : fieldOrValue;
199
+ const fieldPlugin = resource._eventualConsistencyPlugins[field];
200
+
201
+ if (!fieldPlugin) {
202
+ throw new Error(`No eventual consistency plugin found for field "${field}"`);
203
+ }
204
+
205
+ // Create set transaction
206
+ await fieldPlugin.createTransaction({
207
+ originalId: id,
208
+ operation: 'set',
209
+ value: actualValue,
210
+ source: 'set'
211
+ });
212
+
213
+ // In sync mode, immediately consolidate and update
214
+ if (fieldPlugin.config.mode === 'sync') {
215
+ const consolidatedValue = await fieldPlugin.consolidateRecord(id);
216
+ await resource.update(id, {
217
+ [field]: consolidatedValue
218
+ });
219
+ return consolidatedValue;
220
+ }
221
+
222
+ return actualValue;
223
+ };
224
+
225
+ // Add method to increment value
226
+ resource.add = async (id, fieldOrAmount, amount) => {
227
+ // Check if there are multiple fields with eventual consistency
228
+ const hasMultipleFields = Object.keys(resource._eventualConsistencyPlugins).length > 1;
229
+
230
+ // If multiple fields exist and only 2 params given, throw error
231
+ if (hasMultipleFields && amount === undefined) {
232
+ throw new Error(`Multiple fields have eventual consistency. Please specify the field: add(id, field, amount)`);
233
+ }
234
+
235
+ // Handle both signatures: add(id, amount) and add(id, field, amount)
236
+ const field = amount !== undefined ? fieldOrAmount : defaultField;
237
+ const actualAmount = amount !== undefined ? amount : fieldOrAmount;
238
+ const fieldPlugin = resource._eventualConsistencyPlugins[field];
239
+
240
+ if (!fieldPlugin) {
241
+ throw new Error(`No eventual consistency plugin found for field "${field}"`);
242
+ }
243
+
244
+ // Create add transaction
245
+ await fieldPlugin.createTransaction({
246
+ originalId: id,
247
+ operation: 'add',
248
+ value: actualAmount,
249
+ source: 'add'
250
+ });
251
+
252
+ // In sync mode, immediately consolidate and update
253
+ if (fieldPlugin.config.mode === 'sync') {
254
+ const consolidatedValue = await fieldPlugin.consolidateRecord(id);
255
+ await resource.update(id, {
256
+ [field]: consolidatedValue
257
+ });
258
+ return consolidatedValue;
259
+ }
260
+
261
+ // In async mode, return expected value (for user feedback)
262
+ const currentValue = await fieldPlugin.getConsolidatedValue(id);
263
+ return currentValue + actualAmount;
264
+ };
265
+
266
+ // Add method to decrement value
267
+ resource.sub = async (id, fieldOrAmount, amount) => {
268
+ // Check if there are multiple fields with eventual consistency
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 && amount === undefined) {
273
+ throw new Error(`Multiple fields have eventual consistency. Please specify the field: sub(id, field, amount)`);
274
+ }
275
+
276
+ // Handle both signatures: sub(id, amount) and sub(id, field, amount)
277
+ const field = amount !== undefined ? fieldOrAmount : defaultField;
278
+ const actualAmount = amount !== undefined ? amount : fieldOrAmount;
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
+ // Create sub transaction
286
+ await fieldPlugin.createTransaction({
287
+ originalId: id,
288
+ operation: 'sub',
289
+ value: actualAmount,
290
+ source: 'sub'
291
+ });
292
+
293
+ // In sync mode, immediately consolidate and update
294
+ if (fieldPlugin.config.mode === 'sync') {
295
+ const consolidatedValue = await fieldPlugin.consolidateRecord(id);
296
+ await resource.update(id, {
297
+ [field]: consolidatedValue
298
+ });
299
+ return consolidatedValue;
300
+ }
301
+
302
+ // In async mode, return expected value (for user feedback)
303
+ const currentValue = await fieldPlugin.getConsolidatedValue(id);
304
+ return currentValue - actualAmount;
305
+ };
306
+
307
+ // Add method to manually trigger consolidation
308
+ resource.consolidate = async (id, field) => {
309
+ // Check if there are multiple fields with eventual consistency
310
+ const hasMultipleFields = Object.keys(resource._eventualConsistencyPlugins).length > 1;
311
+
312
+ // If multiple fields exist and no field given, throw error
313
+ if (hasMultipleFields && !field) {
314
+ throw new Error(`Multiple fields have eventual consistency. Please specify the field: consolidate(id, field)`);
315
+ }
316
+
317
+ // Handle both signatures: consolidate(id) and consolidate(id, field)
318
+ const actualField = field || defaultField;
319
+ const fieldPlugin = resource._eventualConsistencyPlugins[actualField];
320
+
321
+ if (!fieldPlugin) {
322
+ throw new Error(`No eventual consistency plugin found for field "${actualField}"`);
323
+ }
324
+
325
+ return await fieldPlugin.consolidateRecord(id);
326
+ };
327
+
328
+ // Add method to get consolidated value without applying
329
+ resource.getConsolidatedValue = async (id, fieldOrOptions, options) => {
330
+ // Handle both signatures: getConsolidatedValue(id, options) and getConsolidatedValue(id, field, options)
331
+ if (typeof fieldOrOptions === 'string') {
332
+ const field = fieldOrOptions;
333
+ const fieldPlugin = resource._eventualConsistencyPlugins[field] || plugin;
334
+ return await fieldPlugin.getConsolidatedValue(id, options || {});
335
+ } else {
336
+ return await plugin.getConsolidatedValue(id, fieldOrOptions || {});
337
+ }
338
+ };
339
+ }
340
+
341
+ async createTransaction(data) {
342
+ const now = new Date();
343
+ const cohortInfo = this.getCohortInfo(now);
344
+
345
+ const transaction = {
346
+ id: `txn-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
347
+ originalId: data.originalId,
348
+ field: this.config.field,
349
+ value: data.value || 0,
350
+ operation: data.operation || 'set',
351
+ timestamp: now.toISOString(),
352
+ cohortDate: cohortInfo.date,
353
+ cohortMonth: cohortInfo.month,
354
+ source: data.source || 'unknown',
355
+ applied: false
356
+ };
357
+
358
+ // Batch transactions if configured
359
+ if (this.config.batchTransactions) {
360
+ this.pendingTransactions.set(transaction.id, transaction);
361
+
362
+ // Flush if batch size reached
363
+ if (this.pendingTransactions.size >= this.config.batchSize) {
364
+ await this.flushPendingTransactions();
365
+ }
366
+ } else {
367
+ await this.transactionResource.insert(transaction);
368
+ }
369
+
370
+ return transaction;
371
+ }
372
+
373
+ async flushPendingTransactions() {
374
+ if (this.pendingTransactions.size === 0) return;
375
+
376
+ const transactions = Array.from(this.pendingTransactions.values());
377
+ this.pendingTransactions.clear();
378
+
379
+ // Insert all pending transactions
380
+ for (const transaction of transactions) {
381
+ await this.transactionResource.insert(transaction);
382
+ }
383
+ }
384
+
385
+ getCohortInfo(date) {
386
+ const tz = this.config.cohort.timezone;
387
+
388
+ // Simple timezone offset calculation (can be enhanced with a library)
389
+ const offset = this.getTimezoneOffset(tz);
390
+ const localDate = new Date(date.getTime() + offset);
391
+
392
+ const year = localDate.getFullYear();
393
+ const month = String(localDate.getMonth() + 1).padStart(2, '0');
394
+ const day = String(localDate.getDate()).padStart(2, '0');
395
+
396
+ return {
397
+ date: `${year}-${month}-${day}`,
398
+ month: `${year}-${month}`
399
+ };
400
+ }
401
+
402
+ getTimezoneOffset(timezone) {
403
+ // Simplified timezone offset calculation
404
+ // In production, use a proper timezone library
405
+ const offsets = {
406
+ 'UTC': 0,
407
+ 'America/New_York': -5 * 3600000,
408
+ 'America/Chicago': -6 * 3600000,
409
+ 'America/Denver': -7 * 3600000,
410
+ 'America/Los_Angeles': -8 * 3600000,
411
+ 'America/Sao_Paulo': -3 * 3600000,
412
+ 'Europe/London': 0,
413
+ 'Europe/Paris': 1 * 3600000,
414
+ 'Europe/Berlin': 1 * 3600000,
415
+ 'Asia/Tokyo': 9 * 3600000,
416
+ 'Asia/Shanghai': 8 * 3600000,
417
+ 'Australia/Sydney': 10 * 3600000
418
+ };
419
+
420
+ return offsets[timezone] || 0;
421
+ }
422
+
423
+ startConsolidationTimer() {
424
+ const interval = this.config.consolidationInterval;
425
+
426
+ this.consolidationTimer = setInterval(async () => {
427
+ await this.runConsolidation();
428
+ }, interval);
429
+ }
430
+
431
+ async runConsolidation() {
432
+ try {
433
+ // Get all unique originalIds from transactions that need consolidation
434
+ const [ok, err, transactions] = await tryFn(() =>
435
+ this.transactionResource.query({
436
+ applied: false
437
+ })
438
+ );
439
+
440
+ if (!ok) {
441
+ console.error('Consolidation failed to query transactions:', err);
442
+ return;
443
+ }
444
+
445
+ // Get unique originalIds
446
+ const uniqueIds = [...new Set(transactions.map(t => t.originalId))];
447
+
448
+ // Consolidate each record
449
+ for (const id of uniqueIds) {
450
+ await this.consolidateRecord(id);
451
+ }
452
+
453
+ this.emit('eventual-consistency.consolidated', {
454
+ resource: this.config.resource,
455
+ field: this.config.field,
456
+ recordCount: uniqueIds.length
457
+ });
458
+ } catch (error) {
459
+ console.error('Consolidation error:', error);
460
+ this.emit('eventual-consistency.consolidation-error', error);
461
+ }
462
+ }
463
+
464
+ async consolidateRecord(originalId) {
465
+ // Get the current record value first
466
+ const [recordOk, recordErr, record] = await tryFn(() =>
467
+ this.targetResource.get(originalId)
468
+ );
469
+
470
+ const currentValue = (recordOk && record) ? (record[this.config.field] || 0) : 0;
471
+
472
+ // Get all transactions for this record
473
+ const [ok, err, transactions] = await tryFn(() =>
474
+ this.transactionResource.query({
475
+ originalId,
476
+ applied: false
477
+ })
478
+ );
479
+
480
+ if (!ok || !transactions || transactions.length === 0) {
481
+ return currentValue;
482
+ }
483
+
484
+ // Sort transactions by timestamp
485
+ transactions.sort((a, b) =>
486
+ new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
487
+ );
488
+
489
+ // If there's a current value and no 'set' operations, prepend a synthetic set transaction
490
+ const hasSetOperation = transactions.some(t => t.operation === 'set');
491
+ if (currentValue !== 0 && !hasSetOperation) {
492
+ transactions.unshift({
493
+ id: '__synthetic__', // Synthetic ID that we'll skip when marking as applied
494
+ operation: 'set',
495
+ value: currentValue,
496
+ timestamp: new Date(0).toISOString() // Very old timestamp to ensure it's first
497
+ });
498
+ }
499
+
500
+ // Apply reducer to get consolidated value
501
+ const consolidatedValue = this.config.reducer(transactions);
502
+
503
+ // Update the original record
504
+ const [updateOk, updateErr] = await tryFn(() =>
505
+ this.targetResource.update(originalId, {
506
+ [this.config.field]: consolidatedValue
507
+ })
508
+ );
509
+
510
+ if (updateOk) {
511
+ // Mark transactions as applied (skip synthetic ones)
512
+ for (const txn of transactions) {
513
+ if (txn.id !== '__synthetic__') {
514
+ await this.transactionResource.update(txn.id, {
515
+ applied: true
516
+ });
517
+ }
518
+ }
519
+ }
520
+
521
+ return consolidatedValue;
522
+ }
523
+
524
+ async getConsolidatedValue(originalId, options = {}) {
525
+ const includeApplied = options.includeApplied || false;
526
+ const startDate = options.startDate;
527
+ const endDate = options.endDate;
528
+
529
+ // Build query
530
+ const query = { originalId };
531
+ if (!includeApplied) {
532
+ query.applied = false;
533
+ }
534
+
535
+ // Get transactions
536
+ const [ok, err, transactions] = await tryFn(() =>
537
+ this.transactionResource.query(query)
538
+ );
539
+
540
+ if (!ok || !transactions || transactions.length === 0) {
541
+ // If no transactions, check if record exists and return its current value
542
+ const [recordOk, recordErr, record] = await tryFn(() =>
543
+ this.targetResource.get(originalId)
544
+ );
545
+
546
+ if (recordOk && record) {
547
+ return record[this.config.field] || 0;
548
+ }
549
+
550
+ return 0;
551
+ }
552
+
553
+ // Filter by date range if specified
554
+ let filtered = transactions;
555
+ if (startDate || endDate) {
556
+ filtered = transactions.filter(t => {
557
+ const timestamp = new Date(t.timestamp);
558
+ if (startDate && timestamp < new Date(startDate)) return false;
559
+ if (endDate && timestamp > new Date(endDate)) return false;
560
+ return true;
561
+ });
562
+ }
563
+
564
+ // Sort by timestamp
565
+ filtered.sort((a, b) =>
566
+ new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
567
+ );
568
+
569
+ // Apply reducer
570
+ return this.config.reducer(filtered);
571
+ }
572
+
573
+ // Helper method to get cohort statistics
574
+ async getCohortStats(cohortDate) {
575
+ const [ok, err, transactions] = await tryFn(() =>
576
+ this.transactionResource.query({
577
+ cohortDate
578
+ })
579
+ );
580
+
581
+ if (!ok) return null;
582
+
583
+ const stats = {
584
+ date: cohortDate,
585
+ transactionCount: transactions.length,
586
+ totalValue: 0,
587
+ byOperation: { set: 0, add: 0, sub: 0 },
588
+ byOriginalId: {}
589
+ };
590
+
591
+ for (const txn of transactions) {
592
+ stats.totalValue += txn.value || 0;
593
+ stats.byOperation[txn.operation] = (stats.byOperation[txn.operation] || 0) + 1;
594
+
595
+ if (!stats.byOriginalId[txn.originalId]) {
596
+ stats.byOriginalId[txn.originalId] = {
597
+ count: 0,
598
+ value: 0
599
+ };
600
+ }
601
+ stats.byOriginalId[txn.originalId].count++;
602
+ stats.byOriginalId[txn.originalId].value += txn.value || 0;
603
+ }
604
+
605
+ return stats;
606
+ }
607
+ }
608
+
609
+ export default EventualConsistencyPlugin;
@@ -7,6 +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
11
  export * from './fulltext.plugin.js'
11
12
  export * from './metrics.plugin.js'
12
13
  export * from './queue-consumer.plugin.js'