s3db.js 12.2.4 → 12.4.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.
@@ -27,12 +27,17 @@ import {
27
27
  * @param {string} schemaSync.onMismatch - Action on schema mismatch: 'error' | 'warn' | 'ignore' (default: 'error')
28
28
  * @param {boolean} schemaSync.autoCreateTable - Auto-create table if not exists (default: true)
29
29
  * @param {boolean} schemaSync.autoCreateColumns - Auto-add missing columns (default: true, only with strategy: 'alter')
30
+ * @param {string} mutability - Global mutability mode: 'append-only' | 'mutable' | 'immutable' (default: 'append-only')
31
+ * - 'append-only': Updates/deletes become inserts with _operation_type and _operation_timestamp (most performant, no streaming buffer issues)
32
+ * - 'mutable': Traditional UPDATE/DELETE queries with streaming buffer retry logic
33
+ * - 'immutable': Full audit trail with _operation_type, _operation_timestamp, _is_deleted, _version fields
30
34
  *
31
35
  * @example
32
36
  * new BigqueryReplicator({
33
37
  * projectId: 'my-gcp-project',
34
38
  * datasetId: 'analytics',
35
39
  * credentials: JSON.parse(Buffer.from(GOOGLE_CREDENTIALS, 'base64').toString()),
40
+ * mutability: 'append-only', // Global default
36
41
  * schemaSync: {
37
42
  * enabled: true,
38
43
  * strategy: 'alter',
@@ -41,6 +46,7 @@ import {
41
46
  * }, {
42
47
  * users: {
43
48
  * table: 'users_table',
49
+ * mutability: 'immutable', // Override for audit trail
44
50
  * transform: (data) => ({ ...data, ip: data.ip || 'unknown' })
45
51
  * },
46
52
  * orders: 'orders_table'
@@ -58,6 +64,10 @@ class BigqueryReplicator extends BaseReplicator {
58
64
  this.location = config.location || 'US';
59
65
  this.logTable = config.logTable;
60
66
 
67
+ // Mutability configuration
68
+ this.mutability = config.mutability || 'append-only';
69
+ this._validateMutability(this.mutability);
70
+
61
71
  // Schema sync configuration
62
72
  this.schemaSync = {
63
73
  enabled: config.schemaSync?.enabled || false,
@@ -69,6 +79,16 @@ class BigqueryReplicator extends BaseReplicator {
69
79
 
70
80
  // Parse resources configuration
71
81
  this.resources = this.parseResourcesConfig(resources);
82
+
83
+ // Version tracking for immutable mode
84
+ this.versionCounters = new Map();
85
+ }
86
+
87
+ _validateMutability(mutability) {
88
+ const validModes = ['append-only', 'mutable', 'immutable'];
89
+ if (!validModes.includes(mutability)) {
90
+ throw new Error(`Invalid mutability mode: ${mutability}. Must be one of: ${validModes.join(', ')}`);
91
+ }
72
92
  }
73
93
 
74
94
  parseResourcesConfig(resources) {
@@ -80,26 +100,33 @@ class BigqueryReplicator extends BaseReplicator {
80
100
  parsed[resourceName] = [{
81
101
  table: config,
82
102
  actions: ['insert'],
83
- transform: null
103
+ transform: null,
104
+ mutability: this.mutability
84
105
  }];
85
106
  } else if (Array.isArray(config)) {
86
107
  // Array form: multiple table mappings
87
108
  parsed[resourceName] = config.map(item => {
88
109
  if (typeof item === 'string') {
89
- return { table: item, actions: ['insert'], transform: null };
110
+ return { table: item, actions: ['insert'], transform: null, mutability: this.mutability };
90
111
  }
112
+ const itemMutability = item.mutability || this.mutability;
113
+ this._validateMutability(itemMutability);
91
114
  return {
92
115
  table: item.table,
93
116
  actions: item.actions || ['insert'],
94
- transform: item.transform || null
117
+ transform: item.transform || null,
118
+ mutability: itemMutability
95
119
  };
96
120
  });
97
121
  } else if (typeof config === 'object') {
98
122
  // Single object form
123
+ const configMutability = config.mutability || this.mutability;
124
+ this._validateMutability(configMutability);
99
125
  parsed[resourceName] = [{
100
126
  table: config.table,
101
127
  actions: config.actions || ['insert'],
102
- transform: config.transform || null
128
+ transform: config.transform || null,
129
+ mutability: configMutability
103
130
  }];
104
131
  }
105
132
  }
@@ -186,13 +213,22 @@ class BigqueryReplicator extends BaseReplicator {
186
213
  continue;
187
214
  }
188
215
 
189
- const attributes = resource.config.versions[resource.config.currentVersion]?.attributes || {};
216
+ const allAttributes = resource.config.versions[resource.config.currentVersion]?.attributes || {};
217
+
218
+ // Filter out plugin attributes - they are internal and should not be replicated
219
+ const pluginAttrNames = resource.schema?._pluginAttributes
220
+ ? Object.values(resource.schema._pluginAttributes).flat()
221
+ : [];
222
+ const attributes = Object.fromEntries(
223
+ Object.entries(allAttributes).filter(([name]) => !pluginAttrNames.includes(name))
224
+ );
190
225
 
191
226
  for (const tableConfig of tableConfigs) {
192
227
  const tableName = tableConfig.table;
228
+ const mutability = tableConfig.mutability;
193
229
 
194
230
  const [okSync, errSync] = await tryFn(async () => {
195
- await this.syncTableSchema(tableName, attributes);
231
+ await this.syncTableSchema(tableName, attributes, mutability);
196
232
  });
197
233
 
198
234
  if (!okSync) {
@@ -216,7 +252,7 @@ class BigqueryReplicator extends BaseReplicator {
216
252
  /**
217
253
  * Sync a single table schema in BigQuery
218
254
  */
219
- async syncTableSchema(tableName, attributes) {
255
+ async syncTableSchema(tableName, attributes, mutability = 'append-only') {
220
256
  const dataset = this.bigqueryClient.dataset(this.datasetId);
221
257
  const table = dataset.table(tableName);
222
258
 
@@ -232,11 +268,11 @@ class BigqueryReplicator extends BaseReplicator {
232
268
  throw new Error(`Table ${tableName} does not exist (validate-only mode)`);
233
269
  }
234
270
 
235
- // Create table with schema
236
- const schema = generateBigQuerySchema(attributes);
271
+ // Create table with schema (including tracking fields based on mutability)
272
+ const schema = generateBigQuerySchema(attributes, mutability);
237
273
 
238
274
  if (this.config.verbose) {
239
- console.log(`[BigQueryReplicator] Creating table ${tableName} with schema:`, schema);
275
+ console.log(`[BigQueryReplicator] Creating table ${tableName} with schema (mutability: ${mutability}):`, schema);
240
276
  }
241
277
 
242
278
  await dataset.createTable(tableName, { schema });
@@ -244,7 +280,8 @@ class BigqueryReplicator extends BaseReplicator {
244
280
  this.emit('table_created', {
245
281
  replicator: this.name,
246
282
  tableName,
247
- attributes: Object.keys(attributes)
283
+ attributes: Object.keys(attributes),
284
+ mutability
248
285
  });
249
286
 
250
287
  return;
@@ -257,13 +294,14 @@ class BigqueryReplicator extends BaseReplicator {
257
294
  }
258
295
 
259
296
  await table.delete();
260
- const schema = generateBigQuerySchema(attributes);
297
+ const schema = generateBigQuerySchema(attributes, mutability);
261
298
  await dataset.createTable(tableName, { schema });
262
299
 
263
300
  this.emit('table_recreated', {
264
301
  replicator: this.name,
265
302
  tableName,
266
- attributes: Object.keys(attributes)
303
+ attributes: Object.keys(attributes),
304
+ mutability
267
305
  });
268
306
 
269
307
  return;
@@ -271,7 +309,7 @@ class BigqueryReplicator extends BaseReplicator {
271
309
 
272
310
  if (this.schemaSync.strategy === 'alter' && this.schemaSync.autoCreateColumns) {
273
311
  const existingSchema = await getBigQueryTableSchema(this.bigqueryClient, this.datasetId, tableName);
274
- const newFields = generateBigQuerySchemaUpdate(attributes, existingSchema);
312
+ const newFields = generateBigQuerySchemaUpdate(attributes, existingSchema, mutability);
275
313
 
276
314
  if (newFields.length > 0) {
277
315
  if (this.config.verbose) {
@@ -298,7 +336,7 @@ class BigqueryReplicator extends BaseReplicator {
298
336
 
299
337
  if (this.schemaSync.strategy === 'validate-only') {
300
338
  const existingSchema = await getBigQueryTableSchema(this.bigqueryClient, this.datasetId, tableName);
301
- const newFields = generateBigQuerySchemaUpdate(attributes, existingSchema);
339
+ const newFields = generateBigQuerySchemaUpdate(attributes, existingSchema, mutability);
302
340
 
303
341
  if (newFields.length > 0) {
304
342
  throw new Error(`Table ${tableName} schema mismatch. Missing columns: ${newFields.length}`);
@@ -325,7 +363,8 @@ class BigqueryReplicator extends BaseReplicator {
325
363
  .filter(tableConfig => tableConfig.actions.includes(operation))
326
364
  .map(tableConfig => ({
327
365
  table: tableConfig.table,
328
- transform: tableConfig.transform
366
+ transform: tableConfig.transform,
367
+ mutability: tableConfig.mutability
329
368
  }));
330
369
  }
331
370
 
@@ -354,6 +393,39 @@ class BigqueryReplicator extends BaseReplicator {
354
393
  return cleanData;
355
394
  }
356
395
 
396
+ /**
397
+ * Add tracking fields for append-only and immutable modes
398
+ * @private
399
+ */
400
+ _addTrackingFields(data, operation, mutability, id) {
401
+ const tracked = { ...data };
402
+
403
+ // Add operation tracking for append-only and immutable modes
404
+ if (mutability === 'append-only' || mutability === 'immutable') {
405
+ tracked._operation_type = operation;
406
+ tracked._operation_timestamp = new Date().toISOString();
407
+ }
408
+
409
+ // Add additional fields for immutable mode
410
+ if (mutability === 'immutable') {
411
+ tracked._is_deleted = operation === 'delete';
412
+ tracked._version = this._getNextVersion(id);
413
+ }
414
+
415
+ return tracked;
416
+ }
417
+
418
+ /**
419
+ * Get next version number for immutable mode
420
+ * @private
421
+ */
422
+ _getNextVersion(id) {
423
+ const current = this.versionCounters.get(id) || 0;
424
+ const next = current + 1;
425
+ this.versionCounters.set(id, next);
426
+ return next;
427
+ }
428
+
357
429
  async replicate(resourceName, operation, data, id, beforeData = null) {
358
430
 
359
431
  if (!this.enabled || !this.shouldReplicateResource(resourceName)) {
@@ -379,10 +451,23 @@ class BigqueryReplicator extends BaseReplicator {
379
451
  for (const tableConfig of tableConfigs) {
380
452
  const [okTable, errTable] = await tryFn(async () => {
381
453
  const table = dataset.table(tableConfig.table);
454
+ const mutability = tableConfig.mutability;
382
455
  let job;
383
456
 
384
- if (operation === 'insert') {
385
- const transformedData = this.applyTransform(data, tableConfig.transform);
457
+ // For append-only and immutable modes, convert update/delete to insert
458
+ const shouldConvertToInsert =
459
+ (mutability === 'append-only' || mutability === 'immutable') &&
460
+ (operation === 'update' || operation === 'delete');
461
+
462
+ if (operation === 'insert' || shouldConvertToInsert) {
463
+ // Apply transform first
464
+ let transformedData = this.applyTransform(data, tableConfig.transform);
465
+
466
+ // Add tracking fields if needed
467
+ if (shouldConvertToInsert) {
468
+ transformedData = this._addTrackingFields(transformedData, operation, mutability, id);
469
+ }
470
+
386
471
  try {
387
472
  job = await table.insert([transformedData]);
388
473
  } catch (error) {
@@ -395,7 +480,8 @@ class BigqueryReplicator extends BaseReplicator {
395
480
  }
396
481
  throw error;
397
482
  }
398
- } else if (operation === 'update') {
483
+ } else if (operation === 'update' && mutability === 'mutable') {
484
+ // Traditional UPDATE for mutable mode
399
485
  const transformedData = this.applyTransform(data, tableConfig.transform);
400
486
  const keys = Object.keys(transformedData).filter(k => k !== 'id');
401
487
  const setClause = keys.map(k => `${k} = @${k}`).join(', ');
@@ -447,7 +533,8 @@ class BigqueryReplicator extends BaseReplicator {
447
533
  }
448
534
 
449
535
  if (!job) throw lastError;
450
- } else if (operation === 'delete') {
536
+ } else if (operation === 'delete' && mutability === 'mutable') {
537
+ // Traditional DELETE for mutable mode
451
538
  const query = `DELETE FROM \`${this.projectId}.${this.datasetId}.${tableConfig.table}\` WHERE id = @id`;
452
539
  try {
453
540
  const [deleteJob] = await this.bigqueryClient.createQueryJob({
@@ -606,7 +693,8 @@ class BigqueryReplicator extends BaseReplicator {
606
693
  datasetId: this.datasetId,
607
694
  resources: this.resources,
608
695
  logTable: this.logTable,
609
- schemaSync: this.schemaSync
696
+ schemaSync: this.schemaSync,
697
+ mutability: this.mutability
610
698
  };
611
699
  }
612
700
  }
@@ -221,7 +221,15 @@ class MySQLReplicator extends BaseReplicator {
221
221
  continue;
222
222
  }
223
223
 
224
- const attributes = resource.config.versions[resource.config.currentVersion]?.attributes || {};
224
+ const allAttributes = resource.config.versions[resource.config.currentVersion]?.attributes || {};
225
+
226
+ // Filter out plugin attributes - they are internal and should not be replicated
227
+ const pluginAttrNames = resource.schema?._pluginAttributes
228
+ ? Object.values(resource.schema._pluginAttributes).flat()
229
+ : [];
230
+ const attributes = Object.fromEntries(
231
+ Object.entries(allAttributes).filter(([name]) => !pluginAttrNames.includes(name))
232
+ );
225
233
 
226
234
  for (const tableConfig of tableConfigs) {
227
235
  const tableName = tableConfig.table;
@@ -183,7 +183,15 @@ class PlanetScaleReplicator extends BaseReplicator {
183
183
  continue;
184
184
  }
185
185
 
186
- const attributes = resource.config.versions[resource.config.currentVersion]?.attributes || {};
186
+ const allAttributes = resource.config.versions[resource.config.currentVersion]?.attributes || {};
187
+
188
+ // Filter out plugin attributes - they are internal and should not be replicated
189
+ const pluginAttrNames = resource.schema?._pluginAttributes
190
+ ? Object.values(resource.schema._pluginAttributes).flat()
191
+ : [];
192
+ const attributes = Object.fromEntries(
193
+ Object.entries(allAttributes).filter(([name]) => !pluginAttrNames.includes(name))
194
+ );
187
195
 
188
196
  for (const tableConfig of tableConfigs) {
189
197
  const tableName = tableConfig.table;
@@ -225,7 +225,15 @@ class PostgresReplicator extends BaseReplicator {
225
225
  }
226
226
 
227
227
  // Get resource attributes from current version
228
- const attributes = resource.config.versions[resource.config.currentVersion]?.attributes || {};
228
+ const allAttributes = resource.config.versions[resource.config.currentVersion]?.attributes || {};
229
+
230
+ // Filter out plugin attributes - they are internal and should not be replicated
231
+ const pluginAttrNames = resource.schema?._pluginAttributes
232
+ ? Object.values(resource.schema._pluginAttributes).flat()
233
+ : [];
234
+ const attributes = Object.fromEntries(
235
+ Object.entries(allAttributes).filter(([name]) => !pluginAttrNames.includes(name))
236
+ );
229
237
 
230
238
  // Sync each table configured for this resource
231
239
  for (const tableConfig of tableConfigs) {
@@ -7,6 +7,25 @@
7
7
 
8
8
  import tryFn from "#src/concerns/try-fn.js";
9
9
 
10
+ /**
11
+ * Filter out plugin attributes from attributes object
12
+ * Plugin attributes are internal implementation details and should not be replicated
13
+ * @param {Object} attributes - All attributes including plugin attributes
14
+ * @param {Object} resource - Resource instance with schema._pluginAttributes
15
+ * @returns {Object} Filtered attributes (user attributes only)
16
+ */
17
+ function filterPluginAttributes(attributes, resource) {
18
+ if (!resource?.schema?._pluginAttributes) {
19
+ return attributes;
20
+ }
21
+
22
+ const pluginAttrNames = Object.values(resource.schema._pluginAttributes).flat();
23
+
24
+ return Object.fromEntries(
25
+ Object.entries(attributes).filter(([name]) => !pluginAttrNames.includes(name))
26
+ );
27
+ }
28
+
10
29
  /**
11
30
  * Parse s3db field type notation (e.g., 'string|required|maxlength:50')
12
31
  */
@@ -394,7 +413,7 @@ export function generateMySQLAlterTable(tableName, attributes, existingSchema) {
394
413
  /**
395
414
  * Generate BigQuery table schema from S3DB resource schema
396
415
  */
397
- export function generateBigQuerySchema(attributes) {
416
+ export function generateBigQuerySchema(attributes, mutability = 'append-only') {
398
417
  const fields = [];
399
418
 
400
419
  // Always add id field
@@ -427,6 +446,18 @@ export function generateBigQuerySchema(attributes) {
427
446
  fields.push({ name: 'updated_at', type: 'TIMESTAMP', mode: 'NULLABLE' });
428
447
  }
429
448
 
449
+ // Add tracking fields for append-only and immutable modes
450
+ if (mutability === 'append-only' || mutability === 'immutable') {
451
+ fields.push({ name: '_operation_type', type: 'STRING', mode: 'NULLABLE' });
452
+ fields.push({ name: '_operation_timestamp', type: 'TIMESTAMP', mode: 'NULLABLE' });
453
+ }
454
+
455
+ // Add additional fields for immutable mode
456
+ if (mutability === 'immutable') {
457
+ fields.push({ name: '_is_deleted', type: 'BOOL', mode: 'NULLABLE' });
458
+ fields.push({ name: '_version', type: 'INT64', mode: 'NULLABLE' });
459
+ }
460
+
430
461
  return fields;
431
462
  }
432
463
 
@@ -459,7 +490,7 @@ export async function getBigQueryTableSchema(bigqueryClient, datasetId, tableId)
459
490
  /**
460
491
  * Generate BigQuery schema update (add missing fields)
461
492
  */
462
- export function generateBigQuerySchemaUpdate(attributes, existingSchema) {
493
+ export function generateBigQuerySchemaUpdate(attributes, existingSchema, mutability = 'append-only') {
463
494
  const newFields = [];
464
495
 
465
496
  for (const [fieldName, fieldConfig] of Object.entries(attributes)) {
@@ -477,6 +508,26 @@ export function generateBigQuerySchemaUpdate(attributes, existingSchema) {
477
508
  });
478
509
  }
479
510
 
511
+ // Add tracking fields for append-only and immutable modes if they don't exist
512
+ if (mutability === 'append-only' || mutability === 'immutable') {
513
+ if (!existingSchema['_operation_type']) {
514
+ newFields.push({ name: '_operation_type', type: 'STRING', mode: 'NULLABLE' });
515
+ }
516
+ if (!existingSchema['_operation_timestamp']) {
517
+ newFields.push({ name: '_operation_timestamp', type: 'TIMESTAMP', mode: 'NULLABLE' });
518
+ }
519
+ }
520
+
521
+ // Add additional fields for immutable mode if they don't exist
522
+ if (mutability === 'immutable') {
523
+ if (!existingSchema['_is_deleted']) {
524
+ newFields.push({ name: '_is_deleted', type: 'BOOL', mode: 'NULLABLE' });
525
+ }
526
+ if (!existingSchema['_version']) {
527
+ newFields.push({ name: '_version', type: 'INT64', mode: 'NULLABLE' });
528
+ }
529
+ }
530
+
480
531
  return newFields;
481
532
  }
482
533
 
@@ -177,7 +177,15 @@ class TursoReplicator extends BaseReplicator {
177
177
  continue;
178
178
  }
179
179
 
180
- const attributes = resource.config.versions[resource.config.currentVersion]?.attributes || {};
180
+ const allAttributes = resource.config.versions[resource.config.currentVersion]?.attributes || {};
181
+
182
+ // Filter out plugin attributes - they are internal and should not be replicated
183
+ const pluginAttrNames = resource.schema?._pluginAttributes
184
+ ? Object.values(resource.schema._pluginAttributes).flat()
185
+ : [];
186
+ const attributes = Object.fromEntries(
187
+ Object.entries(allAttributes).filter(([name]) => !pluginAttrNames.includes(name))
188
+ );
181
189
 
182
190
  for (const tableConfig of tableConfigs) {
183
191
  const tableName = tableConfig.table;
@@ -4,7 +4,7 @@
4
4
  * Reads Terraform/OpenTofu state files from S3 buckets
5
5
  */
6
6
  import { TfStateDriver } from './base-driver.js';
7
- import { Client } from '../../client.class.js';
7
+ import { S3Client } from '../../clients/s3-client.class.js';
8
8
  import tryFn from '../../concerns/try-fn.js';
9
9
 
10
10
  export class S3TfStateDriver extends TfStateDriver {
@@ -71,8 +71,8 @@ export class S3TfStateDriver extends TfStateDriver {
71
71
  async initialize() {
72
72
  const { bucket, credentials, region } = this.connectionConfig;
73
73
 
74
- // Create S3 client using s3db's Client class
75
- this.client = new Client({
74
+ // Create S3 client using s3db's S3Client class
75
+ this.client = new S3Client({
76
76
  bucketName: bucket,
77
77
  credentials,
78
78
  region
@@ -183,13 +183,13 @@ export class VectorPlugin extends Plugin {
183
183
  }
184
184
  };
185
185
 
186
- // Add tracking field to schema if not present
186
+ // Add tracking field to schema if not present using plugin API
187
187
  if (!resource.schema.attributes[trackingFieldName]) {
188
- resource.schema.attributes[trackingFieldName] = {
188
+ resource.addPluginAttribute(trackingFieldName, {
189
189
  type: 'boolean',
190
190
  optional: true,
191
191
  default: false
192
- };
192
+ }, 'VectorPlugin');
193
193
  }
194
194
 
195
195
  // Emit event