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.
- package/dist/s3db.cjs.js +1758 -1549
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.es.js +1758 -1549
- package/dist/s3db.es.js.map +1 -1
- package/package.json +1 -1
- package/src/plugins/eventual-consistency/analytics.js +668 -0
- package/src/plugins/eventual-consistency/config.js +120 -0
- package/src/plugins/eventual-consistency/consolidation.js +770 -0
- package/src/plugins/eventual-consistency/garbage-collection.js +126 -0
- package/src/plugins/eventual-consistency/helpers.js +179 -0
- package/src/plugins/eventual-consistency/index.js +455 -0
- package/src/plugins/eventual-consistency/locks.js +77 -0
- package/src/plugins/eventual-consistency/partitions.js +45 -0
- package/src/plugins/eventual-consistency/setup.js +298 -0
- package/src/plugins/eventual-consistency/transactions.js +119 -0
- package/src/plugins/eventual-consistency/utils.js +182 -0
- package/src/plugins/eventual-consistency.plugin.js +195 -2
- package/src/plugins/index.js +1 -1
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup logic for EventualConsistencyPlugin
|
|
3
|
+
* @module eventual-consistency/setup
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import tryFn from "../../concerns/try-fn.js";
|
|
7
|
+
import { createPartitionConfig } from "./partitions.js";
|
|
8
|
+
import { addHelperMethods } from "./helpers.js";
|
|
9
|
+
import { flushPendingTransactions } from "./transactions.js";
|
|
10
|
+
import { startConsolidationTimer } from "./consolidation.js";
|
|
11
|
+
import { startGarbageCollectionTimer } from "./garbage-collection.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Setup plugin for all configured resources
|
|
15
|
+
*
|
|
16
|
+
* @param {Object} database - Database instance
|
|
17
|
+
* @param {Map} fieldHandlers - Field handlers map
|
|
18
|
+
* @param {Function} completeFieldSetupFn - Function to complete setup for a field
|
|
19
|
+
* @param {Function} watchForResourceFn - Function to watch for resource creation
|
|
20
|
+
*/
|
|
21
|
+
export async function onSetup(database, fieldHandlers, completeFieldSetupFn, watchForResourceFn) {
|
|
22
|
+
// Iterate over all resource/field combinations
|
|
23
|
+
for (const [resourceName, resourceHandlers] of fieldHandlers) {
|
|
24
|
+
const targetResource = database.resources[resourceName];
|
|
25
|
+
|
|
26
|
+
if (!targetResource) {
|
|
27
|
+
// Resource doesn't exist yet - mark for deferred setup
|
|
28
|
+
for (const handler of resourceHandlers.values()) {
|
|
29
|
+
handler.deferredSetup = true;
|
|
30
|
+
}
|
|
31
|
+
// Watch for this resource to be created
|
|
32
|
+
watchForResourceFn(resourceName);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Resource exists - setup all fields for this resource
|
|
37
|
+
for (const [fieldName, handler] of resourceHandlers) {
|
|
38
|
+
handler.targetResource = targetResource;
|
|
39
|
+
await completeFieldSetupFn(handler);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Watch for a specific resource creation
|
|
46
|
+
*
|
|
47
|
+
* @param {string} resourceName - Resource name to watch for
|
|
48
|
+
* @param {Object} database - Database instance
|
|
49
|
+
* @param {Map} fieldHandlers - Field handlers map
|
|
50
|
+
* @param {Function} completeFieldSetupFn - Function to complete setup for a field
|
|
51
|
+
*/
|
|
52
|
+
export function watchForResource(resourceName, database, fieldHandlers, completeFieldSetupFn) {
|
|
53
|
+
const hookCallback = async ({ resource, config }) => {
|
|
54
|
+
if (config.name === resourceName) {
|
|
55
|
+
const resourceHandlers = fieldHandlers.get(resourceName);
|
|
56
|
+
if (!resourceHandlers) return;
|
|
57
|
+
|
|
58
|
+
// Setup all fields for this resource
|
|
59
|
+
for (const [fieldName, handler] of resourceHandlers) {
|
|
60
|
+
if (handler.deferredSetup) {
|
|
61
|
+
handler.targetResource = resource;
|
|
62
|
+
handler.deferredSetup = false;
|
|
63
|
+
await completeFieldSetupFn(handler);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
database.addHook('afterCreateResource', hookCallback);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Complete setup for a single field handler
|
|
74
|
+
*
|
|
75
|
+
* @param {Object} handler - Field handler
|
|
76
|
+
* @param {Object} database - Database instance
|
|
77
|
+
* @param {Object} config - Plugin configuration
|
|
78
|
+
* @param {Object} plugin - Plugin instance (for adding helper methods)
|
|
79
|
+
* @returns {Promise<void>}
|
|
80
|
+
*/
|
|
81
|
+
export async function completeFieldSetup(handler, database, config, plugin) {
|
|
82
|
+
if (!handler.targetResource) return;
|
|
83
|
+
|
|
84
|
+
const resourceName = handler.resource;
|
|
85
|
+
const fieldName = handler.field;
|
|
86
|
+
|
|
87
|
+
// Create transaction resource with partitions
|
|
88
|
+
const transactionResourceName = `${resourceName}_transactions_${fieldName}`;
|
|
89
|
+
const partitionConfig = createPartitionConfig();
|
|
90
|
+
|
|
91
|
+
const [ok, err, transactionResource] = await tryFn(() =>
|
|
92
|
+
database.createResource({
|
|
93
|
+
name: transactionResourceName,
|
|
94
|
+
attributes: {
|
|
95
|
+
id: 'string|required',
|
|
96
|
+
originalId: 'string|required',
|
|
97
|
+
field: 'string|required',
|
|
98
|
+
value: 'number|required',
|
|
99
|
+
operation: 'string|required',
|
|
100
|
+
timestamp: 'string|required',
|
|
101
|
+
cohortDate: 'string|required',
|
|
102
|
+
cohortHour: 'string|required',
|
|
103
|
+
cohortMonth: 'string|optional',
|
|
104
|
+
source: 'string|optional',
|
|
105
|
+
applied: 'boolean|optional'
|
|
106
|
+
},
|
|
107
|
+
behavior: 'body-overflow',
|
|
108
|
+
timestamps: true,
|
|
109
|
+
partitions: partitionConfig,
|
|
110
|
+
asyncPartitions: true,
|
|
111
|
+
createdBy: 'EventualConsistencyPlugin'
|
|
112
|
+
})
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
if (!ok && !database.resources[transactionResourceName]) {
|
|
116
|
+
throw new Error(`Failed to create transaction resource for ${resourceName}.${fieldName}: ${err?.message}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
handler.transactionResource = ok ? transactionResource : database.resources[transactionResourceName];
|
|
120
|
+
|
|
121
|
+
// Create lock resource
|
|
122
|
+
const lockResourceName = `${resourceName}_consolidation_locks_${fieldName}`;
|
|
123
|
+
const [lockOk, lockErr, lockResource] = await tryFn(() =>
|
|
124
|
+
database.createResource({
|
|
125
|
+
name: lockResourceName,
|
|
126
|
+
attributes: {
|
|
127
|
+
id: 'string|required',
|
|
128
|
+
lockedAt: 'number|required',
|
|
129
|
+
workerId: 'string|optional'
|
|
130
|
+
},
|
|
131
|
+
behavior: 'body-only',
|
|
132
|
+
timestamps: false,
|
|
133
|
+
createdBy: 'EventualConsistencyPlugin'
|
|
134
|
+
})
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (!lockOk && !database.resources[lockResourceName]) {
|
|
138
|
+
throw new Error(`Failed to create lock resource for ${resourceName}.${fieldName}: ${lockErr?.message}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
handler.lockResource = lockOk ? lockResource : database.resources[lockResourceName];
|
|
142
|
+
|
|
143
|
+
// Create analytics resource if enabled
|
|
144
|
+
if (config.enableAnalytics) {
|
|
145
|
+
await createAnalyticsResource(handler, database, resourceName, fieldName);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Add helper methods to the target resource
|
|
149
|
+
addHelperMethodsForHandler(handler, plugin, config);
|
|
150
|
+
|
|
151
|
+
if (config.verbose) {
|
|
152
|
+
console.log(
|
|
153
|
+
`[EventualConsistency] ${resourceName}.${fieldName} - ` +
|
|
154
|
+
`Setup complete. Resources: ${transactionResourceName}, ${lockResourceName}` +
|
|
155
|
+
`${config.enableAnalytics ? `, ${resourceName}_analytics_${fieldName}` : ''}`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Create analytics resource for a field handler
|
|
162
|
+
*
|
|
163
|
+
* @param {Object} handler - Field handler
|
|
164
|
+
* @param {Object} database - Database instance
|
|
165
|
+
* @param {string} resourceName - Resource name
|
|
166
|
+
* @param {string} fieldName - Field name
|
|
167
|
+
* @returns {Promise<void>}
|
|
168
|
+
*/
|
|
169
|
+
async function createAnalyticsResource(handler, database, resourceName, fieldName) {
|
|
170
|
+
const analyticsResourceName = `${resourceName}_analytics_${fieldName}`;
|
|
171
|
+
|
|
172
|
+
const [ok, err, analyticsResource] = await tryFn(() =>
|
|
173
|
+
database.createResource({
|
|
174
|
+
name: analyticsResourceName,
|
|
175
|
+
attributes: {
|
|
176
|
+
id: 'string|required',
|
|
177
|
+
period: 'string|required',
|
|
178
|
+
cohort: 'string|required',
|
|
179
|
+
transactionCount: 'number|required',
|
|
180
|
+
totalValue: 'number|required',
|
|
181
|
+
avgValue: 'number|required',
|
|
182
|
+
minValue: 'number|required',
|
|
183
|
+
maxValue: 'number|required',
|
|
184
|
+
operations: 'object|optional',
|
|
185
|
+
recordCount: 'number|required',
|
|
186
|
+
consolidatedAt: 'string|required',
|
|
187
|
+
updatedAt: 'string|required'
|
|
188
|
+
},
|
|
189
|
+
behavior: 'body-overflow',
|
|
190
|
+
timestamps: false,
|
|
191
|
+
createdBy: 'EventualConsistencyPlugin'
|
|
192
|
+
})
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
if (!ok && !database.resources[analyticsResourceName]) {
|
|
196
|
+
throw new Error(`Failed to create analytics resource for ${resourceName}.${fieldName}: ${err?.message}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
handler.analyticsResource = ok ? analyticsResource : database.resources[analyticsResourceName];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Add helper methods to the target resource for a field handler
|
|
204
|
+
*
|
|
205
|
+
* @param {Object} handler - Field handler
|
|
206
|
+
* @param {Object} plugin - Plugin instance
|
|
207
|
+
* @param {Object} config - Plugin configuration
|
|
208
|
+
*/
|
|
209
|
+
function addHelperMethodsForHandler(handler, plugin, config) {
|
|
210
|
+
const resource = handler.targetResource;
|
|
211
|
+
const fieldName = handler.field;
|
|
212
|
+
|
|
213
|
+
// Store handler reference on the resource for later access
|
|
214
|
+
if (!resource._eventualConsistencyPlugins) {
|
|
215
|
+
resource._eventualConsistencyPlugins = {};
|
|
216
|
+
}
|
|
217
|
+
resource._eventualConsistencyPlugins[fieldName] = handler;
|
|
218
|
+
|
|
219
|
+
// Add helper methods if not already added
|
|
220
|
+
if (!resource.add) {
|
|
221
|
+
addHelperMethods(resource, plugin, config);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Start timers and emit events for all field handlers
|
|
227
|
+
*
|
|
228
|
+
* @param {Map} fieldHandlers - Field handlers map
|
|
229
|
+
* @param {Object} config - Plugin configuration
|
|
230
|
+
* @param {Function} runConsolidationFn - Function to run consolidation for a handler
|
|
231
|
+
* @param {Function} runGCFn - Function to run GC for a handler
|
|
232
|
+
* @param {Function} emitFn - Function to emit events
|
|
233
|
+
* @returns {Promise<void>}
|
|
234
|
+
*/
|
|
235
|
+
export async function onStart(fieldHandlers, config, runConsolidationFn, runGCFn, emitFn) {
|
|
236
|
+
// Start timers and emit events for all field handlers
|
|
237
|
+
for (const [resourceName, resourceHandlers] of fieldHandlers) {
|
|
238
|
+
for (const [fieldName, handler] of resourceHandlers) {
|
|
239
|
+
if (!handler.deferredSetup) {
|
|
240
|
+
// Start auto-consolidation timer if enabled
|
|
241
|
+
if (config.autoConsolidate && config.mode === 'async') {
|
|
242
|
+
startConsolidationTimer(handler, resourceName, fieldName, runConsolidationFn, config);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Start garbage collection timer
|
|
246
|
+
if (config.transactionRetention && config.transactionRetention > 0) {
|
|
247
|
+
startGarbageCollectionTimer(handler, resourceName, fieldName, runGCFn, config);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (emitFn) {
|
|
251
|
+
emitFn('eventual-consistency.started', {
|
|
252
|
+
resource: resourceName,
|
|
253
|
+
field: fieldName,
|
|
254
|
+
cohort: config.cohort
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Stop all timers and flush pending transactions
|
|
264
|
+
*
|
|
265
|
+
* @param {Map} fieldHandlers - Field handlers map
|
|
266
|
+
* @param {Function} emitFn - Function to emit events
|
|
267
|
+
* @returns {Promise<void>}
|
|
268
|
+
*/
|
|
269
|
+
export async function onStop(fieldHandlers, emitFn) {
|
|
270
|
+
// Stop all timers for all handlers
|
|
271
|
+
for (const [resourceName, resourceHandlers] of fieldHandlers) {
|
|
272
|
+
for (const [fieldName, handler] of resourceHandlers) {
|
|
273
|
+
// Stop consolidation timer
|
|
274
|
+
if (handler.consolidationTimer) {
|
|
275
|
+
clearInterval(handler.consolidationTimer);
|
|
276
|
+
handler.consolidationTimer = null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Stop garbage collection timer
|
|
280
|
+
if (handler.gcTimer) {
|
|
281
|
+
clearInterval(handler.gcTimer);
|
|
282
|
+
handler.gcTimer = null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Flush pending transactions
|
|
286
|
+
if (handler.pendingTransactions && handler.pendingTransactions.size > 0) {
|
|
287
|
+
await flushPendingTransactions(handler);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (emitFn) {
|
|
291
|
+
emitFn('eventual-consistency.stopped', {
|
|
292
|
+
resource: resourceName,
|
|
293
|
+
field: fieldName
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transaction management for EventualConsistencyPlugin
|
|
3
|
+
* @module eventual-consistency/transactions
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { idGenerator } from "../../concerns/id.js";
|
|
7
|
+
import { getCohortInfo } from "./utils.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create a transaction for a field handler
|
|
11
|
+
*
|
|
12
|
+
* @param {Object} handler - Field handler
|
|
13
|
+
* @param {Object} data - Transaction data
|
|
14
|
+
* @param {Object} config - Plugin configuration
|
|
15
|
+
* @returns {Promise<Object|null>} Created transaction or null if ignored
|
|
16
|
+
*/
|
|
17
|
+
export async function createTransaction(handler, data, config) {
|
|
18
|
+
const now = new Date();
|
|
19
|
+
const cohortInfo = getCohortInfo(now, config.cohort.timezone, config.verbose);
|
|
20
|
+
|
|
21
|
+
// Check for late arrivals (transaction older than watermark)
|
|
22
|
+
const watermarkMs = config.consolidationWindow * 60 * 60 * 1000;
|
|
23
|
+
const watermarkTime = now.getTime() - watermarkMs;
|
|
24
|
+
const cohortHourDate = new Date(cohortInfo.hour + ':00:00Z');
|
|
25
|
+
|
|
26
|
+
if (cohortHourDate.getTime() < watermarkTime) {
|
|
27
|
+
// Late arrival detected!
|
|
28
|
+
const hoursLate = Math.floor((now.getTime() - cohortHourDate.getTime()) / (60 * 60 * 1000));
|
|
29
|
+
|
|
30
|
+
if (config.lateArrivalStrategy === 'ignore') {
|
|
31
|
+
if (config.verbose) {
|
|
32
|
+
console.warn(
|
|
33
|
+
`[EventualConsistency] Late arrival ignored: transaction for ${cohortInfo.hour} ` +
|
|
34
|
+
`is ${hoursLate}h late (watermark: ${config.consolidationWindow}h)`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
} else if (config.lateArrivalStrategy === 'warn') {
|
|
39
|
+
console.warn(
|
|
40
|
+
`[EventualConsistency] Late arrival detected: transaction for ${cohortInfo.hour} ` +
|
|
41
|
+
`is ${hoursLate}h late (watermark: ${config.consolidationWindow}h). ` +
|
|
42
|
+
`Processing anyway, but consolidation may not pick it up.`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
// 'process' strategy: continue normally
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const transaction = {
|
|
49
|
+
id: idGenerator(),
|
|
50
|
+
originalId: data.originalId,
|
|
51
|
+
field: handler.field,
|
|
52
|
+
value: data.value || 0,
|
|
53
|
+
operation: data.operation || 'set',
|
|
54
|
+
timestamp: now.toISOString(),
|
|
55
|
+
cohortDate: cohortInfo.date,
|
|
56
|
+
cohortHour: cohortInfo.hour,
|
|
57
|
+
cohortMonth: cohortInfo.month,
|
|
58
|
+
source: data.source || 'unknown',
|
|
59
|
+
applied: false
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Batch transactions if configured
|
|
63
|
+
if (config.batchTransactions) {
|
|
64
|
+
handler.pendingTransactions.set(transaction.id, transaction);
|
|
65
|
+
|
|
66
|
+
if (config.verbose) {
|
|
67
|
+
console.log(
|
|
68
|
+
`[EventualConsistency] ${handler.resource}.${handler.field} - ` +
|
|
69
|
+
`Transaction batched: ${data.operation} ${data.value} for ${data.originalId} ` +
|
|
70
|
+
`(batch: ${handler.pendingTransactions.size}/${config.batchSize})`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Flush if batch size reached
|
|
75
|
+
if (handler.pendingTransactions.size >= config.batchSize) {
|
|
76
|
+
await flushPendingTransactions(handler);
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
await handler.transactionResource.insert(transaction);
|
|
80
|
+
|
|
81
|
+
if (config.verbose) {
|
|
82
|
+
console.log(
|
|
83
|
+
`[EventualConsistency] ${handler.resource}.${handler.field} - ` +
|
|
84
|
+
`Transaction created: ${data.operation} ${data.value} for ${data.originalId} ` +
|
|
85
|
+
`(cohort: ${cohortInfo.hour}, applied: false)`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return transaction;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Flush pending transactions for a handler
|
|
95
|
+
*
|
|
96
|
+
* @param {Object} handler - Field handler with pending transactions
|
|
97
|
+
* @throws {Error} If flush fails
|
|
98
|
+
*/
|
|
99
|
+
export async function flushPendingTransactions(handler) {
|
|
100
|
+
if (handler.pendingTransactions.size === 0) return;
|
|
101
|
+
|
|
102
|
+
const transactions = Array.from(handler.pendingTransactions.values());
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
// Insert all pending transactions in parallel
|
|
106
|
+
await Promise.all(
|
|
107
|
+
transactions.map(transaction =>
|
|
108
|
+
handler.transactionResource.insert(transaction)
|
|
109
|
+
)
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// Only clear after successful inserts (prevents data loss on crashes)
|
|
113
|
+
handler.pendingTransactions.clear();
|
|
114
|
+
} catch (error) {
|
|
115
|
+
// Keep pending transactions for retry on next flush
|
|
116
|
+
console.error('Failed to flush pending transactions:', error);
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions for EventualConsistencyPlugin
|
|
3
|
+
* @module eventual-consistency/utils
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Auto-detect timezone from environment or system
|
|
8
|
+
* @returns {string} Detected timezone (defaults to 'UTC')
|
|
9
|
+
*/
|
|
10
|
+
export function detectTimezone() {
|
|
11
|
+
// 1. Try TZ environment variable (common in Docker/K8s)
|
|
12
|
+
if (process.env.TZ) {
|
|
13
|
+
return process.env.TZ;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// 2. Try Intl API (works in Node.js and browsers)
|
|
17
|
+
try {
|
|
18
|
+
const systemTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
19
|
+
if (systemTimezone) {
|
|
20
|
+
return systemTimezone;
|
|
21
|
+
}
|
|
22
|
+
} catch (err) {
|
|
23
|
+
// Intl API not available or failed
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 3. Fallback to UTC
|
|
27
|
+
return 'UTC';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Get timezone offset in milliseconds
|
|
32
|
+
* @param {string} timezone - IANA timezone name
|
|
33
|
+
* @param {boolean} verbose - Whether to log warnings
|
|
34
|
+
* @returns {number} Offset in milliseconds
|
|
35
|
+
*/
|
|
36
|
+
export function getTimezoneOffset(timezone, verbose = false) {
|
|
37
|
+
// Try to calculate offset using Intl API (handles DST automatically)
|
|
38
|
+
try {
|
|
39
|
+
const now = new Date();
|
|
40
|
+
|
|
41
|
+
// Get UTC time
|
|
42
|
+
const utcDate = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' }));
|
|
43
|
+
|
|
44
|
+
// Get time in target timezone
|
|
45
|
+
const tzDate = new Date(now.toLocaleString('en-US', { timeZone: timezone }));
|
|
46
|
+
|
|
47
|
+
// Calculate offset in milliseconds
|
|
48
|
+
return tzDate.getTime() - utcDate.getTime();
|
|
49
|
+
} catch (err) {
|
|
50
|
+
// Intl API failed, fallback to manual offsets (without DST support)
|
|
51
|
+
const offsets = {
|
|
52
|
+
'UTC': 0,
|
|
53
|
+
'America/New_York': -5 * 3600000,
|
|
54
|
+
'America/Chicago': -6 * 3600000,
|
|
55
|
+
'America/Denver': -7 * 3600000,
|
|
56
|
+
'America/Los_Angeles': -8 * 3600000,
|
|
57
|
+
'America/Sao_Paulo': -3 * 3600000,
|
|
58
|
+
'Europe/London': 0,
|
|
59
|
+
'Europe/Paris': 1 * 3600000,
|
|
60
|
+
'Europe/Berlin': 1 * 3600000,
|
|
61
|
+
'Asia/Tokyo': 9 * 3600000,
|
|
62
|
+
'Asia/Shanghai': 8 * 3600000,
|
|
63
|
+
'Australia/Sydney': 10 * 3600000
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
if (verbose && !offsets[timezone]) {
|
|
67
|
+
console.warn(
|
|
68
|
+
`[EventualConsistency] Unknown timezone '${timezone}', using UTC. ` +
|
|
69
|
+
`Consider using a valid IANA timezone (e.g., 'America/New_York')`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return offsets[timezone] || 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get cohort information for a date
|
|
79
|
+
* @param {Date} date - Date to get cohort info for
|
|
80
|
+
* @param {string} timezone - IANA timezone name
|
|
81
|
+
* @param {boolean} verbose - Whether to log warnings
|
|
82
|
+
* @returns {Object} Cohort information (date, hour, month)
|
|
83
|
+
*/
|
|
84
|
+
export function getCohortInfo(date, timezone, verbose = false) {
|
|
85
|
+
// Simple timezone offset calculation
|
|
86
|
+
const offset = getTimezoneOffset(timezone, verbose);
|
|
87
|
+
const localDate = new Date(date.getTime() + offset);
|
|
88
|
+
|
|
89
|
+
const year = localDate.getFullYear();
|
|
90
|
+
const month = String(localDate.getMonth() + 1).padStart(2, '0');
|
|
91
|
+
const day = String(localDate.getDate()).padStart(2, '0');
|
|
92
|
+
const hour = String(localDate.getHours()).padStart(2, '0');
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
date: `${year}-${month}-${day}`,
|
|
96
|
+
hour: `${year}-${month}-${day}T${hour}`, // ISO-like format for hour partition
|
|
97
|
+
month: `${year}-${month}`
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Create synthetic 'set' transaction from current value
|
|
103
|
+
* @param {number} currentValue - Current value to create transaction for
|
|
104
|
+
* @returns {Object} Synthetic transaction object
|
|
105
|
+
*/
|
|
106
|
+
export function createSyntheticSetTransaction(currentValue) {
|
|
107
|
+
return {
|
|
108
|
+
id: '__synthetic__',
|
|
109
|
+
operation: 'set',
|
|
110
|
+
value: currentValue,
|
|
111
|
+
timestamp: new Date(0).toISOString(),
|
|
112
|
+
synthetic: true
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Create a field handler for a specific resource/field combination
|
|
118
|
+
* @param {string} resourceName - Resource name
|
|
119
|
+
* @param {string} fieldName - Field name
|
|
120
|
+
* @returns {Object} Field handler object
|
|
121
|
+
*/
|
|
122
|
+
export function createFieldHandler(resourceName, fieldName) {
|
|
123
|
+
return {
|
|
124
|
+
resource: resourceName,
|
|
125
|
+
field: fieldName,
|
|
126
|
+
transactionResource: null,
|
|
127
|
+
targetResource: null,
|
|
128
|
+
analyticsResource: null,
|
|
129
|
+
lockResource: null,
|
|
130
|
+
checkpointResource: null,
|
|
131
|
+
consolidationTimer: null,
|
|
132
|
+
gcTimer: null,
|
|
133
|
+
pendingTransactions: new Map(),
|
|
134
|
+
deferredSetup: false
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Resolve field and plugin from arguments
|
|
140
|
+
* @param {Object} resource - Resource object
|
|
141
|
+
* @param {string} field - Field name
|
|
142
|
+
* @param {*} value - Value (for error reporting)
|
|
143
|
+
* @returns {Object} Resolved field and plugin handler
|
|
144
|
+
* @throws {Error} If field or plugin not found
|
|
145
|
+
*/
|
|
146
|
+
export function resolveFieldAndPlugin(resource, field, value) {
|
|
147
|
+
if (!resource._eventualConsistencyPlugins) {
|
|
148
|
+
throw new Error(`No eventual consistency plugins configured for this resource`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const fieldPlugin = resource._eventualConsistencyPlugins[field];
|
|
152
|
+
|
|
153
|
+
if (!fieldPlugin) {
|
|
154
|
+
const availableFields = Object.keys(resource._eventualConsistencyPlugins).join(', ');
|
|
155
|
+
throw new Error(
|
|
156
|
+
`No eventual consistency plugin found for field "${field}". ` +
|
|
157
|
+
`Available fields: ${availableFields}`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { field, value, plugin: fieldPlugin };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Group transactions by cohort field
|
|
166
|
+
* @param {Array} transactions - Transactions to group
|
|
167
|
+
* @param {string} cohortField - Field to group by (e.g., 'cohortHour')
|
|
168
|
+
* @returns {Object} Grouped transactions
|
|
169
|
+
*/
|
|
170
|
+
export function groupByCohort(transactions, cohortField) {
|
|
171
|
+
const groups = {};
|
|
172
|
+
for (const txn of transactions) {
|
|
173
|
+
const cohort = txn[cohortField];
|
|
174
|
+
if (!cohort) continue;
|
|
175
|
+
|
|
176
|
+
if (!groups[cohort]) {
|
|
177
|
+
groups[cohort] = [];
|
|
178
|
+
}
|
|
179
|
+
groups[cohort].push(txn);
|
|
180
|
+
}
|
|
181
|
+
return groups;
|
|
182
|
+
}
|