s3db.js 7.2.0 → 7.2.1

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/PLUGINS.md CHANGED
@@ -1608,18 +1608,147 @@ await users.insert({ name: 'John', email: 'john@example.com' });
1608
1608
 
1609
1609
  #### S3DB Replicator
1610
1610
 
1611
- Replicate to another S3DB instance:
1611
+ Replicate to another S3DB instance with flexible resource mapping and transformation capabilities:
1612
1612
 
1613
1613
  ```javascript
1614
1614
  {
1615
1615
  driver: 's3db',
1616
- resources: ['users', 'products'],
1617
1616
  config: {
1618
1617
  connectionString: "s3://BACKUP_KEY:BACKUP_SECRET@BACKUP_BUCKET/backup"
1618
+ },
1619
+ resources: {
1620
+ // Simple resource mapping (replicate to same name)
1621
+ users: 'users',
1622
+
1623
+ // Map source → destination resource name
1624
+ products: 'backup_products',
1625
+
1626
+ // Advanced mapping with transformer
1627
+ orders: {
1628
+ resource: 'order_backup',
1629
+ transformer: (data) => ({
1630
+ ...data,
1631
+ backup_timestamp: new Date().toISOString(),
1632
+ original_source: 'production'
1633
+ })
1634
+ },
1635
+
1636
+ // Multi-destination replication
1637
+ analytics: [
1638
+ 'analytics_backup',
1639
+ {
1640
+ resource: 'analytics_processed',
1641
+ transformer: (data) => ({
1642
+ ...data,
1643
+ processed_date: data.createdAt?.split('T')[0],
1644
+ data_source: 'analytics'
1645
+ })
1646
+ }
1647
+ ]
1648
+ }
1649
+ }
1650
+ ```
1651
+
1652
+ **Resource Configuration Syntaxes:**
1653
+
1654
+ The S3DB replicator supports highly flexible resource mapping configurations:
1655
+
1656
+ **1. Array of resource names** (replicate to same name):
1657
+ ```javascript
1658
+ resources: ['users', 'products', 'orders']
1659
+ // Replicates each resource to itself in the destination database
1660
+ ```
1661
+
1662
+ **2. Object mapping** (source → destination):
1663
+ ```javascript
1664
+ resources: {
1665
+ users: 'people', // users → people
1666
+ products: 'items', // products → items
1667
+ orders: 'order_history' // orders → order_history
1668
+ }
1669
+ ```
1670
+
1671
+ **3. Array with transformer** (resource + transformation):
1672
+ ```javascript
1673
+ resources: {
1674
+ users: ['people', (data) => ({ ...data, fullName: `${data.firstName} ${data.lastName}` })]
1675
+ // Replicates 'users' to 'people' with transformation
1676
+ }
1677
+ ```
1678
+
1679
+ **4. Object with resource and transformer**:
1680
+ ```javascript
1681
+ resources: {
1682
+ users: {
1683
+ resource: 'people',
1684
+ transformer: (data) => ({
1685
+ ...data,
1686
+ fullName: `${data.firstName} ${data.lastName}`,
1687
+ migrated_at: new Date().toISOString()
1688
+ })
1619
1689
  }
1620
1690
  }
1621
1691
  ```
1622
1692
 
1693
+ **5. Multi-destination arrays**:
1694
+ ```javascript
1695
+ resources: {
1696
+ users: [
1697
+ 'people', // Simple copy
1698
+ {
1699
+ resource: 'user_analytics',
1700
+ transformer: (data) => ({
1701
+ id: data.id,
1702
+ signup_date: data.createdAt,
1703
+ user_type: data.role || 'standard'
1704
+ })
1705
+ }
1706
+ ]
1707
+ }
1708
+ ```
1709
+
1710
+ **6. Function-only transformation** (transform to same resource):
1711
+ ```javascript
1712
+ resources: {
1713
+ users: (data) => ({
1714
+ ...data,
1715
+ processed: true,
1716
+ backup_date: new Date().toISOString()
1717
+ })
1718
+ }
1719
+ ```
1720
+
1721
+ **Mixed Configuration Example:**
1722
+ ```javascript
1723
+ resources: {
1724
+ users: [
1725
+ 'people', // Simple copy
1726
+ {
1727
+ resource: 'user_profiles',
1728
+ transformer: (data) => ({
1729
+ ...data,
1730
+ profile_complete: !!(data.name && data.email)
1731
+ })
1732
+ }
1733
+ ],
1734
+ orders: 'order_backup', // Rename only
1735
+ products: { resource: 'product_catalog' }, // Object form
1736
+ analytics: (data) => ({ ...data, processed: true }) // Transform only
1737
+ }
1738
+ ```
1739
+
1740
+ **Configuration Options:**
1741
+ - `connectionString`: S3DB connection string for destination database (required)
1742
+ - `client`: Pre-configured S3DB client instance (alternative to connectionString)
1743
+ - `resources`: Resource mapping configuration (see syntaxes above)
1744
+
1745
+ **Transformer Features:**
1746
+ - **Data Transformation**: Apply custom logic before replication
1747
+ - **Field Mapping**: Rename, combine, or derive new fields
1748
+ - **Data Enrichment**: Add metadata, timestamps, or computed values
1749
+ - **Conditional Logic**: Apply transformations based on data content
1750
+ - **Multi-destination**: Send different transformed versions to multiple targets
1751
+
1623
1752
  #### SQS Replicator
