s3db.js 11.3.2 → 12.0.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.
Files changed (83) hide show
  1. package/README.md +102 -8
  2. package/dist/s3db.cjs.js +36945 -15510
  3. package/dist/s3db.cjs.js.map +1 -1
  4. package/dist/s3db.d.ts +66 -1
  5. package/dist/s3db.es.js +36914 -15534
  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 +35 -15
  11. package/src/behaviors/user-managed.js +13 -6
  12. package/src/client.class.js +79 -49
  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 +97 -47
  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 +544 -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 +354 -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/replicator.plugin.js +2 -1
  55. package/src/plugins/replicators/bigquery-replicator.class.js +180 -8
  56. package/src/plugins/replicators/dynamodb-replicator.class.js +383 -0
  57. package/src/plugins/replicators/index.js +28 -3
  58. package/src/plugins/replicators/mongodb-replicator.class.js +391 -0
  59. package/src/plugins/replicators/mysql-replicator.class.js +558 -0
  60. package/src/plugins/replicators/planetscale-replicator.class.js +409 -0
  61. package/src/plugins/replicators/postgres-replicator.class.js +182 -7
  62. package/src/plugins/replicators/s3db-replicator.class.js +1 -12
  63. package/src/plugins/replicators/schema-sync.helper.js +601 -0
  64. package/src/plugins/replicators/sqs-replicator.class.js +11 -9
  65. package/src/plugins/replicators/turso-replicator.class.js +416 -0
  66. package/src/plugins/replicators/webhook-replicator.class.js +612 -0
  67. package/src/plugins/state-machine.plugin.js +122 -68
  68. package/src/plugins/tfstate/README.md +745 -0
  69. package/src/plugins/tfstate/base-driver.js +80 -0
  70. package/src/plugins/tfstate/errors.js +112 -0
  71. package/src/plugins/tfstate/filesystem-driver.js +129 -0
  72. package/src/plugins/tfstate/index.js +2660 -0
  73. package/src/plugins/tfstate/s3-driver.js +192 -0
  74. package/src/plugins/ttl.plugin.js +536 -0
  75. package/src/resource.class.js +315 -36
  76. package/src/s3db.d.ts +66 -1
  77. package/src/schema.class.js +366 -32
  78. package/SECURITY.md +0 -76
  79. package/src/partition-drivers/base-partition-driver.js +0 -106
  80. package/src/partition-drivers/index.js +0 -66
  81. package/src/partition-drivers/memory-partition-driver.js +0 -289
  82. package/src/partition-drivers/sqs-partition-driver.js +0 -337
  83. package/src/partition-drivers/sync-partition-driver.js +0 -38
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Money Encoding/Decoding - Integer-based (Banking Standard)
3
+ *
4
+ * IMPORTANT: Money should NEVER use floats/decimals due to precision errors.
5
+ * Always store as integers in smallest currency unit (cents, satoshis, etc).
6
+ *
7
+ * Examples:
8
+ * $19.99 USD → 1999 cents → encoded as "$w7"
9
+ * 0.00012345 BTC → 12345 satoshis → encoded as "$3d9"
10
+ *
11
+ * Benefits:
12
+ * - Zero precision loss (no 0.1 + 0.2 = 0.30000004 bugs)
13
+ * - Faster integer arithmetic
14
+ * - Banking industry standard
15
+ * - 40-67% compression vs JSON floats
16
+ */
17
+
18
+ import { encode, decode } from './base62.js';
19
+
20
+ /**
21
+ * Currency decimal places (number of decimals in smallest unit)
22
+ *
23
+ * Fiat currencies:
24
+ * - Most: 2 decimals (cents)
25
+ * - Some: 0 decimals (yen, won)
26
+ *
27
+ * Cryptocurrencies:
28
+ * - BTC: 8 decimals (satoshis)
29
+ * - ETH: 18 decimals (wei) - but commonly use 9 (gwei)
30
+ * - Stablecoins: 6-8 decimals
31
+ */
32
+ export const CURRENCY_DECIMALS = {
33
+ // Fiat with cents (2 decimals)
34
+ 'USD': 2, 'BRL': 2, 'EUR': 2, 'GBP': 2, 'CAD': 2, 'AUD': 2,
35
+ 'MXN': 2, 'ARS': 2, 'COP': 2, 'PEN': 2, 'UYU': 2,
36
+ 'CHF': 2, 'SEK': 2, 'NOK': 2, 'DKK': 2, 'PLN': 2, 'CZK': 2,
37
+ 'HUF': 2, 'RON': 2, 'BGN': 2, 'HRK': 2, 'RSD': 2, 'TRY': 2,
38
+ 'ZAR': 2, 'EGP': 2, 'NGN': 2, 'KES': 2, 'GHS': 2,
39
+ 'INR': 2, 'PKR': 2, 'BDT': 2, 'LKR': 2, 'NPR': 2,
40
+ 'THB': 2, 'MYR': 2, 'SGD': 2, 'PHP': 2, 'IDR': 2,
41
+ 'CNY': 2, 'HKD': 2, 'TWD': 2,
42
+ 'ILS': 2, 'SAR': 2, 'AED': 2, 'QAR': 2, 'KWD': 3,
43
+ 'RUB': 2, 'UAH': 2, 'KZT': 2,
44
+
45
+ // Fiat without decimals
46
+ 'JPY': 0, // Japanese Yen
47
+ 'KRW': 0, // Korean Won
48
+ 'VND': 0, // Vietnamese Dong
49
+ 'CLP': 0, // Chilean Peso
50
+ 'ISK': 0, // Icelandic Króna
51
+ 'PYG': 0, // Paraguayan Guaraní
52
+
53
+ // Cryptocurrencies
54
+ 'BTC': 8, // Bitcoin (satoshis)
55
+ 'ETH': 18, // Ethereum (wei) - often use 9 for gwei
56
+ 'GWEI': 9, // Ethereum gwei (common unit)
57
+ 'USDT': 6, // Tether
58
+ 'USDC': 6, // USD Coin
59
+ 'BUSD': 18, // Binance USD
60
+ 'DAI': 18, // Dai
61
+ 'BNB': 18, // Binance Coin
62
+ 'XRP': 6, // Ripple
63
+ 'ADA': 6, // Cardano
64
+ 'SOL': 9, // Solana
65
+ 'MATIC': 18, // Polygon
66
+ 'AVAX': 18, // Avalanche
67
+ 'DOT': 10, // Polkadot
68
+ 'LINK': 18, // Chainlink
69
+ 'UNI': 18, // Uniswap
70
+ };
71
+
72
+ /**
73
+ * Get decimal places for a currency
74
+ * @param {string} currency - Currency code (e.g., 'USD', 'BTC')
75
+ * @returns {number} Number of decimal places
76
+ */
77
+ export function getCurrencyDecimals(currency) {
78
+ const normalized = currency.toUpperCase();
79
+ return CURRENCY_DECIMALS[normalized] ?? 2; // Default to 2 (cents)
80
+ }
81
+
82
+ /**
83
+ * Encode money value to integer-based base62
84
+ *
85
+ * @param {number} value - Decimal value (e.g., 19.99)
86
+ * @param {string} currency - Currency code (default: 'USD')
87
+ * @returns {string} Encoded string with '$' prefix
88
+ *
89
+ * @throws {Error} If value is negative
90
+ *
91
+ * @example
92
+ * encodeMoney(19.99, 'USD') // → "$w7" (1999 cents)
93
+ * encodeMoney(1000.50, 'BRL') // → "$6Dl" (100050 centavos)
94
+ * encodeMoney(0.00012345, 'BTC') // → "$3d9" (12345 satoshis)
95
+ */
96
+ export function encodeMoney(value, currency = 'USD') {
97
+ if (value === null || value === undefined) return value;
98
+ if (typeof value !== 'number' || isNaN(value)) return value;
99
+ if (!isFinite(value)) return value;
100
+
101
+ // Money cannot be negative (validation should happen at schema level)
102
+ if (value < 0) {
103
+ throw new Error(`Money value cannot be negative: ${value}`);
104
+ }
105
+
106
+ const decimals = getCurrencyDecimals(currency);
107
+ const multiplier = Math.pow(10, decimals);
108
+
109
+ // Convert to smallest unit (cents, satoshis, wei, etc)
110
+ // Use Math.round to handle floating point precision issues
111
+ const integerValue = Math.round(value * multiplier);
112
+
113
+ // Encode as pure integer using base62
114
+ return '$' + encode(integerValue);
115
+ }
116
+
117
+ /**
118
+ * Decode money from base62 to decimal value
119
+ *
120
+ * @param {string} encoded - Encoded string (must start with '$')
121
+ * @param {string} currency - Currency code (default: 'USD')
122
+ * @returns {number} Decoded decimal value
123
+ *
124
+ * @example
125
+ * decodeMoney('$w7', 'USD') // → 19.99
126
+ * decodeMoney('$6Dl', 'BRL') // → 1000.50
127
+ * decodeMoney('$3d9', 'BTC') // → 0.00012345
128
+ */
129
+ export function decodeMoney(encoded, currency = 'USD') {
130
+ if (typeof encoded !== 'string') return encoded;
131
+ if (!encoded.startsWith('$')) return encoded;
132
+
133
+ const integerValue = decode(encoded.slice(1));
134
+ if (isNaN(integerValue)) return NaN;
135
+
136
+ const decimals = getCurrencyDecimals(currency);
137
+ const divisor = Math.pow(10, decimals);
138
+
139
+ // Convert back to decimal
140
+ return integerValue / divisor;
141
+ }
142
+
143
+ /**
144
+ * Validate if a currency code is supported
145
+ * @param {string} currency - Currency code
146
+ * @returns {boolean} True if supported
147
+ */
148
+ export function isSupportedCurrency(currency) {
149
+ const normalized = currency.toUpperCase();
150
+ return normalized in CURRENCY_DECIMALS;
151
+ }
152
+
153
+ /**
154
+ * Get list of all supported currencies
155
+ * @returns {string[]} Array of currency codes
156
+ */
157
+ export function getSupportedCurrencies() {
158
+ return Object.keys(CURRENCY_DECIMALS);
159
+ }
160
+
161
+ /**
162
+ * Format money value for display
163
+ * @param {number} value - Decimal value
164
+ * @param {string} currency - Currency code
165
+ * @param {string} locale - Locale for formatting (default: 'en-US')
166
+ * @returns {string} Formatted money string
167
+ *
168
+ * @example
169
+ * formatMoney(19.99, 'USD') // → "$19.99"
170
+ * formatMoney(1000.50, 'BRL', 'pt-BR') // → "R$ 1.000,50"
171
+ * formatMoney(0.00012345, 'BTC') // → "0.00012345 BTC"
172
+ */
173
+ export function formatMoney(value, currency = 'USD', locale = 'en-US') {
174
+ const decimals = getCurrencyDecimals(currency);
175
+
176
+ // For fiat currencies, use Intl.NumberFormat
177
+ if (decimals <= 3 && currency !== 'BTC' && !currency.includes('USDT')) {
178
+ try {
179
+ return new Intl.NumberFormat(locale, {
180
+ style: 'currency',
181
+ currency: currency,
182
+ minimumFractionDigits: decimals,
183
+ maximumFractionDigits: decimals
184
+ }).format(value);
185
+ } catch (err) {
186
+ // Fallback for unsupported currencies
187
+ return `${value.toFixed(decimals)} ${currency}`;
188
+ }
189
+ }
190
+
191
+ // For crypto, just show the value with correct decimals
192
+ return `${value.toFixed(decimals)} ${currency}`;
193
+ }
@@ -1,5 +1,6 @@
1
1
  import { EventEmitter } from 'events';
