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.
- package/README.md +102 -8
- package/dist/s3db.cjs.js +36945 -15510
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.d.ts +66 -1
- package/dist/s3db.es.js +36914 -15534
- package/dist/s3db.es.js.map +1 -1
- package/mcp/entrypoint.js +58 -0
- package/mcp/tools/documentation.js +434 -0
- package/mcp/tools/index.js +4 -0
- package/package.json +35 -15
- package/src/behaviors/user-managed.js +13 -6
- package/src/client.class.js +79 -49
- package/src/concerns/base62.js +85 -0
- package/src/concerns/dictionary-encoding.js +294 -0
- package/src/concerns/geo-encoding.js +256 -0
- package/src/concerns/high-performance-inserter.js +34 -30
- package/src/concerns/ip.js +325 -0
- package/src/concerns/metadata-encoding.js +345 -66
- package/src/concerns/money.js +193 -0
- package/src/concerns/partition-queue.js +7 -4
- package/src/concerns/plugin-storage.js +97 -47
- package/src/database.class.js +76 -74
- package/src/errors.js +0 -4
- package/src/plugins/api/auth/api-key-auth.js +88 -0
- package/src/plugins/api/auth/basic-auth.js +154 -0
- package/src/plugins/api/auth/index.js +112 -0
- package/src/plugins/api/auth/jwt-auth.js +169 -0
- package/src/plugins/api/index.js +544 -0
- package/src/plugins/api/middlewares/index.js +15 -0
- package/src/plugins/api/middlewares/validator.js +185 -0
- package/src/plugins/api/routes/auth-routes.js +241 -0
- package/src/plugins/api/routes/resource-routes.js +304 -0
- package/src/plugins/api/server.js +354 -0
- package/src/plugins/api/utils/error-handler.js +147 -0
- package/src/plugins/api/utils/openapi-generator.js +1240 -0
- package/src/plugins/api/utils/response-formatter.js +218 -0
- package/src/plugins/backup/streaming-exporter.js +132 -0
- package/src/plugins/backup.plugin.js +103 -50
- package/src/plugins/cache/s3-cache.class.js +95 -47
- package/src/plugins/cache.plugin.js +107 -9
- package/src/plugins/concerns/plugin-dependencies.js +313 -0
- package/src/plugins/concerns/prometheus-formatter.js +255 -0
- package/src/plugins/consumers/rabbitmq-consumer.js +4 -0
- package/src/plugins/consumers/sqs-consumer.js +4 -0
- package/src/plugins/costs.plugin.js +255 -39
- package/src/plugins/eventual-consistency/helpers.js +15 -1
- package/src/plugins/geo.plugin.js +873 -0
- package/src/plugins/importer/index.js +1020 -0
- package/src/plugins/index.js +11 -0
- package/src/plugins/metrics.plugin.js +163 -4
- package/src/plugins/queue-consumer.plugin.js +6 -27
- package/src/plugins/relation.errors.js +139 -0
- package/src/plugins/relation.plugin.js +1242 -0
- package/src/plugins/replicator.plugin.js +2 -1
- package/src/plugins/replicators/bigquery-replicator.class.js +180 -8
- package/src/plugins/replicators/dynamodb-replicator.class.js +383 -0
- package/src/plugins/replicators/index.js +28 -3
- package/src/plugins/replicators/mongodb-replicator.class.js +391 -0
- package/src/plugins/replicators/mysql-replicator.class.js +558 -0
- package/src/plugins/replicators/planetscale-replicator.class.js +409 -0
- package/src/plugins/replicators/postgres-replicator.class.js +182 -7
- package/src/plugins/replicators/s3db-replicator.class.js +1 -12
- package/src/plugins/replicators/schema-sync.helper.js +601 -0
- package/src/plugins/replicators/sqs-replicator.class.js +11 -9
- package/src/plugins/replicators/turso-replicator.class.js +416 -0
- package/src/plugins/replicators/webhook-replicator.class.js +612 -0
- package/src/plugins/state-machine.plugin.js +122 -68
- package/src/plugins/tfstate/README.md +745 -0
- package/src/plugins/tfstate/base-driver.js +80 -0
- package/src/plugins/tfstate/errors.js +112 -0
- package/src/plugins/tfstate/filesystem-driver.js +129 -0
- package/src/plugins/tfstate/index.js +2660 -0
- package/src/plugins/tfstate/s3-driver.js +192 -0
- package/src/plugins/ttl.plugin.js +536 -0
- package/src/resource.class.js +315 -36
- package/src/s3db.d.ts +66 -1
- package/src/schema.class.js +366 -32
- package/SECURITY.md +0 -76
- package/src/partition-drivers/base-partition-driver.js +0 -106
- package/src/partition-drivers/index.js +0 -66
- package/src/partition-drivers/memory-partition-driver.js +0 -289
- package/src/partition-drivers/sqs-partition-driver.js +0 -337
- 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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
184
|
+
return { ...parsedMetadata, ...body };
|
|
185
185
|
}
|
|
186
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
385
|
+
return { ...parsedMetadata, ...body };
|
|
381
386
|
}
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
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
|
-
|
|
429
|
+
return { ...parsedMetadata, ...body };
|
|
420
430
|
}
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
475
|
-
delete
|
|
476
|
-
|
|
477
|
-
//
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
491
|
-
|
|
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);
|