1624
1753
 
1625
1754
  Send changes to AWS SQS queues:
@@ -1639,23 +1768,84 @@ Send changes to AWS SQS queues:
1639
1768
 
1640
1769
  #### BigQuery Replicator
1641
1770
 
1642
- Replicate to Google BigQuery:
1771
+ Replicate to Google BigQuery with advanced data transformation capabilities:
1643
1772
 
1644
1773
  ```javascript
1645
1774
  {
1646
1775
  driver: 'bigquery',
1647
- resources: {
1648
- users: [{ actions: ['insert', 'update'], table: 'users_table' }],
1649
- orders: 'orders_table'
1650
- },
1651
1776
  config: {
1652
- projectId: 'my-project',
1653
- datasetId: 'analytics',
1654
- credentials: { /* service account */ }
1777
+ location: 'US',
1778
+ projectId: 'my-gcp-project',
1779
+ datasetId: 'analytics',
1780
+ credentials: JSON.parse(Buffer.from(GOOGLE_CREDENTIALS, 'base64').toString()),
1781
+ logTable: 'replication_log' // Optional: table for operation logging
1782
+ },
1783
+ resources: {
1784
+ // Simple table mapping
1785
+ clicks: 'mrt-shortner__clicks',
1786
+ fingerprints: 'mrt-shortner__fingerprints',
1787
+ scans: 'mrt-shortner__scans',
1788
+ sessions: 'mrt-shortner__sessions',
1789
+ shares: 'mrt-shortner__shares',
1790
+ urls: 'mrt-shortner__urls',
1791
+ views: 'mrt-shortner__views',
1792
+
1793
+ // Advanced configuration with transform functions
1794
+ users: {
1795
+ table: 'mrt-shortner__users',
1796
+ actions: ['insert', 'update'],
1797
+ transform: (data) => {
1798
+ return {
1799
+ ...data,
1800
+ ip: data.ip || 'unknown',
1801
+ userIp: data.userIp || 'unknown',
1802
+ }
1803
+ }
1804
+ },
1805
+
1806
+ // Multiple destinations for a single resource
1807
+ orders: [
1808
+ { actions: ['insert'], table: 'fact_orders' },
1809
+ {
1810
+ actions: ['insert'],
1811
+ table: 'daily_revenue',
1812
+ transform: (data) => ({
1813
+ date: data.createdAt?.split('T')[0],
1814
+ revenue: data.amount,
1815
+ customer_id: data.userId,
1816
+ order_count: 1
1817
+ })
1818
+ }
1819
+ ]
1655
1820
  }
1656
1821
  }
1657
1822
  ```
1658
1823
 
1824
+ **Transform Function Features:**
1825
+ - **Data Transformation**: Apply custom logic before sending to BigQuery
1826
+ - **Field Mapping**: Rename, combine, or derive new fields
1827
+ - **Data Enrichment**: Add computed fields, defaults, or metadata
1828
+ - **Format Conversion**: Convert data types or formats for BigQuery compatibility
1829
+ - **Multiple Destinations**: Send transformed data to different tables
1830
+
1831
+ **Configuration Options:**
1832
+ - `projectId`: Google Cloud project ID (required)
1833
+ - `datasetId`: BigQuery dataset ID (required)
1834
+ - `credentials`: Service account credentials object (optional, uses default if omitted)
1835
+ - `location`: BigQuery dataset location/region (default: 'US')
1836
+ - `logTable`: Table name for operation logging (optional)
1837
+
1838
+ **Resource Configuration:**
1839
+ - **String**: Simple table mapping (e.g., `'table_name'`)
1840
+ - **Object**: Advanced configuration with actions and transforms
1841
+ - **Array**: Multiple destination tables with different configurations
1842
+
1843
+ **Automatic Features:**
1844
+ - **Retry Logic**: Handles BigQuery streaming buffer limitations with 30-second retry delays
1845
+ - **Error Handling**: Graceful handling of schema mismatches and quota limits
1846
+ - **Operation Logging**: Optional audit trail of all replication operations
1847
+ - **Schema Compatibility**: Automatic handling of missing fields
1848
+
1659
1849
  #### PostgreSQL Replicator