2
2
  import { PartitionDriverError } from '../errors.js';
3
+ import tryFn from './try-fn.js';
3
4
 
4
5
  /**
5
6
  * Robust partition operation queue with retry and persistence
@@ -52,17 +53,19 @@ export class PartitionQueue extends EventEmitter {
52
53
 
53
54
  while (this.queue.length > 0) {
54
55
  const item = this.queue.shift();
55
-
56
- try {
56
+
57
+ const [ok, error] = await tryFn(async () => {
57
58
  await this.executeOperation(item);
58
59
  item.status = 'completed';
59
60
  this.emit('success', item);
60
-
61
+
61
62
  // Remove from persistence
62
63
  if (this.persistence) {
63
64
  await this.persistence.remove(item.id);
64
65
  }
65
- } catch (error) {
66
+ });
67
+
68
+ if (!ok) {
66
69
  item.retries++;
67
70
  item.lastError = error;
68
71
 
@@ -174,16 +174,19 @@ export class PluginStorage {
174
174
 
175
175
  // If has body, merge with metadata
176
176
  if (response.Body) {
177
- try {
177
+ const [ok, parseErr, result] = await tryFn(async () => {
178
178
  const bodyContent = await response.Body.transformToString();
179
179
 
180
180
  // Only parse if body has content
181
181
  if (bodyContent && bodyContent.trim()) {
182
182
  const body = JSON.parse(bodyContent);
183
183
  // Body takes precedence over metadata for same keys
184
- data = { ...parsedMetadata, ...body };
184
+ return { ...parsedMetadata, ...body };
185
185
  }
186
- } catch (parseErr) {
186
+ return parsedMetadata;
187
+ });
188
+
189
+ if (!ok) {
187
190
  throw new PluginStorageError(`Failed to parse JSON body`, {
188
191
  pluginSlug: this.pluginSlug,
189
192
  key,
@@ -192,6 +195,8 @@ export class PluginStorage {
192
195
  suggestion: 'Body content may be corrupted. Check S3 object integrity'
193
196
  });
194
197
  }
198
+
199
+ data = result;
195
200
  }
196
201
 
197
202
  // Check TTL expiration (S3 lowercases metadata keys)
@@ -224,12 +229,12 @@ export class PluginStorage {
224
229
  (value.startsWith('{') && value.endsWith('}')) ||
225
230
  (value.startsWith('[') && value.endsWith(']'))
226
231
  ) {
227
- try {
228
- parsed[key] = JSON.parse(value);
232
+ const [ok, err, result] = tryFn(() => JSON.parse(value));
233
+ if (ok) {
234
+ parsed[key] = result;
229
235
  continue;
230
- } catch {
231
- // Not JSON, keep as string
232
236
  }
237
+ // Not JSON, keep as string
233
238
  }
234
239
 
235
240
  // Try to parse as number
@@ -373,15 +378,20 @@ export class PluginStorage {
373
378
  let data = parsedMetadata;
374
379
 
375
380
  if (response.Body) {
376
- try {
381
+ const [ok, err, result] = await tryFn(async () => {
377
382
  const bodyContent = await response.Body.transformToString();
378
383
  if (bodyContent && bodyContent.trim()) {
379
384
  const body = JSON.parse(bodyContent);
380
- data = { ...parsedMetadata, ...body };
385
+ return { ...parsedMetadata, ...body };
381
386
  }
382
- } catch {
383
- return true;
387
+ return parsedMetadata;
388
+ });
389
+
390
+ if (!ok) {
391
+ return true; // Parse error = expired
384
392
  }
393
+
394
+ data = result;
385
395
  }
386
396
 
387
397
  // S3 lowercases metadata keys
@@ -412,15 +422,20 @@ export class PluginStorage {
412
422
  let data = parsedMetadata;
413
423
 
414
424
  if (response.Body) {
415
- try {
425
+ const [ok, err, result] = await tryFn(async () => {
416
426
  const bodyContent = await response.Body.transformToString();
417
427
  if (bodyContent && bodyContent.trim()) {
418
428
  const body = JSON.parse(bodyContent);
419
- data = { ...parsedMetadata, ...body };
429
+ return { ...parsedMetadata, ...body };
420
430
  }
421
- } catch {
422
- return null;
431
+ return parsedMetadata;
432
+ });
433
+
434
+ if (!ok) {
435
+ return null; // Parse error
423
436
  }
437
+
438
+ data = result;
424
439
  }
425
440
 
426
441
  // S3 lowercases metadata keys
@@ -441,7 +456,9 @@ export class PluginStorage {
441
456
  * @returns {Promise<boolean>} True if extended, false if not found or no TTL
442
457
  */
