s3db.js 11.3.2 → 12.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 +102 -8
- package/dist/s3db.cjs.js +36664 -15480
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.d.ts +57 -0
- package/dist/s3db.es.js +36661 -15531
- package/dist/s3db.es.js.map +1 -1
- package/mcp/entrypoint.js +58 -0
- package/mcp/tools/documentation.js +434 -0
- package/mcp/tools/index.js +4 -0
- package/package.json +27 -6
- package/src/behaviors/user-managed.js +13 -6
- package/src/client.class.js +41 -46
- package/src/concerns/base62.js +85 -0
- package/src/concerns/dictionary-encoding.js +294 -0
- package/src/concerns/geo-encoding.js +256 -0
- package/src/concerns/high-performance-inserter.js +34 -30
- package/src/concerns/ip.js +325 -0
- package/src/concerns/metadata-encoding.js +345 -66
- package/src/concerns/money.js +193 -0
- package/src/concerns/partition-queue.js +7 -4
- package/src/concerns/plugin-storage.js +39 -19
- package/src/database.class.js +76 -74
- package/src/errors.js +0 -4
- package/src/plugins/api/auth/api-key-auth.js +88 -0
- package/src/plugins/api/auth/basic-auth.js +154 -0
- package/src/plugins/api/auth/index.js +112 -0
- package/src/plugins/api/auth/jwt-auth.js +169 -0
- package/src/plugins/api/index.js +539 -0
- package/src/plugins/api/middlewares/index.js +15 -0
- package/src/plugins/api/middlewares/validator.js +185 -0
- package/src/plugins/api/routes/auth-routes.js +241 -0
- package/src/plugins/api/routes/resource-routes.js +304 -0
- package/src/plugins/api/server.js +350 -0
- package/src/plugins/api/utils/error-handler.js +147 -0
- package/src/plugins/api/utils/openapi-generator.js +1240 -0
- package/src/plugins/api/utils/response-formatter.js +218 -0
- package/src/plugins/backup/streaming-exporter.js +132 -0
- package/src/plugins/backup.plugin.js +103 -50
- package/src/plugins/cache/s3-cache.class.js +95 -47
- package/src/plugins/cache.plugin.js +107 -9
- package/src/plugins/concerns/plugin-dependencies.js +313 -0
- package/src/plugins/concerns/prometheus-formatter.js +255 -0
- package/src/plugins/consumers/rabbitmq-consumer.js +4 -0
- package/src/plugins/consumers/sqs-consumer.js +4 -0
- package/src/plugins/costs.plugin.js +255 -39
- package/src/plugins/eventual-consistency/helpers.js +15 -1
- package/src/plugins/geo.plugin.js +873 -0
- package/src/plugins/importer/index.js +1020 -0
- package/src/plugins/index.js +11 -0
- package/src/plugins/metrics.plugin.js +163 -4
- package/src/plugins/queue-consumer.plugin.js +6 -27
- package/src/plugins/relation.errors.js +139 -0
- package/src/plugins/relation.plugin.js +1242 -0
- package/src/plugins/replicators/bigquery-replicator.class.js +180 -8
- package/src/plugins/replicators/dynamodb-replicator.class.js +383 -0
- package/src/plugins/replicators/index.js +28 -3
- package/src/plugins/replicators/mongodb-replicator.class.js +391 -0
- package/src/plugins/replicators/mysql-replicator.class.js +558 -0
- package/src/plugins/replicators/planetscale-replicator.class.js +409 -0
- package/src/plugins/replicators/postgres-replicator.class.js +182 -7
- package/src/plugins/replicators/s3db-replicator.class.js +1 -12
- package/src/plugins/replicators/schema-sync.helper.js +601 -0
- package/src/plugins/replicators/sqs-replicator.class.js +11 -9
- package/src/plugins/replicators/turso-replicator.class.js +416 -0
- package/src/plugins/replicators/webhook-replicator.class.js +612 -0
- package/src/plugins/state-machine.plugin.js +122 -68
- package/src/plugins/tfstate/README.md +745 -0
- package/src/plugins/tfstate/base-driver.js +80 -0
- package/src/plugins/tfstate/errors.js +112 -0
- package/src/plugins/tfstate/filesystem-driver.js +129 -0
- package/src/plugins/tfstate/index.js +2660 -0
- package/src/plugins/tfstate/s3-driver.js +192 -0
- package/src/plugins/ttl.plugin.js +536 -0
- package/src/resource.class.js +14 -10
- package/src/s3db.d.ts +57 -0
- package/src/schema.class.js +366 -32
- package/SECURITY.md +0 -76
- package/src/partition-drivers/base-partition-driver.js +0 -106
- package/src/partition-drivers/index.js +0 -66
- package/src/partition-drivers/memory-partition-driver.js +0 -289
- package/src/partition-drivers/sqs-partition-driver.js +0 -337
- package/src/partition-drivers/sync-partition-driver.js +0 -38
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
import tryFn from "#src/concerns/try-fn.js";
|
|
2
|
+
import BaseReplicator from './base-replicator.class.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Webhook Replicator - Send data changes to HTTP endpoints
|
|
6
|
+
*
|
|
7
|
+
* Sends database changes to webhook endpoints via HTTP POST requests.
|
|
8
|
+
* Supports multiple authentication methods, custom headers, retries, and transformers.
|
|
9
|
+
*
|
|
10
|
+
* Configuration:
|
|
11
|
+
* @param {string} url - Webhook endpoint URL (required)
|
|
12
|
+
* @param {string} method - HTTP method (default: 'POST')
|
|
13
|
+
* @param {Object} auth - Authentication configuration
|
|
14
|
+
* @param {string} auth.type - Auth type: 'bearer', 'basic', 'apikey'
|
|
15
|
+
* @param {string} auth.token - Bearer token
|
|
16
|
+
* @param {string} auth.username - Basic auth username
|
|
17
|
+
* @param {string} auth.password - Basic auth password
|
|
18
|
+
* @param {string} auth.header - API key header name
|
|
19
|
+
* @param {string} auth.value - API key value
|
|
20
|
+
* @param {Object} headers - Custom headers to send
|
|
21
|
+
* @param {number} timeout - Request timeout in ms (default: 5000)
|
|
22
|
+
* @param {number} retries - Number of retry attempts (default: 3)
|
|
23
|
+
* @param {number} retryDelay - Delay between retries in ms (default: 1000)
|
|
24
|
+
* @param {string} retryStrategy - 'fixed' or 'exponential' (default: 'exponential')
|
|
25
|
+
* @param {Array<number>} retryOnStatus - Status codes to retry (default: [429, 500, 502, 503, 504])
|
|
26
|
+
* @param {boolean} batch - Enable batch mode (default: false)
|
|
27
|
+
* @param {number} batchSize - Max records per batch request (default: 100)
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* // Bearer token authentication
|
|
31
|
+
* new WebhookReplicator({
|
|
32
|
+
* url: 'https://api.example.com/webhook',
|
|
33
|
+
* auth: {
|
|
34
|
+
* type: 'bearer',
|
|
35
|
+
* token: 'your-secret-token'
|
|
36
|
+
* },
|
|
37
|
+
* headers: {
|
|
38
|
+
* 'Content-Type': 'application/json',
|
|
39
|
+
* 'X-Custom-Header': 'value'
|
|
40
|
+
* },
|
|
41
|
+
* timeout: 10000,
|
|
42
|
+
* retries: 3
|
|
43
|
+
* }, ['users', 'orders'])
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* // Basic authentication
|
|
47
|
+
* new WebhookReplicator({
|
|
48
|
+
* url: 'https://api.example.com/webhook',
|
|
49
|
+
* auth: {
|
|
50
|
+
* type: 'basic',
|
|
51
|
+
* username: 'user',
|
|
52
|
+
* password: 'pass'
|
|
53
|
+
* }
|
|
54
|
+
* })
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* // API Key authentication
|
|
58
|
+
* new WebhookReplicator({
|
|
59
|
+
* url: 'https://api.example.com/webhook',
|
|
60
|
+
* auth: {
|
|
61
|
+
* type: 'apikey',
|
|
62
|
+
* header: 'X-API-Key',
|
|
63
|
+
* value: 'your-api-key'
|
|
64
|
+
* }
|
|
65
|
+
* })
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* // With resource transformers
|
|
69
|
+
* new WebhookReplicator({
|
|
70
|
+
* url: 'https://api.example.com/webhook',
|
|
71
|
+
* resources: {
|
|
72
|
+
* users: (data) => ({
|
|
73
|
+
* ...data,
|
|
74
|
+
* source: 's3db',
|
|
75
|
+
* transformedAt: new Date().toISOString()
|
|
76
|
+
* })
|
|
77
|
+
* }
|
|
78
|
+
* })
|
|
79
|
+
*/
|
|
80
|
+
class WebhookReplicator extends BaseReplicator {
|
|
81
|
+
constructor(config = {}, resources = [], client = null) {
|
|
82
|
+
super(config);
|
|
83
|
+
|
|
84
|
+
// Required
|
|
85
|
+
this.url = config.url;
|
|
86
|
+
if (!this.url) {
|
|
87
|
+
throw new Error('WebhookReplicator requires a "url" configuration');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// HTTP settings
|
|
91
|
+
this.method = (config.method || 'POST').toUpperCase();
|
|
92
|
+
this.headers = config.headers || {};
|
|
93
|
+
this.timeout = config.timeout || 5000;
|
|
94
|
+
|
|
95
|
+
// Retry settings
|
|
96
|
+
this.retries = config.retries ?? 3;
|
|
97
|
+
this.retryDelay = config.retryDelay || 1000;
|
|
98
|
+
this.retryStrategy = config.retryStrategy || 'exponential';
|
|
99
|
+
this.retryOnStatus = config.retryOnStatus || [429, 500, 502, 503, 504];
|
|
100
|
+
|
|
101
|
+
// Batch settings
|
|
102
|
+
this.batch = config.batch || false;
|
|
103
|
+
this.batchSize = config.batchSize || 100;
|
|
104
|
+
|
|
105
|
+
// Authentication
|
|
106
|
+
this.auth = config.auth || null;
|
|
107
|
+
|
|
108
|
+
// Resource configuration
|
|
109
|
+
if (Array.isArray(resources)) {
|
|
110
|
+
this.resources = {};
|
|
111
|
+
for (const resource of resources) {
|
|
112
|
+
if (typeof resource === 'string') {
|
|
113
|
+
this.resources[resource] = true;
|
|
114
|
+
} else if (typeof resource === 'object' && resource.name) {
|
|
115
|
+
this.resources[resource.name] = resource;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} else if (typeof resources === 'object') {
|
|
119
|
+
this.resources = resources;
|
|
120
|
+
} else {
|
|
121
|
+
this.resources = {};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Statistics
|
|
125
|
+
this.stats = {
|
|
126
|
+
totalRequests: 0,
|
|
127
|
+
successfulRequests: 0,
|
|
128
|
+
failedRequests: 0,
|
|
129
|
+
retriedRequests: 0,
|
|
130
|
+
totalRetries: 0
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
validateConfig() {
|
|
135
|
+
const errors = [];
|
|
136
|
+
|
|
137
|
+
if (!this.url) {
|
|
138
|
+
errors.push('URL is required');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Validate URL format
|
|
142
|
+
try {
|
|
143
|
+
new URL(this.url);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
errors.push(`Invalid URL format: ${this.url}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Validate auth configuration
|
|
149
|
+
if (this.auth) {
|
|
150
|
+
if (!this.auth.type) {
|
|
151
|
+
errors.push('auth.type is required when auth is configured');
|
|
152
|
+
} else if (!['bearer', 'basic', 'apikey'].includes(this.auth.type)) {
|
|
153
|
+
errors.push('auth.type must be one of: bearer, basic, apikey');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (this.auth.type === 'bearer' && !this.auth.token) {
|
|
157
|
+
errors.push('auth.token is required for bearer authentication');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (this.auth.type === 'basic' && (!this.auth.username || !this.auth.password)) {
|
|
161
|
+
errors.push('auth.username and auth.password are required for basic authentication');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (this.auth.type === 'apikey' && (!this.auth.header || !this.auth.value)) {
|
|
165
|
+
errors.push('auth.header and auth.value are required for API key authentication');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
isValid: errors.length === 0,
|
|
171
|
+
errors
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Build headers with authentication
|
|
177
|
+
* @returns {Object} Headers object
|
|
178
|
+
*/
|
|
179
|
+
_buildHeaders() {
|
|
180
|
+
const headers = {
|
|
181
|
+
'Content-Type': 'application/json',
|
|
182
|
+
'User-Agent': 's3db-webhook-replicator',
|
|
183
|
+
...this.headers
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
if (this.auth) {
|
|
187
|
+
switch (this.auth.type) {
|
|
188
|
+
case 'bearer':
|
|
189
|
+
headers['Authorization'] = `Bearer ${this.auth.token}`;
|
|
190
|
+
break;
|
|
191
|
+
|
|
192
|
+
case 'basic':
|
|
193
|
+
const credentials = Buffer.from(`${this.auth.username}:${this.auth.password}`).toString('base64');
|
|
194
|
+
headers['Authorization'] = `Basic ${credentials}`;
|
|
195
|
+
break;
|
|
196
|
+
|
|
197
|
+
case 'apikey':
|
|
198
|
+
headers[this.auth.header] = this.auth.value;
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return headers;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Apply resource transformer if configured
|
|
208
|
+
* @param {string} resource - Resource name
|
|
209
|
+
* @param {Object} data - Data to transform
|
|
210
|
+
* @returns {Object} Transformed data
|
|
211
|
+
*/
|
|
212
|
+
_applyTransformer(resource, data) {
|
|
213
|
+
// Clean internal fields
|
|
214
|
+
let cleanData = this._cleanInternalFields(data);
|
|
215
|
+
|
|
216
|
+
const entry = this.resources[resource];
|
|
217
|
+
let result = cleanData;
|
|
218
|
+
|
|
219
|
+
if (!entry) return cleanData;
|
|
220
|
+
|
|
221
|
+
// Apply transform function if configured
|
|
222
|
+
if (typeof entry.transform === 'function') {
|
|
223
|
+
result = entry.transform(cleanData);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return result || cleanData;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Remove internal fields from data
|
|
231
|
+
* @param {Object} data - Data object
|
|
232
|
+
* @returns {Object} Cleaned data
|
|
233
|
+
*/
|
|
234
|
+
_cleanInternalFields(data) {
|
|
235
|
+
if (!data || typeof data !== 'object') return data;
|
|
236
|
+
|
|
237
|
+
const cleanData = { ...data };
|
|
238
|
+
|
|
239
|
+
// Remove fields starting with $ or _
|
|
240
|
+
Object.keys(cleanData).forEach(key => {
|
|
241
|
+
if (key.startsWith('$') || key.startsWith('_')) {
|
|
242
|
+
delete cleanData[key];
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
return cleanData;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Create standardized webhook payload
|
|
251
|
+
* @param {string} resource - Resource name
|
|
252
|
+
* @param {string} operation - Operation type
|
|
253
|
+
* @param {Object} data - Record data
|
|
254
|
+
* @param {string} id - Record ID
|
|
255
|
+
* @param {Object} beforeData - Before data (for updates)
|
|
256
|
+
* @returns {Object} Webhook payload
|
|
257
|
+
*/
|
|
258
|
+
createPayload(resource, operation, data, id, beforeData = null) {
|
|
259
|
+
const basePayload = {
|
|
260
|
+
resource: resource,
|
|
261
|
+
action: operation,
|
|
262
|
+
timestamp: new Date().toISOString(),
|
|
263
|
+
source: 's3db-webhook-replicator'
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
switch (operation) {
|
|
267
|
+
case 'insert':
|
|
268
|
+
return {
|
|
269
|
+
...basePayload,
|
|
270
|
+
data: data
|
|
271
|
+
};
|
|
272
|
+
case 'update':
|
|
273
|
+
return {
|
|
274
|
+
...basePayload,
|
|
275
|
+
before: beforeData,
|
|
276
|
+
data: data
|
|
277
|
+
};
|
|
278
|
+
case 'delete':
|
|
279
|
+
return {
|
|
280
|
+
...basePayload,
|
|
281
|
+
data: data
|
|
282
|
+
};
|
|
283
|
+
default:
|
|
284
|
+
return {
|
|
285
|
+
...basePayload,
|
|
286
|
+
data: data
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Make HTTP request with retries
|
|
293
|
+
* @param {Object} payload - Request payload
|
|
294
|
+
* @param {number} attempt - Current attempt number
|
|
295
|
+
* @returns {Promise<Object>} Response
|
|
296
|
+
*/
|
|
297
|
+
async _makeRequest(payload, attempt = 0) {
|
|
298
|
+
const controller = new AbortController();
|
|
299
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const response = await fetch(this.url, {
|
|
303
|
+
method: this.method,
|
|
304
|
+
headers: this._buildHeaders(),
|
|
305
|
+
body: JSON.stringify(payload),
|
|
306
|
+
signal: controller.signal
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
clearTimeout(timeoutId);
|
|
310
|
+
this.stats.totalRequests++;
|
|
311
|
+
|
|
312
|
+
// Check if response is OK
|
|
313
|
+
if (response.ok) {
|
|
314
|
+
this.stats.successfulRequests++;
|
|
315
|
+
return {
|
|
316
|
+
success: true,
|
|
317
|
+
status: response.status,
|
|
318
|
+
statusText: response.statusText
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Check if we should retry this status code
|
|
323
|
+
if (this.retryOnStatus.includes(response.status) && attempt < this.retries) {
|
|
324
|
+
this.stats.retriedRequests++;
|
|
325
|
+
this.stats.totalRetries++;
|
|
326
|
+
|
|
327
|
+
// Calculate retry delay
|
|
328
|
+
const delay = this.retryStrategy === 'exponential'
|
|
329
|
+
? this.retryDelay * Math.pow(2, attempt)
|
|
330
|
+
: this.retryDelay;
|
|
331
|
+
|
|
332
|
+
if (this.config.verbose) {
|
|
333
|
+
console.log(`[WebhookReplicator] Retrying request (attempt ${attempt + 1}/${this.retries}) after ${delay}ms - Status: ${response.status}`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
337
|
+
return this._makeRequest(payload, attempt + 1);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Failed without retry
|
|
341
|
+
this.stats.failedRequests++;
|
|
342
|
+
const errorText = await response.text().catch(() => '');
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
success: false,
|
|
346
|
+
status: response.status,
|
|
347
|
+
statusText: response.statusText,
|
|
348
|
+
error: errorText || `HTTP ${response.status}: ${response.statusText}`
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
} catch (error) {
|
|
352
|
+
clearTimeout(timeoutId);
|
|
353
|
+
|
|
354
|
+
// Retry on network errors
|
|
355
|
+
if (attempt < this.retries) {
|
|
356
|
+
this.stats.retriedRequests++;
|
|
357
|
+
this.stats.totalRetries++;
|
|
358
|
+
|
|
359
|
+
const delay = this.retryStrategy === 'exponential'
|
|
360
|
+
? this.retryDelay * Math.pow(2, attempt)
|
|
361
|
+
: this.retryDelay;
|
|
362
|
+
|
|
363
|
+
if (this.config.verbose) {
|
|
364
|
+
console.log(`[WebhookReplicator] Retrying request (attempt ${attempt + 1}/${this.retries}) after ${delay}ms - Error: ${error.message}`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
368
|
+
return this._makeRequest(payload, attempt + 1);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
this.stats.failedRequests++;
|
|
372
|
+
this.stats.totalRequests++;
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
success: false,
|
|
376
|
+
error: error.message
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async initialize(database) {
|
|
382
|
+
await super.initialize(database);
|
|
383
|
+
|
|
384
|
+
// Validate configuration
|
|
385
|
+
const validation = this.validateConfig();
|
|
386
|
+
if (!validation.isValid) {
|
|
387
|
+
const error = new Error(`WebhookReplicator configuration is invalid: ${validation.errors.join(', ')}`);
|
|
388
|
+
|
|
389
|
+
if (this.config.verbose) {
|
|
390
|
+
console.error(`[WebhookReplicator] ${error.message}`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
this.emit('initialization_error', {
|
|
394
|
+
replicator: this.name,
|
|
395
|
+
error: error.message,
|
|
396
|
+
errors: validation.errors
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
throw error;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
this.emit('initialized', {
|
|
403
|
+
replicator: this.name,
|
|
404
|
+
url: this.url,
|
|
405
|
+
method: this.method,
|
|
406
|
+
authType: this.auth?.type || 'none',
|
|
407
|
+
resources: Object.keys(this.resources || {})
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async replicate(resource, operation, data, id, beforeData = null) {
|
|
412
|
+
if (this.enabled === false) {
|
|
413
|
+
return { skipped: true, reason: 'replicator_disabled' };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (!this.shouldReplicateResource(resource)) {
|
|
417
|
+
return { skipped: true, reason: 'resource_not_included' };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const [ok, err, result] = await tryFn(async () => {
|
|
421
|
+
// Apply transformation
|
|
422
|
+
const transformedData = this._applyTransformer(resource, data);
|
|
423
|
+
|
|
424
|
+
// Create payload
|
|
425
|
+
const payload = this.createPayload(resource, operation, transformedData, id, beforeData);
|
|
426
|
+
|
|
427
|
+
// Make request
|
|
428
|
+
const response = await this._makeRequest(payload);
|
|
429
|
+
|
|
430
|
+
if (response.success) {
|
|
431
|
+
this.emit('replicated', {
|
|
432
|
+
replicator: this.name,
|
|
433
|
+
resource,
|
|
434
|
+
operation,
|
|
435
|
+
id,
|
|
436
|
+
url: this.url,
|
|
437
|
+
status: response.status,
|
|
438
|
+
success: true
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
return { success: true, status: response.status };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
throw new Error(response.error || `HTTP ${response.status}: ${response.statusText}`);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
if (ok) return result;
|
|
448
|
+
|
|
449
|
+
if (this.config.verbose) {
|
|
450
|
+
console.warn(`[WebhookReplicator] Replication failed for ${resource}: ${err.message}`);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
this.emit('replicator_error', {
|
|
454
|
+
replicator: this.name,
|
|
455
|
+
resource,
|
|
456
|
+
operation,
|
|
457
|
+
id,
|
|
458
|
+
error: err.message
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
return { success: false, error: err.message };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async replicateBatch(resource, records) {
|
|
465
|
+
if (this.enabled === false) {
|
|
466
|
+
return { skipped: true, reason: 'replicator_disabled' };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (!this.shouldReplicateResource(resource)) {
|
|
470
|
+
return { skipped: true, reason: 'resource_not_included' };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const [ok, err, result] = await tryFn(async () => {
|
|
474
|
+
// If batch mode is enabled, send all records in one request
|
|
475
|
+
if (this.batch) {
|
|
476
|
+
const payloads = records.map(record =>
|
|
477
|
+
this.createPayload(
|
|
478
|
+
resource,
|
|
479
|
+
record.operation,
|
|
480
|
+
this._applyTransformer(resource, record.data),
|
|
481
|
+
record.id,
|
|
482
|
+
record.beforeData
|
|
483
|
+
)
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
const response = await this._makeRequest({ batch: payloads });
|
|
487
|
+
|
|
488
|
+
if (response.success) {
|
|
489
|
+
this.emit('batch_replicated', {
|
|
490
|
+
replicator: this.name,
|
|
491
|
+
resource,
|
|
492
|
+
url: this.url,
|
|
493
|
+
total: records.length,
|
|
494
|
+
successful: records.length,
|
|
495
|
+
errors: 0,
|
|
496
|
+
status: response.status
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
success: true,
|
|
501
|
+
total: records.length,
|
|
502
|
+
successful: records.length,
|
|
503
|
+
errors: 0,
|
|
504
|
+
status: response.status
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
throw new Error(response.error || `HTTP ${response.status}: ${response.statusText}`);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Otherwise, send individual requests (parallel)
|
|
512
|
+
const results = await Promise.allSettled(
|
|
513
|
+
records.map(record =>
|
|
514
|
+
this.replicate(resource, record.operation, record.data, record.id, record.beforeData)
|
|
515
|
+
)
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
const successful = results.filter(r => r.status === 'fulfilled' && r.value.success).length;
|
|
519
|
+
const failed = results.length - successful;
|
|
520
|
+
|
|
521
|
+
this.emit('batch_replicated', {
|
|
522
|
+
replicator: this.name,
|
|
523
|
+
resource,
|
|
524
|
+
url: this.url,
|
|
525
|
+
total: records.length,
|
|
526
|
+
successful,
|
|
527
|
+
errors: failed
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
success: failed === 0,
|
|
532
|
+
total: records.length,
|
|
533
|
+
successful,
|
|
534
|
+
errors: failed,
|
|
535
|
+
results
|
|
536
|
+
};
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
if (ok) return result;
|
|
540
|
+
|
|
541
|
+
if (this.config.verbose) {
|
|
542
|
+
console.warn(`[WebhookReplicator] Batch replication failed for ${resource}: ${err.message}`);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
this.emit('batch_replicator_error', {
|
|
546
|
+
replicator: this.name,
|
|
547
|
+
resource,
|
|
548
|
+
error: err.message
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
return { success: false, error: err.message };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async testConnection() {
|
|
555
|
+
const [ok, err] = await tryFn(async () => {
|
|
556
|
+
const testPayload = {
|
|
557
|
+
test: true,
|
|
558
|
+
timestamp: new Date().toISOString(),
|
|
559
|
+
source: 's3db-webhook-replicator'
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
const response = await this._makeRequest(testPayload);
|
|
563
|
+
|
|
564
|
+
if (!response.success) {
|
|
565
|
+
throw new Error(response.error || `HTTP ${response.status}: ${response.statusText}`);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return true;
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
if (ok) return true;
|
|
572
|
+
|
|
573
|
+
if (this.config.verbose) {
|
|
574
|
+
console.warn(`[WebhookReplicator] Connection test failed: ${err.message}`);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
this.emit('connection_error', {
|
|
578
|
+
replicator: this.name,
|
|
579
|
+
error: err.message
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async getStatus() {
|
|
586
|
+
const baseStatus = await super.getStatus();
|
|
587
|
+
return {
|
|
588
|
+
...baseStatus,
|
|
589
|
+
url: this.url,
|
|
590
|
+
method: this.method,
|
|
591
|
+
authType: this.auth?.type || 'none',
|
|
592
|
+
timeout: this.timeout,
|
|
593
|
+
retries: this.retries,
|
|
594
|
+
retryStrategy: this.retryStrategy,
|
|
595
|
+
batchMode: this.batch,
|
|
596
|
+
resources: Object.keys(this.resources || {}),
|
|
597
|
+
stats: { ...this.stats }
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
shouldReplicateResource(resource) {
|
|
602
|
+
// If no resources configured, replicate all
|
|
603
|
+
if (!this.resources || Object.keys(this.resources).length === 0) {
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Check if resource is in the list
|
|
608
|
+
return Object.keys(this.resources).includes(resource);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export default WebhookReplicator;
|