1660
1850
 
1661
1851
  Replicate to PostgreSQL database:
package/dist/s3db.cjs.js CHANGED
@@ -8006,22 +8006,25 @@ class BigqueryReplicator extends base_replicator_class_default {
8006
8006
  if (typeof config === "string") {
8007
8007
  parsed[resourceName] = [{
8008
8008
  table: config,
8009
- actions: ["insert"]
8009
+ actions: ["insert"],
8010
+ transform: null
8010
8011
  }];
8011
8012
  } else if (Array.isArray(config)) {
8012
8013
  parsed[resourceName] = config.map((item) => {
8013
8014
  if (typeof item === "string") {
8014
- return { table: item, actions: ["insert"] };
8015
+ return { table: item, actions: ["insert"], transform: null };
8015
8016
  }
8016
8017
  return {
8017
8018
  table: item.table,
8018
- actions: item.actions || ["insert"]
8019
+ actions: item.actions || ["insert"],
8020
+ transform: item.transform || null
8019
8021
  };
8020
8022
  });
8021
8023
  } else if (typeof config === "object") {
8022
8024
  parsed[resourceName] = [{
8023
8025
  table: config.table,
8024
- actions: config.actions || ["insert"]
8026
+ actions: config.actions || ["insert"],
8027
+ transform: config.transform || null
8025
8028
  }];
8026
8029
  }
8027
8030
  }
@@ -8045,6 +8048,9 @@ class BigqueryReplicator extends base_replicator_class_default {
8045
8048
  if (invalidActions.length > 0) {
8046
8049
  errors.push(`Invalid actions for resource '${resourceName}': ${invalidActions.join(", ")}. Valid actions: ${validActions.join(", ")}`);
8047
8050
  }
8051
+ if (tableConfig.transform && typeof tableConfig.transform !== "function") {
8052
+ errors.push(`Transform must be a function for resource '${resourceName}'`);
8053
+ }
8048
8054
  }
8049
8055
  }
8050
8056
  return { isValid: errors.length === 0, errors };
@@ -8080,7 +8086,16 @@ class BigqueryReplicator extends base_replicator_class_default {
8080
8086
  }
8081
8087
  getTablesForResource(resourceName, operation) {
8082
8088
  if (!this.resources[resourceName]) return [];
8083
- return this.resources[resourceName].filter((tableConfig) => tableConfig.actions.includes(operation)).map((tableConfig) => tableConfig.table);
8089
+ return this.resources[resourceName].filter((tableConfig) => tableConfig.actions.includes(operation)).map((tableConfig) => ({
8090
+ table: tableConfig.table,
8091
+ transform: tableConfig.transform
8092
+ }));
8093
+ }
8094
+ applyTransform(data, transformFn) {
8095
+ if (!transformFn) return data;
8096
+ let transformedData = JSON.parse(JSON.stringify(data));
8097
+ if (transformedData._length) delete transformedData._length;
8098
+ return transformFn(transformedData);
8084
8099
  }
