s3db.js 6.1.0 → 7.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 (60) hide show
  1. package/PLUGINS.md +2724 -0
  2. package/README.md +377 -492
  3. package/UNLICENSE +24 -0
  4. package/dist/s3db.cjs.js +30054 -18189
  5. package/dist/s3db.cjs.min.js +1 -1
  6. package/dist/s3db.d.ts +373 -72
  7. package/dist/s3db.es.js +30040 -18186
  8. package/dist/s3db.es.min.js +1 -1
  9. package/dist/s3db.iife.js +29727 -17863
  10. package/dist/s3db.iife.min.js +1 -1
  11. package/package.json +44 -69
  12. package/src/behaviors/body-only.js +110 -0
  13. package/src/behaviors/body-overflow.js +153 -0
  14. package/src/behaviors/enforce-limits.js +195 -0
  15. package/src/behaviors/index.js +39 -0
  16. package/src/behaviors/truncate-data.js +204 -0
  17. package/src/behaviors/user-managed.js +147 -0
  18. package/src/client.class.js +515 -0
  19. package/src/concerns/base62.js +61 -0
  20. package/src/concerns/calculator.js +204 -0
  21. package/src/concerns/crypto.js +142 -0
  22. package/src/concerns/id.js +8 -0
  23. package/src/concerns/index.js +5 -0
  24. package/src/concerns/try-fn.js +151 -0
  25. package/src/connection-string.class.js +75 -0
  26. package/src/database.class.js +599 -0
  27. package/src/errors.js +261 -0
  28. package/src/index.js +17 -0
  29. package/src/plugins/audit.plugin.js +442 -0
  30. package/src/plugins/cache/cache.class.js +53 -0
  31. package/src/plugins/cache/index.js +6 -0
  32. package/src/plugins/cache/memory-cache.class.js +164 -0
  33. package/src/plugins/cache/s3-cache.class.js +189 -0
  34. package/src/plugins/cache.plugin.js +275 -0
  35. package/src/plugins/consumers/index.js +24 -0
  36. package/src/plugins/consumers/rabbitmq-consumer.js +56 -0
  37. package/src/plugins/consumers/sqs-consumer.js +102 -0
  38. package/src/plugins/costs.plugin.js +81 -0
  39. package/src/plugins/fulltext.plugin.js +473 -0
  40. package/src/plugins/index.js +12 -0
  41. package/src/plugins/metrics.plugin.js +603 -0
  42. package/src/plugins/plugin.class.js +210 -0
  43. package/src/plugins/plugin.obj.js +13 -0
  44. package/src/plugins/queue-consumer.plugin.js +134 -0
  45. package/src/plugins/replicator.plugin.js +769 -0
  46. package/src/plugins/replicators/base-replicator.class.js +85 -0
  47. package/src/plugins/replicators/bigquery-replicator.class.js +328 -0
  48. package/src/plugins/replicators/index.js +44 -0
  49. package/src/plugins/replicators/postgres-replicator.class.js +427 -0
  50. package/src/plugins/replicators/s3db-replicator.class.js +352 -0
  51. package/src/plugins/replicators/sqs-replicator.class.js +427 -0
  52. package/src/resource.class.js +2626 -0
  53. package/src/s3db.d.ts +1263 -0
  54. package/src/schema.class.js +706 -0
  55. package/src/stream/index.js +16 -0
  56. package/src/stream/resource-ids-page-reader.class.js +10 -0
  57. package/src/stream/resource-ids-reader.class.js +63 -0
  58. package/src/stream/resource-reader.class.js +81 -0
  59. package/src/stream/resource-writer.class.js +92 -0
  60. package/src/validator.class.js +97 -0
