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.
Files changed (82) hide show
  1. package/README.md +102 -8
  2. package/dist/s3db.cjs.js +36664 -15480
  3. package/dist/s3db.cjs.js.map +1 -1
  4. package/dist/s3db.d.ts +57 -0
  5. package/dist/s3db.es.js +36661 -15531
  6. package/dist/s3db.es.js.map +1 -1
  7. package/mcp/entrypoint.js +58 -0
  8. package/mcp/tools/documentation.js +434 -0
  9. package/mcp/tools/index.js +4 -0
  10. package/package.json +27 -6
  11. package/src/behaviors/user-managed.js +13 -6
  12. package/src/client.class.js +41 -46
  13. package/src/concerns/base62.js +85 -0
  14. package/src/concerns/dictionary-encoding.js +294 -0
  15. package/src/concerns/geo-encoding.js +256 -0
  16. package/src/concerns/high-performance-inserter.js +34 -30
  17. package/src/concerns/ip.js +325 -0
  18. package/src/concerns/metadata-encoding.js +345 -66
  19. package/src/concerns/money.js +193 -0
  20. package/src/concerns/partition-queue.js +7 -4
  21. package/src/concerns/plugin-storage.js +39 -19
  22. package/src/database.class.js +76 -74
  23. package/src/errors.js +0 -4
  24. package/src/plugins/api/auth/api-key-auth.js +88 -0
  25. package/src/plugins/api/auth/basic-auth.js +154 -0
  26. package/src/plugins/api/auth/index.js +112 -0
  27. package/src/plugins/api/auth/jwt-auth.js +169 -0
  28. package/src/plugins/api/index.js +539 -0
  29. package/src/plugins/api/middlewares/index.js +15 -0
  30. package/src/plugins/api/middlewares/validator.js +185 -0
  31. package/src/plugins/api/routes/auth-routes.js +241 -0
  32. package/src/plugins/api/routes/resource-routes.js +304 -0
  33. package/src/plugins/api/server.js +350 -0
  34. package/src/plugins/api/utils/error-handler.js +147 -0
  35. package/src/plugins/api/utils/openapi-generator.js +1240 -0
  36. package/src/plugins/api/utils/response-formatter.js +218 -0
  37. package/src/plugins/backup/streaming-exporter.js +132 -0
  38. package/src/plugins/backup.plugin.js +103 -50
  39. package/src/plugins/cache/s3-cache.class.js +95 -47
  40. package/src/plugins/cache.plugin.js +107 -9
  41. package/src/plugins/concerns/plugin-dependencies.js +313 -0
  42. package/src/plugins/concerns/prometheus-formatter.js +255 -0
  43. package/src/plugins/consumers/rabbitmq-consumer.js +4 -0
  44. package/src/plugins/consumers/sqs-consumer.js +4 -0
  45. package/src/plugins/costs.plugin.js +255 -39
  46. package/src/plugins/eventual-consistency/helpers.js +15 -1
  47. package/src/plugins/geo.plugin.js +873 -0
  48. package/src/plugins/importer/index.js +1020 -0
  49. package/src/plugins/index.js +11 -0
  50. package/src/plugins/metrics.plugin.js +163 -4
  51. package/src/plugins/queue-consumer.plugin.js +6 -27
  52. package/src/plugins/relation.errors.js +139 -0
  53. package/src/plugins/relation.plugin.js +1242 -0
  54. package/src/plugins/replicators/bigquery-replicator.class.js +180 -8
  55. package/src/plugins/replicators/dynamodb-replicator.class.js +383 -0
  56. package/src/plugins/replicators/index.js +28 -3
  57. package/src/plugins/replicators/mongodb-replicator.class.js +391 -0
  58. package/src/plugins/replicators/mysql-replicator.class.js +558 -0
  59. package/src/plugins/replicators/planetscale-replicator.class.js +409 -0
  60. package/src/plugins/replicators/postgres-replicator.class.js +182 -7
  61. package/src/plugins/replicators/s3db-replicator.class.js +1 -12
  62. package/src/plugins/replicators/schema-sync.helper.js +601 -0
  63. package/src/plugins/replicators/sqs-replicator.class.js +11 -9
  64. package/src/plugins/replicators/turso-replicator.class.js +416 -0
  65. package/src/plugins/replicators/webhook-replicator.class.js +612 -0
  66. package/src/plugins/state-machine.plugin.js +122 -68
  67. package/src/plugins/tfstate/README.md +745 -0
  68. package/src/plugins/tfstate/base-driver.js +80 -0
  69. package/src/plugins/tfstate/errors.js +112 -0
  70. package/src/plugins/tfstate/filesystem-driver.js +129 -0
  71. package/src/plugins/tfstate/index.js +2660 -0
  72. package/src/plugins/tfstate/s3-driver.js +192 -0
  73. package/src/plugins/ttl.plugin.js +536 -0
  74. package/src/resource.class.js +14 -10
  75. package/src/s3db.d.ts +57 -0
  76. package/src/schema.class.js +366 -32
  77. package/SECURITY.md +0 -76
  78. package/src/partition-drivers/base-partition-driver.js +0 -106
  79. package/src/partition-drivers/index.js +0 -66
  80. package/src/partition-drivers/memory-partition-driver.js +0 -289
  81. package/src/partition-drivers/sqs-partition-driver.js +0 -337
  82. 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
- // Prefer transform, fallback to transformer for backwards compatibility
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