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,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
+ }