8085
8100
  async replicate(resourceName, operation, data, id, beforeData = null) {
8086
8101
  if (!this.enabled || !this.shouldReplicateResource(resourceName)) {
@@ -8089,40 +8104,56 @@ class BigqueryReplicator extends base_replicator_class_default {
8089
8104
  if (!this.shouldReplicateAction(resourceName, operation)) {
8090
8105
  return { skipped: true, reason: "action_not_included" };
8091
8106
  }
8092
- const tables = this.getTablesForResource(resourceName, operation);
8093
- if (tables.length === 0) {
8107
+ const tableConfigs = this.getTablesForResource(resourceName, operation);
8108
+ if (tableConfigs.length === 0) {
8094
8109
  return { skipped: true, reason: "no_tables_for_action" };
8095
8110
  }
8096
8111
  const results = [];
8097
8112
  const errors = [];
8098
8113
  const [ok, err, result] = await try_fn_default(async () => {
8099
8114
  const dataset = this.bigqueryClient.dataset(this.datasetId);
8100
- for (const tableId of tables) {
8115
+ for (const tableConfig of tableConfigs) {
8101
8116
  const [okTable, errTable] = await try_fn_default(async () => {
8102
- const table = dataset.table(tableId);
8117
+ const table = dataset.table(tableConfig.table);
8103
8118
  let job;
8104
8119
  if (operation === "insert") {
8105
- const row = { ...data };
8106
- job = await table.insert([row]);
8120
+ const transformedData = this.applyTransform(data, tableConfig.transform);
8121
+ job = await table.insert([transformedData]);
8107
8122
  } else if (operation === "update") {
8108
- const keys = Object.keys(data).filter((k) => k !== "id");
8109
- const setClause = keys.map((k) => `${k}=@${k}`).join(", ");
8110
- const params = { id };
8111
- keys.forEach((k) => {
8112
- params[k] = data[k];
8113
- });
8114
- const query = `UPDATE \`${this.projectId}.${this.datasetId}.${tableId}\` SET ${setClause} WHERE id=@id`;
8115
- const [updateJob] = await this.bigqueryClient.createQueryJob({
8116
- query,
8117
- params
8118
- });
8119
- await updateJob.getQueryResults();
8120
- job = [updateJob];
8123
+ const transformedData = this.applyTransform(data, tableConfig.transform);
8124
+ const keys = Object.keys(transformedData).filter((k) => k !== "id");
8125
+ const setClause = keys.map((k) => `${k} = @${k}`).join(", ");
8126
+ const params = { id, ...transformedData };
8127
+ const query = `UPDATE \`${this.projectId}.${this.datasetId}.${tableConfig.table}\` SET ${setClause} WHERE id = @id`;
8128
+ const maxRetries = 2;
8129
+ let lastError = null;
8130
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
8131
+ try {
8132
+ const [updateJob] = await this.bigqueryClient.createQueryJob({
8133
+ query,
8134
+ params,
8135
+ location: this.location
8136
+ });
8137
+ await updateJob.getQueryResults();
8138
+ job = [updateJob];
8139
+ break;
8140
+ } catch (error) {
8141
+ lastError = error;
8142
+ if (error?.message?.includes("streaming buffer") && attempt < maxRetries) {
8143
+ const delaySeconds = 30;
8144
+ await new Promise((resolve) => setTimeout(resolve, delaySeconds * 1e3));
8145
+ continue;
8146
+ }
8147
+ throw error;
8148
+ }
8149
+ }
8150
+ if (!job) throw lastError;
8121
8151
  } else if (operation === "delete") {
8122
- const query = `DELETE FROM \`${this.projectId}.${this.datasetId}.${tableId}\` WHERE id=@id`;
8152
+ const query = `DELETE FROM \`${this.projectId}.${this.datasetId}.${tableConfig.table}\` WHERE id = @id`;
8123
8153
  const [deleteJob] = await this.bigqueryClient.createQueryJob({
8124
8154
  query,
8125
- params: { id }
8155
+ params: { id },
8156
+ location: this.location
8126
8157
  });
8127
8158
  await deleteJob.getQueryResults();
8128
8159
  job = [deleteJob];
@@ -8130,14 +8161,14 @@ class BigqueryReplicator extends base_replicator_class_default {
8130
8161
  throw new Error(`Unsupported operation: ${operation}`);
8131
8162
  }
8132
8163
  results.push({
8133
- table: tableId,
8164
+ table: tableConfig.table,
8134
8165
  success: true,
8135
8166
  jobId: job[0]?.id
8136
8167
  });
8137
8168
  });
8138
8169
  if (!okTable) {
8139
8170
  errors.push({
8140
- table: tableId,
8171
+ table: tableConfig.table,
8141
8172
  error: errTable.message
8142
8173
  });
8143
8174
  }
@@ -8163,7 +8194,7 @@ class BigqueryReplicator extends base_replicator_class_default {
8163
8194
  resourceName,
8164
8195
  operation,
8165
8196
  id,
8166
- tables,
8197
+ tables: tableConfigs.map((t) => t.table),
8167
8198
  results,
8168
8199
  errors,
8169
8200
  success
@@ -8172,7 +8203,7 @@ class BigqueryReplicator extends base_replicator_class_default {
8172
8203
  success,
8173
8204
  results,
8174
8205
  errors,
8175
- tables
8206
+ tables: tableConfigs.map((t) => t.table)
8176
8207
  };
8177
8208
  });
8178
8209
  if (ok) return result;