@@ -0,0 +1,427 @@
1
+ /**
2
+ * Postgres Replicator Configuration Documentation
3
+ *
4
+ * This replicator executes real SQL operations (INSERT, UPDATE, DELETE) on PostgreSQL tables
5
+ * using the official pg (node-postgres) library. It maps s3db resources to database tables
6
+ * and performs actual database operations for each replicator event.
7
+ *
8
+ * ⚠️ REQUIRED DEPENDENCY: You must install the PostgreSQL client library to use this replicator:
9
+ *
10
+ * ```bash
11
+ * npm install pg
12
+ * # or
13
+ * yarn add pg
14
+ * # or
15
+ * pnpm add pg
16
+ * ```
17
+ *
18
+ * @typedef {Object} PostgresReplicatorConfig
19
+ * @property {string} database - The name of the PostgreSQL database to connect to
20
+ * @property {string} resourceArn - The ARN of the Aurora Serverless cluster or RDS instance
21
+ * @property {string} secretArn - The ARN of the Secrets Manager secret containing database credentials
22
+ * @property {string} [region='us-east-1'] - AWS region where the database is located
23
+ * @property {Object.<string, string>} [tableMapping] - Maps s3db resource names to PostgreSQL table names
24
+ * - Key: s3db resource name (e.g., 'users', 'orders')
25
+ * - Value: PostgreSQL table name (e.g., 'public.users', 'analytics.orders')
26
+ * - If not provided, resource names are used as table names
27
+ * @property {boolean} [logOperations=false] - Whether to log SQL operations to console for debugging
28
+ * @property {string} [schema='public'] - Default database schema to use when tableMapping doesn't specify schema
29
+ * @property {number} [maxRetries=3] - Maximum number of retry attempts for failed operations
30
+ * @property {number} [retryDelay=1000] - Delay in milliseconds between retry attempts
31
+ * @property {boolean} [useUpsert=true] - Whether to use UPSERT (INSERT ... ON CONFLICT) for updates
32
+ * @property {string} [conflictColumn='id'] - Column name to use for conflict resolution in UPSERT operations
33
+ *
34
+ * @example
35
+ * // Basic configuration with table mapping
36
+ * {
37
+ * database: 'analytics_db',
38
+ * resourceArn: 'arn:aws:rds:us-east-1:123456789012:cluster:my-aurora-cluster',
39
+ * secretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:db-credentials',
40
+ * region: 'us-east-1',
41
+ * tableMapping: {
42
+ * 'users': 'public.users',
43
+ * 'orders': 'analytics.orders',
44
+ * 'products': 'inventory.products'
45
+ * },
46
+ * logOperations: true,
47
+ * useUpsert: true,
48
+ * conflictColumn: 'id'
49
+ * }
50
+ *
51
+ * @example
52
+ * // Minimal configuration using default settings
53
+ * {
54
+ * database: 'my_database',
55
+ * resourceArn: 'arn:aws:rds:us-east-1:123456789012:cluster:my-cluster',
56
+ * secretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:db-secret'
57
+ * }
58
+ *
59
+ * @notes
60
+ * - Requires AWS credentials with RDS Data Service permissions
61
+ * - Database tables must exist before replicator starts
62
+ * - For UPSERT operations, the conflict column must have a unique constraint
63
+ * - All data is automatically converted to JSON format for storage
64
+ * - Timestamps are stored as ISO strings in the database
65
+ * - Failed operations are retried with exponential backoff
66
+ * - Operations are executed within database transactions for consistency
67
+ */
68
+ import BaseReplicator from './base-replicator.class.js';
69
+ import tryFn from "../../concerns/try-fn.js";
70
+
71
+ /**
72
+ * PostgreSQL Replicator
73
+ *
74
+ * Replicates data to PostgreSQL tables, supporting per-resource table mapping and action filtering.
75
+ *
76
+ * ⚠️ REQUIRED DEPENDENCY: You must install the PostgreSQL client library to use this replicator:
77
+ *
78
+ * ```bash
79
+ * npm install pg
80
+ * # or
81
+ * yarn add pg
82
+ * # or
83
+ * pnpm add pg
84
+ * ```
85
+ *
86
+ * @config {Object} config - Configuration object for the replicator
87
+ * @config {string} [config.connectionString] - PostgreSQL connection string (alternative to individual params)
88
+ * @config {string} [config.host] - Database host (required if not using connectionString)
89
+ * @config {number} [config.port=5432] - Database port
90
+ * @config {string} [config.database] - Database name (required if not using connectionString)
91
+ * @config {string} [config.user] - Database user (required if not using connectionString)
92
+ * @config {string} [config.password] - Database password (required if not using connectionString)
93
+ * @config {Object} [config.ssl] - SSL configuration
94
+ * @config {string} [config.logTable] - Table name for operation logging. If omitted, no logging is performed.
95
+ * @config {Object} resources - Resource configuration mapping
96
+ * @config {Object|string} resources[resourceName] - Resource configuration
97
+ * @config {string} resources[resourceName].table - Table name for this resource
98
+ * @config {Array} resources[resourceName].actions - Array of actions to replicate (insert, update, delete)
99
+ * @config {string} resources[resourceName] - Short form: just the table name (equivalent to { actions: ['insert'], table: tableName })
100
+ *
101
+ * @example
102
+ * new PostgresReplicator({
103
+ * connectionString: 'postgresql://user:password@localhost:5432/analytics',
104
+ * ssl: false,
105
+ * logTable: 's3db_replicator_log'
106
+ * }, {
107
+ * users: [
108
+ * { actions: ['insert', 'update', 'delete'], table: 'users_table' },
109
+ * ],
110
+ * orders: [
111
+ * { actions: ['insert'], table: 'orders_table' },
112
+ * { actions: ['insert'], table: 'orders_analytics' }, // Also replicate to analytics table
113
+ * ],
114
+ * products: 'products_table' // Short form: equivalent to { actions: ['insert'], table: 'products_table' }
115
+ * })
116
+ *
117
+ * Notes:
118
+ * - The target tables must exist and have columns matching the resource attributes (id is required as primary key)
119
+ * - The log table must have columns: resource_name, operation, record_id, data, timestamp, source
120
+ * - Uses pg (node-postgres) library
121
+ * - Supports UPSERT operations with ON CONFLICT handling
122
+ */
123
+ class PostgresReplicator extends BaseReplicator {
124
+ constructor(config = {}, resources = {}) {
125
+ super(config);
126
+ this.connectionString = config.connectionString;
127
+ this.host = config.host;
128
+ this.port = config.port || 5432;
129
+ this.database = config.database;
130
+ this.user = config.user;
131
+ this.password = config.password;
132
+ this.client = null;
133
+ this.ssl = config.ssl;
134
+ this.logTable = config.logTable;
135
+
136
+ // Parse resources configuration
137
+ this.resources = this.parseResourcesConfig(resources);
138
+ }
139
+
140
+ parseResourcesConfig(resources) {
141
+ const parsed = {};
142
+
143
+ for (const [resourceName, config] of Object.entries(resources)) {
144
+ if (typeof config === 'string') {
145
+ // Short form: just table name
146
+ parsed[resourceName] = [{
147
+ table: config,
148
+ actions: ['insert']
149
+ }];
150
+ } else if (Array.isArray(config)) {
151
+ // Array form: multiple table mappings
152
+ parsed[resourceName] = config.map(item => {
153
+ if (typeof item === 'string') {
154
+ return { table: item, actions: ['insert'] };
155
+ }
156
+ return {
157
+ table: item.table,
158
+ actions: item.actions || ['insert']
159
+ };
160
+ });
161
+ } else if (typeof config === 'object') {
162
+ // Single object form
163
+ parsed[resourceName] = [{
164
+ table: config.table,
165
+ actions: config.actions || ['insert']
166
+ }];
167
+ }
168
+ }
169
+
170
+ return parsed;
171
+ }
172
+
173
+ validateConfig() {
174
+ const errors = [];
175
+ if (!this.connectionString && (!this.host || !this.database)) {
176
+ errors.push('Either connectionString or host+database must be provided');
177
+ }
178
+ if (Object.keys(this.resources).length === 0) {
179
+ errors.push('At least one resource must be configured');
180
+ }
181
+
182
+ // Validate resource configurations
183
+ for (const [resourceName, tables] of Object.entries(this.resources)) {
184
+ for (const tableConfig of tables) {
185
+ if (!tableConfig.table) {
186
+ errors.push(`Table name is required for resource '${resourceName}'`);
187
+ }
188
+ if (!Array.isArray(tableConfig.actions) || tableConfig.actions.length === 0) {
189
+ errors.push(`Actions array is required for resource '${resourceName}'`);
190
+ }
191
+ const validActions = ['insert', 'update', 'delete'];
192
+ const invalidActions = tableConfig.actions.filter(action => !validActions.includes(action));
193
+ if (invalidActions.length > 0) {
194
+ errors.push(`Invalid actions for resource '${resourceName}': ${invalidActions.join(', ')}. Valid actions: ${validActions.join(', ')}`);
195
+ }
196
+ }
197
+ }
198
+
199
+ return { isValid: errors.length === 0, errors };
200
+ }
201
+
202
+ async initialize(database) {
203
+ await super.initialize(database);
204
+ const [ok, err, sdk] = await tryFn(() => import('pg'));
205
+ if (!ok) {
206
+ this.emit('initialization_error', {
207
+ replicator: this.name,
208
+ error: err.message
209
+ });
210
+ throw err;
211
+ }
212
+ const { Client } = sdk;
213
+ const config = this.connectionString ? {
214
+ connectionString: this.connectionString,
215
+ ssl: this.ssl
216
+ } : {
217
+ host: this.host,
218
+ port: this.port,
219
+ database: this.database,
220
+ user: this.user,
221
+ password: this.password,
222
+ ssl: this.ssl
223
+ };
224
+ this.client = new Client(config);
225
+ await this.client.connect();
226
+ // Create log table if configured
227
+ if (this.logTable) {
228
+ await this.createLogTableIfNotExists();
229
+ }
230
+ this.emit('initialized', {
231
+ replicator: this.name,
232
+ database: this.database || 'postgres',
233
+ resources: Object.keys(this.resources)
234
+ });
235
+ }
236
+
237
+ async createLogTableIfNotExists() {
238
+ const createTableQuery = `
239
+ CREATE TABLE IF NOT EXISTS ${this.logTable} (
240
+ id SERIAL PRIMARY KEY,
241
+ resource_name VARCHAR(255) NOT NULL,
242
+ operation VARCHAR(50) NOT NULL,
243
+ record_id VARCHAR(255) NOT NULL,
244
+ data JSONB,
245
+ timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
246
+ source VARCHAR(100) DEFAULT 's3db-replicator',
247
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
248
+ );
249
+ CREATE INDEX IF NOT EXISTS idx_${this.logTable}_resource_name ON ${this.logTable}(resource_name);
250
+ CREATE INDEX IF NOT EXISTS idx_${this.logTable}_operation ON ${this.logTable}(operation);
251
+ CREATE INDEX IF NOT EXISTS idx_${this.logTable}_record_id ON ${this.logTable}(record_id);
252
+ CREATE INDEX IF NOT EXISTS idx_${this.logTable}_timestamp ON ${this.logTable}(timestamp);
253
+ `;
254
+ await this.client.query(createTableQuery);
255
+ }
256
+
257
+ shouldReplicateResource(resourceName) {
258
+ return this.resources.hasOwnProperty(resourceName);
259
+ }
260
+
261
+ shouldReplicateAction(resourceName, operation) {
262
+ if (!this.resources[resourceName]) return false;
263
+
264
+ return this.resources[resourceName].some(tableConfig =>
265
+ tableConfig.actions.includes(operation)
266
+ );
267
+ }
268
+
269
+ getTablesForResource(resourceName, operation) {
270
+ if (!this.resources[resourceName]) return [];
271
+
272
+ return this.resources[resourceName]
273
+ .filter(tableConfig => tableConfig.actions.includes(operation))
274
+ .map(tableConfig => tableConfig.table);
275
+ }
276
+
277
+ async replicate(resourceName, operation, data, id, beforeData = null) {
278
+ if (!this.enabled || !this.shouldReplicateResource(resourceName)) {
279
+ return { skipped: true, reason: 'resource_not_included' };
280
+ }
281
+
282
+ if (!this.shouldReplicateAction(resourceName, operation)) {
283
+ return { skipped: true, reason: 'action_not_included' };
284
+ }
285
+
286
+ const tables = this.getTablesForResource(resourceName, operation);
287
+ if (tables.length === 0) {
288
+ return { skipped: true, reason: 'no_tables_for_action' };
289
+ }
290
+
291
+ const results = [];
292
+ const errors = [];
293
+
294
+ const [ok, err, result] = await tryFn(async () => {
295
+ // Replicate to all applicable tables
296
+ for (const table of tables) {
297
+ const [okTable, errTable] = await tryFn(async () => {
298
+ let result;
299
+
300
+ if (operation === 'insert') {
301
+ // INSERT INTO table (col1, col2, ...) VALUES (...)
302
+ const keys = Object.keys(data);
303
+ const values = keys.map(k => data[k]);
304
+ const columns = keys.map(k => `"${k}"`).join(', ');
305
+ const params = keys.map((_, i) => `$${i + 1}`).join(', ');
306
+ const sql = `INSERT INTO ${table} (${columns}) VALUES (${params}) ON CONFLICT (id) DO NOTHING RETURNING *`;
307
+ result = await this.client.query(sql, values);
308
+ } else if (operation === 'update') {
309
+ // UPDATE table SET col1=$1, col2=$2 ... WHERE id=$N
310
+ const keys = Object.keys(data).filter(k => k !== 'id');
311
+ const setClause = keys.map((k, i) => `"${k}"=$${i + 1}`).join(', ');
312
+ const values = keys.map(k => data[k]);
313
+ values.push(id);
314
+ const sql = `UPDATE ${table} SET ${setClause} WHERE id=$${keys.length + 1} RETURNING *`;
315
+ result = await this.client.query(sql, values);
316
+ } else if (operation === 'delete') {
317
+ // DELETE FROM table WHERE id=$1
318
+ const sql = `DELETE FROM ${table} WHERE id=$1 RETURNING *`;
319
+ result = await this.client.query(sql, [id]);
320
+ } else {
321
+ throw new Error(`Unsupported operation: ${operation}`);
322
+ }
323
+
324
+ results.push({
325
+ table,
326
+ success: true,
327
+ rows: result.rows,
328
+ rowCount: result.rowCount
329
+ });
330
+ });
331
+ if (!okTable) {
332
+ errors.push({
333
+ table,
334
+ error: errTable.message
335
+ });
336
+ }
337
+ }
338
+ // Log operation if logTable is configured
339
+ if (this.logTable) {
340
+ const [okLog, errLog] = await tryFn(async () => {
341
+ await this.client.query(
342
+ `INSERT INTO ${this.logTable} (resource_name, operation, record_id, data, timestamp, source) VALUES ($1, $2, $3, $4, $5, $6)`,
343
+ [resourceName, operation, id, JSON.stringify(data), new Date().toISOString(), 's3db-replicator']
344
+ );
345
+ });
346
+ if (!okLog) {
347
+ // Don't fail the main operation if logging fails
348
+ }
349
+ }
350
+ const success = errors.length === 0;
351
+ this.emit('replicated', {
352
+ replicator: this.name,
353
+ resourceName,
354
+ operation,
355
+ id,
356
+ tables,
357
+ results,
358
+ errors,
359
+ success
360
+ });
361
+ return {
362
+ success,
363
+ results,
364
+ errors,
365
+ tables
366
+ };
367
+ });
368
+ if (ok) return result;
369
+ this.emit('replicator_error', {
370
+ replicator: this.name,
371
+ resourceName,
372
+ operation,
373
+ id,
374
+ error: err.message
375
+ });
376
+ return { success: false, error: err.message };
377
+ }
378
+
379
+ async replicateBatch(resourceName, records) {
380
+ const results = [];
381
+ const errors = [];
382
+
383
+ for (const record of records) {
384
+ const [ok, err, res] = await tryFn(() => this.replicate(
385
+ resourceName,
386
+ record.operation,
387
+ record.data,
388
+ record.id,
389
+ record.beforeData
390
+ ));
391
+ if (ok) results.push(res);
392
+ else errors.push({ id: record.id, error: err.message });
393
+ }
394
+
395
+ return {
396
+ success: errors.length === 0,
397
+ results,
398
+ errors
399
+ };
400
+ }
401
+
402
+ async testConnection() {
403
+ const [ok, err] = await tryFn(async () => {
404
+ if (!this.client) await this.initialize();
405
+ await this.client.query('SELECT 1');
406
+ return true;
407
+ });
408
+ if (ok) return true;
409
+ this.emit('connection_error', { replicator: this.name, error: err.message });
410
+ return false;
411
+ }
412
+
413
+ async cleanup() {
414
+ if (this.client) await this.client.end();
415
+ }
416
+
417
+ getStatus() {
418
+ return {
419
+ ...super.getStatus(),
420
+ database: this.database || 'postgres',
421
+ resources: this.resources,
422
+ logTable: this.logTable
423
+ };
424
+ }
425
+ }
426
+
427
+ export default PostgresReplicator;