443
458
  async touch(key, additionalSeconds) {
444
- const [ok, err, response] = await tryFn(() => this.client.getObject(key));
459
+ // Optimization: Use HEAD + COPY instead of GET + PUT for metadata-only updates
460
+ // This avoids transferring the body when only updating the TTL
461
+ const [ok, err, response] = await tryFn(() => this.client.headObject(key));
445
462
 
446
463
  if (!ok) {
447
464
  return false;
@@ -450,45 +467,34 @@ export class PluginStorage {
450
467
  const metadata = response.Metadata || {};
451
468
  const parsedMetadata = this._parseMetadataValues(metadata);
452
469
 
453
- let data = parsedMetadata;
454
-
455
- if (response.Body) {
456
- try {
457
- const bodyContent = await response.Body.transformToString();
458
- if (bodyContent && bodyContent.trim()) {
459
- const body = JSON.parse(bodyContent);
460
- data = { ...parsedMetadata, ...body };
461
- }
462
- } catch {
463
- return false;
464
- }
465
- }
466
-
467
470
  // S3 lowercases metadata keys
468
- const expiresAt = data._expiresat || data._expiresAt;
471
+ const expiresAt = parsedMetadata._expiresat || parsedMetadata._expiresAt;
469
472
  if (!expiresAt) {
470
473
  return false; // No TTL to extend
471
474
  }
472
475
 
473
476
  // Extend TTL - use the standard field name (will be lowercased by S3)
474
- data._expiresAt = expiresAt + (additionalSeconds * 1000);
475
- delete data._expiresat; // Remove lowercased version
476
-
477
- // Save back (reuse same behavior)
478
- const { metadata: newMetadata, body: newBody } = this._applyBehavior(data, 'body-overflow');
479
-
480
- const putParams = {
481
- key,
482
- metadata: newMetadata,
483
- contentType: 'application/json'
484
- };
485
-
486
- if (newBody !== null) {
487
- putParams.body = JSON.stringify(newBody);
477
+ parsedMetadata._expiresAt = expiresAt + (additionalSeconds * 1000);
478
+ delete parsedMetadata._expiresat; // Remove lowercased version
479
+
480
+ // Encode metadata for S3
481
+ const encodedMetadata = {};
482
+ for (const [metaKey, metaValue] of Object.entries(parsedMetadata)) {
483
+ const { encoded } = metadataEncode(metaValue);
484
+ encodedMetadata[metaKey] = encoded;
488
485
  }
489
486
 
490
- const [putOk] = await tryFn(() => this.client.putObject(putParams));
491
- return putOk;
487
+ // Use COPY with MetadataDirective: REPLACE to update metadata atomically
488
+ // This preserves the body without re-transferring it
489
+ const [copyOk] = await tryFn(() => this.client.copyObject({
490
+ from: key,
491
+ to: key,
492
+ metadata: encodedMetadata,
493
+ metadataDirective: 'REPLACE',
494
+ contentType: response.ContentType || 'application/json'
495
+ }));
496
+
497
+ return copyOk;
492
498
  }
493
499
 
494
500
  /**
@@ -655,12 +661,56 @@ export class PluginStorage {
655
661
  /**
656
662
  * Increment a counter value
657
663
  *
664
+ * Optimization: Uses HEAD + COPY for existing counters to avoid body transfer.
665
+ * Falls back to GET + PUT for non-existent counters or those with additional data.
666
+ *
658
667
  * @param {string} key - S3 key
659
668
  * @param {number} amount - Amount to increment (default: 1)
660
669
  * @param {Object} options - Options (e.g., ttl)
661
670
  * @returns {Promise<number>} New value
662
671
  */
663
672
  async increment(key, amount = 1, options = {}) {
673
+ // Try optimized path first: HEAD + COPY for existing counters
674
+ const [headOk, headErr, headResponse] = await tryFn(() => this.client.headObject(key));
675
+
676
+ if (headOk && headResponse.Metadata) {
677
+ // Counter exists, use optimized HEAD + COPY
678
+ const metadata = headResponse.Metadata || {};
679
+ const parsedMetadata = this._parseMetadataValues(metadata);
680
+
681
+ const currentValue = parsedMetadata.value || 0;
682
+ const newValue = currentValue + amount;
683
+
684
+ // Update only the value field
685
+ parsedMetadata.value = newValue;
686
+
687
+ // Handle TTL if specified
688
+ if (options.ttl) {
689
+ parsedMetadata._expiresAt = Date.now() + (options.ttl * 1000);
690
+ }
691
+
692
+ // Encode metadata
693
+ const encodedMetadata = {};
694
+ for (const [metaKey, metaValue] of Object.entries(parsedMetadata)) {
695
+ const { encoded } = metadataEncode(metaValue);
696
+ encodedMetadata[metaKey] = encoded;
697
+ }
698
+
699
+ // Atomic update via COPY
700
+ const [copyOk] = await tryFn(() => this.client.copyObject({
701
+ from: key,
702
+ to: key,
703
+ metadata: encodedMetadata,
704
+ metadataDirective: 'REPLACE',
705
+ contentType: headResponse.ContentType || 'application/json'
706
+ }));
707
+
708
+ if (copyOk) {
709
+ return newValue;
710
+ }
711
+ }
712
+
713
+ // Fallback: counter doesn't exist or has body data, use traditional path
664
714
  const data = await this.get(key);
665
715
  const value = (data?.value || 0) + amount;
666
716
  await this.set(key, { value }, options);