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,409 @@
|
|
|
1
|
+
import tryFn from "#src/concerns/try-fn.js";
|
|
2
|
+
import requirePluginDependency from "#src/plugins/concerns/plugin-dependencies.js";
|
|
3
|
+
import BaseReplicator from './base-replicator.class.js';
|
|
4
|
+
import { ReplicationError } from '../replicator.errors.js';
|
|
5
|
+
import {
|
|
6
|
+
generateMySQLCreateTable,
|
|
7
|
+
getMySQLTableSchema,
|
|
8
|
+
generateMySQLAlterTable
|
|
9
|
+
} from './schema-sync.helper.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* PlanetScale Replicator - Replicate data to PlanetScale (MySQL serverless)
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ REQUIRED DEPENDENCY: You must install the PlanetScale client library:
|
|
15
|
+
* ```bash
|
|
16
|
+
* pnpm add @planetscale/database
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Configuration:
|
|
20
|
+
* @param {string} host - PlanetScale database host (required) - e.g., 'aws.connect.psdb.cloud'
|
|
21
|
+
* @param {string} username - Database username (required)
|
|
22
|
+
* @param {string} password - Database password (required)
|
|
23
|
+
* @param {Object} schemaSync - Schema synchronization configuration
|
|
24
|
+
* @param {boolean} schemaSync.enabled - Enable automatic schema management (default: false)
|
|
25
|
+
* @param {string} schemaSync.strategy - Sync strategy: 'alter' | 'drop-create' | 'validate-only' (default: 'alter')
|
|
26
|
+
* @param {string} schemaSync.onMismatch - Action on schema mismatch: 'error' | 'warn' | 'ignore' (default: 'error')
|
|
27
|
+
* @param {boolean} schemaSync.autoCreateTable - Auto-create table if not exists (default: true)
|
|
28
|
+
* @param {boolean} schemaSync.autoCreateColumns - Auto-add missing columns (default: true, only with strategy: 'alter')
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* new PlanetScaleReplicator({
|
|
32
|
+
* host: 'aws.connect.psdb.cloud',
|
|
33
|
+
* username: process.env.PLANETSCALE_USERNAME,
|
|
34
|
+
* password: process.env.PLANETSCALE_PASSWORD,
|
|
35
|
+
* schemaSync: {
|
|
36
|
+
* enabled: true,
|
|
37
|
+
* strategy: 'alter',
|
|
38
|
+
* onMismatch: 'error'
|
|
39
|
+
* }
|
|
40
|
+
* }, {
|
|
41
|
+
* users: [{ actions: ['insert', 'update'], table: 'users_table' }],
|
|
42
|
+
* orders: 'orders_table'
|
|
43
|
+
* })
|
|
44
|
+
*
|
|
45
|
+
* See docs/plugins/replicator.md for comprehensive configuration documentation.
|
|
46
|
+
*/
|
|
47
|
+
class PlanetScaleReplicator extends BaseReplicator {
|
|
48
|
+
constructor(config = {}, resources = {}) {
|
|
49
|
+
super(config);
|
|
50
|
+
this.host = config.host;
|
|
51
|
+
this.username = config.username;
|
|
52
|
+
this.password = config.password;
|
|
53
|
+
this.connection = null;
|
|
54
|
+
|
|
55
|
+
// Schema sync configuration
|
|
56
|
+
this.schemaSync = {
|
|
57
|
+
enabled: config.schemaSync?.enabled || false,
|
|
58
|
+
strategy: config.schemaSync?.strategy || 'alter',
|
|
59
|
+
onMismatch: config.schemaSync?.onMismatch || 'error',
|
|
60
|
+
autoCreateTable: config.schemaSync?.autoCreateTable !== false,
|
|
61
|
+
autoCreateColumns: config.schemaSync?.autoCreateColumns !== false
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Parse resources configuration
|
|
65
|
+
this.resources = this.parseResourcesConfig(resources);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
parseResourcesConfig(resources) {
|
|
69
|
+
const parsed = {};
|
|
70
|
+
|
|
71
|
+
for (const [resourceName, config] of Object.entries(resources)) {
|
|
72
|
+
if (typeof config === 'string') {
|
|
73
|
+
parsed[resourceName] = [{
|
|
74
|
+
table: config,
|
|
75
|
+
actions: ['insert']
|
|
76
|
+
}];
|
|
77
|
+
} else if (Array.isArray(config)) {
|
|
78
|
+
parsed[resourceName] = config.map(item => {
|
|
79
|
+
if (typeof item === 'string') {
|
|
80
|
+
return { table: item, actions: ['insert'] };
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
table: item.table,
|
|
84
|
+
actions: item.actions || ['insert']
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
} else if (typeof config === 'object') {
|
|
88
|
+
parsed[resourceName] = [{
|
|
89
|
+
table: config.table,
|
|
90
|
+
actions: config.actions || ['insert']
|
|
91
|
+
}];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return parsed;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
validateConfig() {
|
|
99
|
+
const errors = [];
|
|
100
|
+
if (!this.host) errors.push('Host is required');
|
|
101
|
+
if (!this.username) errors.push('Username is required');
|
|
102
|
+
if (!this.password) errors.push('Password is required');
|
|
103
|
+
if (Object.keys(this.resources).length === 0) {
|
|
104
|
+
errors.push('At least one resource must be configured');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const [resourceName, tables] of Object.entries(this.resources)) {
|
|
108
|
+
for (const tableConfig of tables) {
|
|
109
|
+
if (!tableConfig.table) {
|
|
110
|
+
errors.push(`Table name is required for resource '${resourceName}'`);
|
|
111
|
+
}
|
|
112
|
+
if (!Array.isArray(tableConfig.actions) || tableConfig.actions.length === 0) {
|
|
113
|
+
errors.push(`Actions array is required for resource '${resourceName}'`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { isValid: errors.length === 0, errors };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async initialize(database) {
|
|
122
|
+
await super.initialize(database);
|
|
123
|
+
|
|
124
|
+
// Validate plugin dependencies are installed
|
|
125
|
+
await requirePluginDependency('planetscale-replicator');
|
|
126
|
+
|
|
127
|
+
const [ok, err, sdk] = await tryFn(() => import('@planetscale/database'));
|
|
128
|
+
if (!ok) {
|
|
129
|
+
throw new ReplicationError('Failed to import PlanetScale SDK', {
|
|
130
|
+
operation: 'initialize',
|
|
131
|
+
replicatorClass: 'PlanetScaleReplicator',
|
|
132
|
+
original: err,
|
|
133
|
+
suggestion: 'Install @planetscale/database: pnpm add @planetscale/database'
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const { connect } = sdk;
|
|
138
|
+
this.connection = connect({
|
|
139
|
+
host: this.host,
|
|
140
|
+
username: this.username,
|
|
141
|
+
password: this.password
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Test connection
|
|
145
|
+
const [okTest, errTest] = await tryFn(async () => {
|
|
146
|
+
await this.connection.execute('SELECT 1');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (!okTest) {
|
|
150
|
+
throw new ReplicationError('Failed to connect to PlanetScale database', {
|
|
151
|
+
operation: 'initialize',
|
|
152
|
+
replicatorClass: 'PlanetScaleReplicator',
|
|
153
|
+
host: this.host,
|
|
154
|
+
original: errTest,
|
|
155
|
+
suggestion: 'Check PlanetScale credentials'
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Sync schemas if enabled
|
|
160
|
+
if (this.schemaSync.enabled) {
|
|
161
|
+
await this.syncSchemas(database);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
this.emit('connected', {
|
|
165
|
+
replicator: 'PlanetScaleReplicator',
|
|
166
|
+
host: this.host
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Sync table schemas based on S3DB resource definitions
|
|
172
|
+
*/
|
|
173
|
+
async syncSchemas(database) {
|
|
174
|
+
for (const [resourceName, tableConfigs] of Object.entries(this.resources)) {
|
|
175
|
+
const [okRes, errRes, resource] = await tryFn(async () => {
|
|
176
|
+
return await database.getResource(resourceName);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
if (!okRes) {
|
|
180
|
+
if (this.config.verbose) {
|
|
181
|
+
console.warn(`[PlanetScaleReplicator] Could not get resource ${resourceName} for schema sync: ${errRes.message}`);
|
|
182
|
+
}
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const attributes = resource.config.versions[resource.config.currentVersion]?.attributes || {};
|
|
187
|
+
|
|
188
|
+
for (const tableConfig of tableConfigs) {
|
|
189
|
+
const tableName = tableConfig.table;
|
|
190
|
+
|
|
191
|
+
const [okSync, errSync] = await tryFn(async () => {
|
|
192
|
+
await this.syncTableSchema(tableName, attributes);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
if (!okSync) {
|
|
196
|
+
const message = `Schema sync failed for table ${tableName}: ${errSync.message}`;
|
|
197
|
+
|
|
198
|
+
if (this.schemaSync.onMismatch === 'error') {
|
|
199
|
+
throw new Error(message);
|
|
200
|
+
} else if (this.schemaSync.onMismatch === 'warn') {
|
|
201
|
+
console.warn(`[PlanetScaleReplicator] ${message}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
this.emit('schema_sync_completed', {
|
|
208
|
+
replicator: this.name,
|
|
209
|
+
resources: Object.keys(this.resources)
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Sync a single table schema
|
|
215
|
+
*/
|
|
216
|
+
async syncTableSchema(tableName, attributes) {
|
|
217
|
+
// Check if table exists using PlanetScale execute
|
|
218
|
+
const existingSchema = await getMySQLTableSchema(this.connection, tableName);
|
|
219
|
+
|
|
220
|
+
if (!existingSchema) {
|
|
221
|
+
if (!this.schemaSync.autoCreateTable) {
|
|
222
|
+
throw new Error(`Table ${tableName} does not exist and autoCreateTable is disabled`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (this.schemaSync.strategy === 'validate-only') {
|
|
226
|
+
throw new Error(`Table ${tableName} does not exist (validate-only mode)`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Create table
|
|
230
|
+
const createSQL = generateMySQLCreateTable(tableName, attributes);
|
|
231
|
+
|
|
232
|
+
if (this.config.verbose) {
|
|
233
|
+
console.log(`[PlanetScaleReplicator] Creating table ${tableName}:\n${createSQL}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
await this.connection.execute(createSQL);
|
|
237
|
+
|
|
238
|
+
this.emit('table_created', {
|
|
239
|
+
replicator: this.name,
|
|
240
|
+
tableName,
|
|
241
|
+
attributes: Object.keys(attributes)
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Table exists - check for schema changes
|
|
248
|
+
if (this.schemaSync.strategy === 'drop-create') {
|
|
249
|
+
if (this.config.verbose) {
|
|
250
|
+
console.warn(`[PlanetScaleReplicator] Dropping and recreating table ${tableName}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
await this.connection.execute(`DROP TABLE IF EXISTS ${tableName}`);
|
|
254
|
+
const createSQL = generateMySQLCreateTable(tableName, attributes);
|
|
255
|
+
await this.connection.execute(createSQL);
|
|
256
|
+
|
|
257
|
+
this.emit('table_recreated', {
|
|
258
|
+
replicator: this.name,
|
|
259
|
+
tableName,
|
|
260
|
+
attributes: Object.keys(attributes)
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (this.schemaSync.strategy === 'alter' && this.schemaSync.autoCreateColumns) {
|
|
267
|
+
const alterStatements = generateMySQLAlterTable(tableName, attributes, existingSchema);
|
|
268
|
+
|
|
269
|
+
if (alterStatements.length > 0) {
|
|
270
|
+
if (this.config.verbose) {
|
|
271
|
+
console.log(`[PlanetScaleReplicator] Altering table ${tableName}:`, alterStatements);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
for (const stmt of alterStatements) {
|
|
275
|
+
await this.connection.execute(stmt);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
this.emit('table_altered', {
|
|
279
|
+
replicator: this.name,
|
|
280
|
+
tableName,
|
|
281
|
+
addedColumns: alterStatements.length
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (this.schemaSync.strategy === 'validate-only') {
|
|
287
|
+
const alterStatements = generateMySQLAlterTable(tableName, attributes, existingSchema);
|
|
288
|
+
|
|
289
|
+
if (alterStatements.length > 0) {
|
|
290
|
+
throw new Error(`Table ${tableName} schema mismatch. Missing columns: ${alterStatements.length}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
shouldReplicateResource(resourceName) {
|
|
296
|
+
return this.resources.hasOwnProperty(resourceName);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
shouldReplicateAction(resourceName, operation) {
|
|
300
|
+
if (!this.resources[resourceName]) return false;
|
|
301
|
+
|
|
302
|
+
return this.resources[resourceName].some(tableConfig =>
|
|
303
|
+
tableConfig.actions.includes(operation)
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
getTablesForResource(resourceName, operation) {
|
|
308
|
+
if (!this.resources[resourceName]) return [];
|
|
309
|
+
|
|
310
|
+
return this.resources[resourceName]
|
|
311
|
+
.filter(tableConfig => tableConfig.actions.includes(operation))
|
|
312
|
+
.map(tableConfig => tableConfig.table);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async replicate(resourceName, operation, data, id, beforeData = null) {
|
|
316
|
+
if (!this.enabled || !this.shouldReplicateResource(resourceName)) {
|
|
317
|
+
return { skipped: true, reason: 'resource_not_included' };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (!this.shouldReplicateAction(resourceName, operation)) {
|
|
321
|
+
return { skipped: true, reason: 'action_not_included' };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const tables = this.getTablesForResource(resourceName, operation);
|
|
325
|
+
if (tables.length === 0) {
|
|
326
|
+
return { skipped: true, reason: 'no_tables_for_action' };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const results = [];
|
|
330
|
+
const errors = [];
|
|
331
|
+
|
|
332
|
+
for (const table of tables) {
|
|
333
|
+
const [okTable, errTable] = await tryFn(async () => {
|
|
334
|
+
if (operation === 'insert') {
|
|
335
|
+
const cleanData = this._cleanInternalFields(data);
|
|
336
|
+
const keys = Object.keys(cleanData);
|
|
337
|
+
const values = keys.map(k => cleanData[k]);
|
|
338
|
+
const placeholders = keys.map(() => '?').join(', ');
|
|
339
|
+
const sql = `INSERT INTO ${table} (${keys.map(k => `\`${k}\``).join(', ')}) VALUES (${placeholders}) ON DUPLICATE KEY UPDATE id=id`;
|
|
340
|
+
await this.connection.execute(sql, values);
|
|
341
|
+
} else if (operation === 'update') {
|
|
342
|
+
const cleanData = this._cleanInternalFields(data);
|
|
343
|
+
const keys = Object.keys(cleanData).filter(k => k !== 'id');
|
|
344
|
+
const setClause = keys.map(k => `\`${k}\`=?`).join(', ');
|
|
345
|
+
const values = keys.map(k => cleanData[k]);
|
|
346
|
+
values.push(id);
|
|
347
|
+
const sql = `UPDATE ${table} SET ${setClause} WHERE id=?`;
|
|
348
|
+
await this.connection.execute(sql, values);
|
|
349
|
+
} else if (operation === 'delete') {
|
|
350
|
+
const sql = `DELETE FROM ${table} WHERE id=?`;
|
|
351
|
+
await this.connection.execute(sql, [id]);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
results.push({ table, success: true });
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
if (!okTable) {
|
|
358
|
+
errors.push({ table, error: errTable.message });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const success = errors.length === 0;
|
|
363
|
+
|
|
364
|
+
this.emit('replicated', {
|
|
365
|
+
replicator: this.name,
|
|
366
|
+
resourceName,
|
|
367
|
+
operation,
|
|
368
|
+
id,
|
|
369
|
+
tables,
|
|
370
|
+
results,
|
|
371
|
+
errors,
|
|
372
|
+
success
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
return { success, results, errors, tables };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
_cleanInternalFields(data) {
|
|
379
|
+
if (!data || typeof data !== 'object') return data;
|
|
380
|
+
|
|
381
|
+
const cleanData = { ...data };
|
|
382
|
+
|
|
383
|
+
Object.keys(cleanData).forEach(key => {
|
|
384
|
+
if (key.startsWith('$') || key.startsWith('_')) {
|
|
385
|
+
delete cleanData[key];
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
return cleanData;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async cleanup() {
|
|
393
|
+
// PlanetScale SDK doesn't need explicit cleanup
|
|
394
|
+
this.connection = null;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async getStatus() {
|
|
398
|
+
const baseStatus = await super.getStatus();
|
|
399
|
+
return {
|
|
400
|
+
...baseStatus,
|
|
401
|
+
connected: !!this.connection,
|
|
402
|
+
host: this.host,
|
|
403
|
+
resources: Object.keys(this.resources),
|
|
404
|
+
schemaSync: this.schemaSync
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export default PlanetScaleReplicator;
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import tryFn from "#src/concerns/try-fn.js";
|
|
2
|
+
import requirePluginDependency from "#src/plugins/concerns/plugin-dependencies.js";
|
|
2
3
|
import BaseReplicator from './base-replicator.class.js';
|
|
4
|
+
import {
|
|
5
|
+
generatePostgresCreateTable,
|
|
6
|
+
getPostgresTableSchema,
|
|
7
|
+
generatePostgresAlterTable
|
|
8
|
+
} from './schema-sync.helper.js';
|
|
3
9
|
|
|
4
10
|
/**
|
|
5
11
|
* PostgreSQL Replicator - Replicate data to PostgreSQL tables
|
|
6
|
-
*
|
|
12
|
+
*
|
|
7
13
|
* ⚠️ REQUIRED DEPENDENCY: You must install the PostgreSQL client library:
|
|
8
14
|
* ```bash
|
|
9
15
|
* pnpm add pg
|
|
10
16
|
* ```
|
|
11
|
-
*
|
|
17
|
+
*
|
|
12
18
|
* Configuration:
|
|
13
19
|
* @param {string} connectionString - PostgreSQL connection string (required)
|
|
14
20
|
* @param {string} host - Database host (alternative to connectionString)
|
|
@@ -18,16 +24,29 @@ import BaseReplicator from './base-replicator.class.js';
|
|
|
18
24
|
* @param {string} password - Database password
|
|
19
25
|
* @param {Object} ssl - SSL configuration (optional)
|
|
20
26
|
* @param {string} logTable - Table name for operation logging (optional)
|
|
21
|
-
*
|
|
27
|
+
* @param {Object} schemaSync - Schema synchronization configuration
|
|
28
|
+
* @param {boolean} schemaSync.enabled - Enable automatic schema management (default: false)
|
|
29
|
+
* @param {string} schemaSync.strategy - Sync strategy: 'alter' | 'drop-create' | 'validate-only' (default: 'alter')
|
|
30
|
+
* @param {string} schemaSync.onMismatch - Action on schema mismatch: 'error' | 'warn' | 'ignore' (default: 'error')
|
|
31
|
+
* @param {boolean} schemaSync.autoCreateTable - Auto-create table if not exists (default: true)
|
|
32
|
+
* @param {boolean} schemaSync.autoCreateColumns - Auto-add missing columns (default: true, only with strategy: 'alter')
|
|
33
|
+
* @param {boolean} schemaSync.dropMissingColumns - Remove extra columns (default: false, dangerous!)
|
|
34
|
+
*
|
|
22
35
|
* @example
|
|
23
36
|
* new PostgresReplicator({
|
|
24
37
|
* connectionString: 'postgresql://user:password@localhost:5432/analytics',
|
|
25
|
-
* logTable: 'replication_log'
|
|
38
|
+
* logTable: 'replication_log',
|
|
39
|
+
* schemaSync: {
|
|
40
|
+
* enabled: true,
|
|
41
|
+
* strategy: 'alter',
|
|
42
|
+
* onMismatch: 'error',
|
|
43
|
+
* autoCreateTable: true
|
|
44
|
+
* }
|
|
26
45
|
* }, {
|
|
27
46
|
* users: [{ actions: ['insert', 'update'], table: 'users_table' }],
|
|
28
47
|
* orders: 'orders_table'
|
|
29
48
|
* })
|
|
30
|
-
*
|
|
49
|
+
*
|
|
31
50
|
* See PLUGINS.md for comprehensive configuration documentation.
|
|
32
51
|
*/
|
|
33
52
|
class PostgresReplicator extends BaseReplicator {
|
|
@@ -42,7 +61,17 @@ class PostgresReplicator extends BaseReplicator {
|
|
|
42
61
|
this.client = null;
|
|
43
62
|
this.ssl = config.ssl;
|
|
44
63
|
this.logTable = config.logTable;
|
|
45
|
-
|
|
64
|
+
|
|
65
|
+
// Schema sync configuration
|
|
66
|
+
this.schemaSync = {
|
|
67
|
+
enabled: config.schemaSync?.enabled || false,
|
|
68
|
+
strategy: config.schemaSync?.strategy || 'alter',
|
|
69
|
+
onMismatch: config.schemaSync?.onMismatch || 'error',
|
|
70
|
+
autoCreateTable: config.schemaSync?.autoCreateTable !== false,
|
|
71
|
+
autoCreateColumns: config.schemaSync?.autoCreateColumns !== false,
|
|
72
|
+
dropMissingColumns: config.schemaSync?.dropMissingColumns || false
|
|
73
|
+
};
|
|
74
|
+
|
|
46
75
|
// Parse resources configuration
|
|
47
76
|
this.resources = this.parseResourcesConfig(resources);
|
|
48
77
|
}
|
|
@@ -111,6 +140,10 @@ class PostgresReplicator extends BaseReplicator {
|
|
|
111
140
|
|
|
112
141
|
async initialize(database) {
|
|
113
142
|
await super.initialize(database);
|
|
143
|
+
|
|
144
|
+
// Validate plugin dependencies are installed
|
|
145
|
+
await requirePluginDependency('postgresql-replicator');
|
|
146
|
+
|
|
114
147
|
const [ok, err, sdk] = await tryFn(() => import('pg'));
|
|
115
148
|
if (!ok) {
|
|
116
149
|
if (this.config.verbose) {
|
|
@@ -136,10 +169,17 @@ class PostgresReplicator extends BaseReplicator {
|
|
|
136
169
|
};
|
|
137
170
|
this.client = new Client(config);
|
|
138
171
|
await this.client.connect();
|
|
172
|
+
|
|
139
173
|
// Create log table if configured
|
|
140
174
|
if (this.logTable) {
|
|
141
175
|
await this.createLogTableIfNotExists();
|
|
142
176
|
}
|
|
177
|
+
|
|
178
|
+
// Sync schemas if enabled
|
|
179
|
+
if (this.schemaSync.enabled) {
|
|
180
|
+
await this.syncSchemas(database);
|
|
181
|
+
}
|
|
182
|
+
|
|
143
183
|
this.emit('initialized', {
|
|
144
184
|
replicator: this.name,
|
|
145
185
|
database: this.database || 'postgres',
|
|
@@ -167,6 +207,139 @@ class PostgresReplicator extends BaseReplicator {
|
|
|
167
207
|
await this.client.query(createTableQuery);
|
|
168
208
|
}
|
|
169
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Sync table schemas based on S3DB resource definitions
|
|
212
|
+
*/
|
|
213
|
+
async syncSchemas(database) {
|
|
214
|
+
for (const [resourceName, tableConfigs] of Object.entries(this.resources)) {
|
|
215
|
+
// Get resource metadata from database
|
|
216
|
+
const [okRes, errRes, resource] = await tryFn(async () => {
|
|
217
|
+
return await database.getResource(resourceName);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
if (!okRes) {
|
|
221
|
+
if (this.config.verbose) {
|
|
222
|
+
console.warn(`[PostgresReplicator] Could not get resource ${resourceName} for schema sync: ${errRes.message}`);
|
|
223
|
+
}
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Get resource attributes from current version
|
|
228
|
+
const attributes = resource.config.versions[resource.config.currentVersion]?.attributes || {};
|
|
229
|
+
|
|
230
|
+
// Sync each table configured for this resource
|
|
231
|
+
for (const tableConfig of tableConfigs) {
|
|
232
|
+
const tableName = tableConfig.table;
|
|
233
|
+
|
|
234
|
+
const [okSync, errSync] = await tryFn(async () => {
|
|
235
|
+
await this.syncTableSchema(tableName, attributes);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (!okSync) {
|
|
239
|
+
const message = `Schema sync failed for table ${tableName}: ${errSync.message}`;
|
|
240
|
+
|
|
241
|
+
if (this.schemaSync.onMismatch === 'error') {
|
|
242
|
+
throw new Error(message);
|
|
243
|
+
} else if (this.schemaSync.onMismatch === 'warn') {
|
|
244
|
+
console.warn(`[PostgresReplicator] ${message}`);
|
|
245
|
+
}
|
|
246
|
+
// 'ignore' does nothing
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
this.emit('schema_sync_completed', {
|
|
252
|
+
replicator: this.name,
|
|
253
|
+
resources: Object.keys(this.resources)
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Sync a single table schema
|
|
259
|
+
*/
|
|
260
|
+
async syncTableSchema(tableName, attributes) {
|
|
261
|
+
// Check if table exists
|
|
262
|
+
const existingSchema = await getPostgresTableSchema(this.client, tableName);
|
|
263
|
+
|
|
264
|
+
if (!existingSchema) {
|
|
265
|
+
// Table doesn't exist
|
|
266
|
+
if (!this.schemaSync.autoCreateTable) {
|
|
267
|
+
throw new Error(`Table ${tableName} does not exist and autoCreateTable is disabled`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (this.schemaSync.strategy === 'validate-only') {
|
|
271
|
+
throw new Error(`Table ${tableName} does not exist (validate-only mode)`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Create table
|
|
275
|
+
const createSQL = generatePostgresCreateTable(tableName, attributes);
|
|
276
|
+
|
|
277
|
+
if (this.config.verbose) {
|
|
278
|
+
console.log(`[PostgresReplicator] Creating table ${tableName}:\n${createSQL}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
await this.client.query(createSQL);
|
|
282
|
+
|
|
283
|
+
this.emit('table_created', {
|
|
284
|
+
replicator: this.name,
|
|
285
|
+
tableName,
|
|
286
|
+
attributes: Object.keys(attributes)
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Table exists - check for schema changes
|
|
293
|
+
if (this.schemaSync.strategy === 'drop-create') {
|
|
294
|
+
// Drop and recreate table (DANGEROUS!)
|
|
295
|
+
if (this.config.verbose) {
|
|
296
|
+
console.warn(`[PostgresReplicator] Dropping and recreating table ${tableName}`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
await this.client.query(`DROP TABLE IF EXISTS ${tableName} CASCADE`);
|
|
300
|
+
const createSQL = generatePostgresCreateTable(tableName, attributes);
|
|
301
|
+
await this.client.query(createSQL);
|
|
302
|
+
|
|
303
|
+
this.emit('table_recreated', {
|
|
304
|
+
replicator: this.name,
|
|
305
|
+
tableName,
|
|
306
|
+
attributes: Object.keys(attributes)
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (this.schemaSync.strategy === 'alter' && this.schemaSync.autoCreateColumns) {
|
|
313
|
+
// Add missing columns
|
|
314
|
+
const alterStatements = generatePostgresAlterTable(tableName, attributes, existingSchema);
|
|
315
|
+
|
|
316
|
+
if (alterStatements.length > 0) {
|
|
317
|
+
if (this.config.verbose) {
|
|
318
|
+
console.log(`[PostgresReplicator] Altering table ${tableName}:`, alterStatements);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
for (const stmt of alterStatements) {
|
|
322
|
+
await this.client.query(stmt);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
this.emit('table_altered', {
|
|
326
|
+
replicator: this.name,
|
|
327
|
+
tableName,
|
|
328
|
+
addedColumns: alterStatements.length
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (this.schemaSync.strategy === 'validate-only') {
|
|
334
|
+
// Just validate, don't modify
|
|
335
|
+
const alterStatements = generatePostgresAlterTable(tableName, attributes, existingSchema);
|
|
336
|
+
|
|
337
|
+
if (alterStatements.length > 0) {
|
|
338
|
+
throw new Error(`Table ${tableName} schema mismatch. Missing columns: ${alterStatements.length}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
170
343
|
shouldReplicateResource(resourceName) {
|
|
171
344
|
return this.resources.hasOwnProperty(resourceName);
|
|
172
345
|
}
|
|
@@ -374,9 +547,11 @@ class PostgresReplicator extends BaseReplicator {
|
|
|
374
547
|
...super.getStatus(),
|
|
375
548
|
database: this.database || 'postgres',
|
|
376
549
|
resources: this.resources,
|
|
377
|
-
logTable: this.logTable
|
|
550
|
+
logTable: this.logTable,
|
|
551
|
+
schemaSync: this.schemaSync
|
|
378
552
|
};
|
|
379
553
|
}
|
|
380
554
|
}
|
|
381
555
|
|
|
556
|
+
|
|
382
557
|
export default PostgresReplicator;
|
|
@@ -232,12 +232,6 @@ class S3dbReplicator extends BaseReplicator {
|
|
|
232
232
|
if (transformedData && data && data.id && !transformedData.id) {
|
|
233
233
|
transformedData.id = data.id;
|
|
234
234
|
}
|
|
235
|
-
} else if (typeof destConfig === 'object' && destConfig.transformer && typeof destConfig.transformer === 'function') {
|
|
236
|
-
transformedData = destConfig.transformer(data);
|
|
237
|
-
// Ensure ID is preserved
|
|
238
|
-
if (transformedData && data && data.id && !transformedData.id) {
|
|
239
|
-
transformedData.id = data.id;
|
|
240
|
-
}
|
|
241
235
|
} else {
|
|
242
236
|
transformedData = data;
|
|
243
237
|
}
|
|
@@ -281,18 +275,13 @@ class S3dbReplicator extends BaseReplicator {
|
|
|
281
275
|
if (typeof item === 'object' && item.transform && typeof item.transform === 'function') {
|
|
282
276
|
result = item.transform(cleanData);
|
|
283
277
|
break;
|
|
284
|
-
} else if (typeof item === 'object' && item.transformer && typeof item.transformer === 'function') {
|
|
285
|
-
result = item.transformer(cleanData);
|
|
286
|
-
break;
|
|
287
278
|
}
|
|
288
279
|
}
|
|
289
280
|
if (!result) result = cleanData;
|
|
290
281
|
} else if (typeof entry === 'object') {
|
|
291
|
-
//
|
|
282
|
+
// Apply transform function if configured
|
|
292
283
|
if (typeof entry.transform === 'function') {
|
|
293
284
|
result = entry.transform(cleanData);
|
|
294
|
-
} else if (typeof entry.transformer === 'function') {
|
|
295
|
-
result = entry.transformer(cleanData);
|
|
296
285
|
}
|
|
297
286
|
} else if (typeof entry === 'function') {
|
|
298
287
|
// Function directly as transformer
|