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.
- package/README.md +71 -13
- package/dist/s3db.cjs.js +466 -8
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.es.js +466 -9
- package/dist/s3db.es.js.map +1 -1
- package/mcp/server.js +12 -8
- package/package.json +4 -4
- package/src/client.class.js +2 -2
- package/src/concerns/high-performance-inserter.js +285 -0
- package/src/concerns/partition-queue.js +171 -0
- package/src/errors.js +10 -2
- package/src/partition-drivers/base-partition-driver.js +96 -0
- package/src/partition-drivers/index.js +60 -0
- package/src/partition-drivers/memory-partition-driver.js +274 -0
- package/src/partition-drivers/sqs-partition-driver.js +332 -0
- package/src/partition-drivers/sync-partition-driver.js +38 -0
- package/src/plugins/backup.plugin.js +1 -1
- package/src/plugins/backup.plugin.js.backup +1 -1
- package/src/plugins/eventual-consistency.plugin.js +609 -0
- package/src/plugins/index.js +1 -0
- package/PLUGINS.md +0 -5036
|
@@ -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 || '
|
|
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;
|
package/src/plugins/index.js
CHANGED
|
@@ -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'
|