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.
- package/PLUGINS.md +2724 -0
- package/README.md +377 -492
- package/UNLICENSE +24 -0
- package/dist/s3db.cjs.js +30054 -18189
- package/dist/s3db.cjs.min.js +1 -1
- package/dist/s3db.d.ts +373 -72
- package/dist/s3db.es.js +30040 -18186
- package/dist/s3db.es.min.js +1 -1
- package/dist/s3db.iife.js +29727 -17863
- package/dist/s3db.iife.min.js +1 -1
- package/package.json +44 -69
- package/src/behaviors/body-only.js +110 -0
- package/src/behaviors/body-overflow.js +153 -0
- package/src/behaviors/enforce-limits.js +195 -0
- package/src/behaviors/index.js +39 -0
- package/src/behaviors/truncate-data.js +204 -0
- package/src/behaviors/user-managed.js +147 -0
- package/src/client.class.js +515 -0
- package/src/concerns/base62.js +61 -0
- package/src/concerns/calculator.js +204 -0
- package/src/concerns/crypto.js +142 -0
- package/src/concerns/id.js +8 -0
- package/src/concerns/index.js +5 -0
- package/src/concerns/try-fn.js +151 -0
- package/src/connection-string.class.js +75 -0
- package/src/database.class.js +599 -0
- package/src/errors.js +261 -0
- package/src/index.js +17 -0
- package/src/plugins/audit.plugin.js +442 -0
- package/src/plugins/cache/cache.class.js +53 -0
- package/src/plugins/cache/index.js +6 -0
- package/src/plugins/cache/memory-cache.class.js +164 -0
- package/src/plugins/cache/s3-cache.class.js +189 -0
- package/src/plugins/cache.plugin.js +275 -0
- package/src/plugins/consumers/index.js +24 -0
- package/src/plugins/consumers/rabbitmq-consumer.js +56 -0
- package/src/plugins/consumers/sqs-consumer.js +102 -0
- package/src/plugins/costs.plugin.js +81 -0
- package/src/plugins/fulltext.plugin.js +473 -0
- package/src/plugins/index.js +12 -0
- package/src/plugins/metrics.plugin.js +603 -0
- package/src/plugins/plugin.class.js +210 -0
- package/src/plugins/plugin.obj.js +13 -0
- package/src/plugins/queue-consumer.plugin.js +134 -0
- package/src/plugins/replicator.plugin.js +769 -0
- package/src/plugins/replicators/base-replicator.class.js +85 -0
- package/src/plugins/replicators/bigquery-replicator.class.js +328 -0
- package/src/plugins/replicators/index.js +44 -0
- package/src/plugins/replicators/postgres-replicator.class.js +427 -0
- package/src/plugins/replicators/s3db-replicator.class.js +352 -0
- package/src/plugins/replicators/sqs-replicator.class.js +427 -0
- package/src/resource.class.js +2626 -0
- package/src/s3db.d.ts +1263 -0
- package/src/schema.class.js +706 -0
- package/src/stream/index.js +16 -0
- package/src/stream/resource-ids-page-reader.class.js +10 -0
- package/src/stream/resource-ids-reader.class.js +63 -0
- package/src/stream/resource-reader.class.js +81 -0
- package/src/stream/resource-writer.class.js +92 -0
- package/src/validator.class.js +97 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calculates the size in bytes of a string using UTF-8 encoding
|
|
3
|
+
* @param {string} str - The string to calculate size for
|
|
4
|
+
* @returns {number} - Size in bytes
|
|
5
|
+
*/
|
|
6
|
+
export function calculateUTF8Bytes(str) {
|
|
7
|
+
if (typeof str !== 'string') {
|
|
8
|
+
str = String(str);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let bytes = 0;
|
|
12
|
+
for (let i = 0; i < str.length; i++) {
|
|
13
|
+
const codePoint = str.codePointAt(i);
|
|
14
|
+
|
|
15
|
+
if (codePoint <= 0x7F) {
|
|
16
|
+
// 1 byte: U+0000 to U+007F (ASCII characters)
|
|
17
|
+
bytes += 1;
|
|
18
|
+
} else if (codePoint <= 0x7FF) {
|
|
19
|
+
// 2 bytes: U+0080 to U+07FF
|
|
20
|
+
bytes += 2;
|
|
21
|
+
} else if (codePoint <= 0xFFFF) {
|
|
22
|
+
// 3 bytes: U+0800 to U+FFFF
|
|
23
|
+
bytes += 3;
|
|
24
|
+
} else if (codePoint <= 0x10FFFF) {
|
|
25
|
+
// 4 bytes: U+10000 to U+10FFFF
|
|
26
|
+
bytes += 4;
|
|
27
|
+
// Skip the next character if it's a surrogate pair
|
|
28
|
+
if (codePoint > 0xFFFF) {
|
|
29
|
+
i++;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return bytes;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Calculates the size in bytes of attribute names (mapped to digits)
|
|
39
|
+
* @param {Object} mappedObject - The object returned by schema.mapper()
|
|
40
|
+
* @returns {number} - Total size of attribute names in bytes
|
|
41
|
+
*/
|
|
42
|
+
export function calculateAttributeNamesSize(mappedObject) {
|
|
43
|
+
let totalSize = 0;
|
|
44
|
+
|
|
45
|
+
for (const key of Object.keys(mappedObject)) {
|
|
46
|
+
totalSize += calculateUTF8Bytes(key);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return totalSize;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Transforms a value according to the schema mapper rules
|
|
54
|
+
* @param {any} value - The value to transform
|
|
55
|
+
* @returns {string} - The transformed value as string
|
|
56
|
+
*/
|
|
57
|
+
export function transformValue(value) {
|
|
58
|
+
if (value === null || value === undefined) {
|
|
59
|
+
return '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (typeof value === 'boolean') {
|
|
63
|
+
return value ? '1' : '0';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (typeof value === 'number') {
|
|
67
|
+
return String(value);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (typeof value === 'string') {
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (Array.isArray(value)) {
|
|
75
|
+
// Handle arrays like in the schema mapper
|
|
76
|
+
if (value.length === 0) {
|
|
77
|
+
return '[]';
|
|
78
|
+
}
|
|
79
|
+
// For simplicity, join with | separator like in the schema
|
|
80
|
+
return value.map(item => String(item)).join('|');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (typeof value === 'object') {
|
|
84
|
+
return JSON.stringify(value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return String(value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Calculates the size in bytes of each attribute in a mapped object
|
|
92
|
+
* @param {Object} mappedObject - The object returned by schema.mapper()
|
|
93
|
+
* @returns {Object} - Object with attribute names as keys and byte sizes as values
|
|
94
|
+
*/
|
|
95
|
+
export function calculateAttributeSizes(mappedObject) {
|
|
96
|
+
const sizes = {};
|
|
97
|
+
|
|
98
|
+
for (const [key, value] of Object.entries(mappedObject)) {
|
|
99
|
+
const transformedValue = transformValue(value);
|
|
100
|
+
const byteSize = calculateUTF8Bytes(transformedValue);
|
|
101
|
+
sizes[key] = byteSize;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return sizes;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Calculates the total size in bytes of a mapped object (including attribute names)
|
|
109
|
+
* @param {Object} mappedObject - The object returned by schema.mapper()
|
|
110
|
+
* @returns {number} - Total size in bytes
|
|
111
|
+
*/
|
|
112
|
+
export function calculateTotalSize(mappedObject) {
|
|
113
|
+
const valueSizes = calculateAttributeSizes(mappedObject);
|
|
114
|
+
const valueTotal = Object.values(valueSizes).reduce((total, size) => total + size, 0);
|
|
115
|
+
|
|
116
|
+
// Add the size of attribute names (digits)
|
|
117
|
+
const namesSize = calculateAttributeNamesSize(mappedObject);
|
|
118
|
+
|
|
119
|
+
return valueTotal + namesSize;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Gets detailed size information for a mapped object
|
|
124
|
+
* @param {Object} mappedObject - The object returned by schema.mapper()
|
|
125
|
+
* @returns {Object} - Object with sizes, total, and breakdown information
|
|
126
|
+
*/
|
|
127
|
+
export function getSizeBreakdown(mappedObject) {
|
|
128
|
+
const valueSizes = calculateAttributeSizes(mappedObject);
|
|
129
|
+
const namesSize = calculateAttributeNamesSize(mappedObject);
|
|
130
|
+
|
|
131
|
+
const valueTotal = Object.values(valueSizes).reduce((sum, size) => sum + size, 0);
|
|
132
|
+
const total = valueTotal + namesSize;
|
|
133
|
+
|
|
134
|
+
// Sort attributes by size (largest first)
|
|
135
|
+
const sortedAttributes = Object.entries(valueSizes)
|
|
136
|
+
.sort(([, a], [, b]) => b - a)
|
|
137
|
+
.map(([key, size]) => ({
|
|
138
|
+
attribute: key,
|
|
139
|
+
size,
|
|
140
|
+
percentage: ((size / total) * 100).toFixed(2) + '%'
|
|
141
|
+
}));
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
total,
|
|
145
|
+
valueSizes,
|
|
146
|
+
namesSize,
|
|
147
|
+
valueTotal,
|
|
148
|
+
breakdown: sortedAttributes,
|
|
149
|
+
// Add detailed breakdown including names
|
|
150
|
+
detailedBreakdown: {
|
|
151
|
+
values: valueTotal,
|
|
152
|
+
names: namesSize,
|
|
153
|
+
total: total
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Calculates the minimum overhead required for system fields
|
|
160
|
+
* @param {Object} config - Configuration object
|
|
161
|
+
* @param {string} [config.version='1'] - Resource version
|
|
162
|
+
* @param {boolean} [config.timestamps=false] - Whether timestamps are enabled
|
|
163
|
+
* @param {string} [config.id=''] - Resource ID (if known)
|
|
164
|
+
* @returns {number} - Minimum overhead in bytes
|
|
165
|
+
*/
|
|
166
|
+
export function calculateSystemOverhead(config = {}) {
|
|
167
|
+
const { version = '1', timestamps = false, id = '' } = config;
|
|
168
|
+
|
|
169
|
+
// System fields that are always present
|
|
170
|
+
const systemFields = {
|
|
171
|
+
'_v': String(version), // Version field (e.g., "1", "10", "100")
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Optional system fields
|
|
175
|
+
if (timestamps) {
|
|
176
|
+
systemFields.createdAt = '2024-01-01T00:00:00.000Z'; // Example timestamp
|
|
177
|
+
systemFields.updatedAt = '2024-01-01T00:00:00.000Z'; // Example timestamp
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (id) {
|
|
181
|
+
systemFields.id = id;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Calculate overhead for system fields
|
|
185
|
+
const overheadObject = {};
|
|
186
|
+
for (const [key, value] of Object.entries(systemFields)) {
|
|
187
|
+
overheadObject[key] = value;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return calculateTotalSize(overheadObject);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Calculates the effective metadata limit considering system overhead
|
|
195
|
+
* @param {Object} config - Configuration object
|
|
196
|
+
* @param {number} [config.s3Limit=2048] - S3 metadata limit in bytes
|
|
197
|
+
* @param {Object} [config.systemConfig] - System configuration for overhead calculation
|
|
198
|
+
* @returns {number} - Effective limit in bytes
|
|
199
|
+
*/
|
|
200
|
+
export function calculateEffectiveLimit(config = {}) {
|
|
201
|
+
const { s3Limit = 2048, systemConfig = {} } = config;
|
|
202
|
+
const overhead = calculateSystemOverhead(systemConfig);
|
|
203
|
+
return s3Limit - overhead;
|
|
204
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { CryptoError } from "../errors.js";
|
|
2
|
+
import tryFn, { tryFnSync } from "./try-fn.js";
|
|
3
|
+
|
|
4
|
+
async function dynamicCrypto() {
|
|
5
|
+
let lib;
|
|
6
|
+
|
|
7
|
+
if (typeof process !== 'undefined') {
|
|
8
|
+
const [ok, err, result] = await tryFn(async () => {
|
|
9
|
+
const { webcrypto } = await import('crypto');
|
|
10
|
+
return webcrypto;
|
|
11
|
+
});
|
|
12
|
+
if (ok) {
|
|
13
|
+
lib = result;
|
|
14
|
+
} else {
|
|
15
|
+
throw new CryptoError('Crypto API not available', { original: err, context: 'dynamicCrypto' });
|
|
16
|
+
}
|
|
17
|
+
} else if (typeof window !== 'undefined') {
|
|
18
|
+
lib = window.crypto;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!lib) throw new CryptoError('Could not load any crypto library', { context: 'dynamicCrypto' });
|
|
22
|
+
return lib;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function sha256(message) {
|
|
26
|
+
const [okCrypto, errCrypto, cryptoLib] = await tryFn(dynamicCrypto);
|
|
27
|
+
if (!okCrypto) throw new CryptoError('Crypto API not available', { original: errCrypto });
|
|
28
|
+
|
|
29
|
+
const encoder = new TextEncoder();
|
|
30
|
+
const data = encoder.encode(message);
|
|
31
|
+
const [ok, err, hashBuffer] = await tryFn(() => cryptoLib.subtle.digest('SHA-256', data));
|
|
32
|
+
if (!ok) throw new CryptoError('SHA-256 digest failed', { original: err, input: message });
|
|
33
|
+
|
|
34
|
+
// Convert buffer to hex string
|
|
35
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
36
|
+
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
37
|
+
|
|
38
|
+
return hashHex;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function encrypt(content, passphrase) {
|
|
42
|
+
const [okCrypto, errCrypto, cryptoLib] = await tryFn(dynamicCrypto);
|
|
43
|
+
if (!okCrypto) throw new CryptoError('Crypto API not available', { original: errCrypto });
|
|
44
|
+
|
|
45
|
+
const salt = cryptoLib.getRandomValues(new Uint8Array(16)); // Generate a random salt
|
|
46
|
+
const [okKey, errKey, key] = await tryFn(() => getKeyMaterial(passphrase, salt));
|
|
47
|
+
if (!okKey) throw new CryptoError('Key derivation failed', { original: errKey, passphrase, salt });
|
|
48
|
+
|
|
49
|
+
const iv = cryptoLib.getRandomValues(new Uint8Array(12)); // 12-byte IV for AES-GCM
|
|
50
|
+
|
|
51
|
+
const encoder = new TextEncoder();
|
|
52
|
+
const encodedContent = encoder.encode(content);
|
|
53
|
+
|
|
54
|
+
const [okEnc, errEnc, encryptedContent] = await tryFn(() => cryptoLib.subtle.encrypt({ name: 'AES-GCM', iv: iv }, key, encodedContent));
|
|
55
|
+
if (!okEnc) throw new CryptoError('Encryption failed', { original: errEnc, content });
|
|
56
|
+
|
|
57
|
+
const encryptedData = new Uint8Array(salt.length + iv.length + encryptedContent.byteLength);
|
|
58
|
+
encryptedData.set(salt); // Prepend salt
|
|
59
|
+
encryptedData.set(iv, salt.length); // Prepend IV after salt
|
|
60
|
+
encryptedData.set(new Uint8Array(encryptedContent), salt.length + iv.length); // Append encrypted content
|
|
61
|
+
|
|
62
|
+
return arrayBufferToBase64(encryptedData);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function decrypt(encryptedBase64, passphrase) {
|
|
66
|
+
const [okCrypto, errCrypto, cryptoLib] = await tryFn(dynamicCrypto);
|
|
67
|
+
if (!okCrypto) throw new CryptoError('Crypto API not available', { original: errCrypto });
|
|
68
|
+
|
|
69
|
+
const encryptedData = base64ToArrayBuffer(encryptedBase64);
|
|
70
|
+
|
|
71
|
+
const salt = encryptedData.slice(0, 16); // Extract salt (first 16 bytes)
|
|
72
|
+
const iv = encryptedData.slice(16, 28); // Extract IV (next 12 bytes)
|
|
73
|
+
const encryptedContent = encryptedData.slice(28); // Remaining is the encrypted content
|
|
74
|
+
|
|
75
|
+
const [okKey, errKey, key] = await tryFn(() => getKeyMaterial(passphrase, salt));
|
|
76
|
+
if (!okKey) throw new CryptoError('Key derivation failed (decrypt)', { original: errKey, passphrase, salt });
|
|
77
|
+
|
|
78
|
+
const [okDec, errDec, decryptedContent] = await tryFn(() => cryptoLib.subtle.decrypt({ name: 'AES-GCM', iv: iv }, key, encryptedContent));
|
|
79
|
+
if (!okDec) throw new CryptoError('Decryption failed', { original: errDec, encryptedBase64 });
|
|
80
|
+
|
|
81
|
+
const decoder = new TextDecoder();
|
|
82
|
+
return decoder.decode(decryptedContent);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function getKeyMaterial(passphrase, salt) {
|
|
86
|
+
const [okCrypto, errCrypto, cryptoLib] = await tryFn(dynamicCrypto);
|
|
87
|
+
if (!okCrypto) throw new CryptoError('Crypto API not available', { original: errCrypto });
|
|
88
|
+
|
|
89
|
+
const encoder = new TextEncoder();
|
|
90
|
+
const keyMaterial = encoder.encode(passphrase); // Convert passphrase to bytes
|
|
91
|
+
|
|
92
|
+
const [okImport, errImport, baseKey] = await tryFn(() => cryptoLib.subtle.importKey(
|
|
93
|
+
'raw',
|
|
94
|
+
keyMaterial,
|
|
95
|
+
{ name: 'PBKDF2' },
|
|
96
|
+
false,
|
|
97
|
+
['deriveKey']
|
|
98
|
+
));
|
|
99
|
+
if (!okImport) throw new CryptoError('importKey failed', { original: errImport, passphrase });
|
|
100
|
+
|
|
101
|
+
const [okDerive, errDerive, derivedKey] = await tryFn(() => cryptoLib.subtle.deriveKey(
|
|
102
|
+
{
|
|
103
|
+
name: 'PBKDF2',
|
|
104
|
+
salt: salt,
|
|
105
|
+
iterations: 100000,
|
|
106
|
+
hash: 'SHA-256'
|
|
107
|
+
},
|
|
108
|
+
baseKey,
|
|
109
|
+
{ name: 'AES-GCM', length: 256 },
|
|
110
|
+
true,
|
|
111
|
+
['encrypt', 'decrypt']
|
|
112
|
+
));
|
|
113
|
+
if (!okDerive) throw new CryptoError('deriveKey failed', { original: errDerive, passphrase, salt });
|
|
114
|
+
return derivedKey;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function arrayBufferToBase64(buffer) {
|
|
118
|
+
if (typeof process !== 'undefined') {
|
|
119
|
+
// Node.js version
|
|
120
|
+
return Buffer.from(buffer).toString('base64');
|
|
121
|
+
} else {
|
|
122
|
+
// Browser version
|
|
123
|
+
const [ok, err, binary] = tryFnSync(() => String.fromCharCode.apply(null, new Uint8Array(buffer)));
|
|
124
|
+
if (!ok) throw new CryptoError('Failed to convert ArrayBuffer to base64 (browser)', { original: err });
|
|
125
|
+
return window.btoa(binary);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function base64ToArrayBuffer(base64) {
|
|
130
|
+
if (typeof process !== 'undefined') {
|
|
131
|
+
return new Uint8Array(Buffer.from(base64, 'base64'));
|
|
132
|
+
} else {
|
|
133
|
+
const [ok, err, binaryString] = tryFnSync(() => window.atob(base64));
|
|
134
|
+
if (!ok) throw new CryptoError('Failed to decode base64 (browser)', { original: err });
|
|
135
|
+
const len = binaryString.length;
|
|
136
|
+
const bytes = new Uint8Array(len);
|
|
137
|
+
for (let i = 0; i < len; i++) {
|
|
138
|
+
bytes[i] = binaryString.charCodeAt(i);
|
|
139
|
+
}
|
|
140
|
+
return bytes;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { customAlphabet, urlAlphabet } from 'nanoid'
|
|
2
|
+
|
|
3
|
+
export const idGenerator = customAlphabet(urlAlphabet, 22)
|
|
4
|
+
|
|
5
|
+
// Password generator using nanoid with custom alphabet for better readability
|
|
6
|
+
// Excludes similar characters (0, O, 1, l, I) to avoid confusion
|
|
7
|
+
const passwordAlphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'
|
|
8
|
+
export const passwordGenerator = customAlphabet(passwordAlphabet, 16)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tryFn - A robust error handling utility for JavaScript functions and values.
|
|
3
|
+
*
|
|
4
|
+
* This utility provides a consistent way to handle errors and return values across different types:
|
|
5
|
+
* - Synchronous functions
|
|
6
|
+
* - Asynchronous functions (Promises)
|
|
7
|
+
* - Direct values
|
|
8
|
+
* - Promises
|
|
9
|
+
* - null/undefined values
|
|
10
|
+
*
|
|
11
|
+
* @param {Function|Promise|*} fnOrPromise - The input to process, can be:
|
|
12
|
+
* - A synchronous function that returns a value
|
|
13
|
+
* - An async function that returns a Promise
|
|
14
|
+
* - A Promise directly
|
|
15
|
+
* - Any direct value (number, string, object, etc)
|
|
16
|
+
*
|
|
17
|
+
* @returns {Array} A tuple containing:
|
|
18
|
+
* - [0] ok: boolean - Indicates if the operation succeeded
|
|
19
|
+
* - [1] err: Error|null - Error object if failed, null if succeeded
|
|
20
|
+
* - [2] data: any - The result data if succeeded, undefined if failed
|
|
21
|
+
*
|
|
22
|
+
* Key Features:
|
|
23
|
+
* - Unified error handling interface for all types of operations
|
|
24
|
+
* - Preserves and enhances error stack traces for better debugging
|
|
25
|
+
* - Zero dependencies
|
|
26
|
+
* - TypeScript friendly return tuple
|
|
27
|
+
* - Handles edge cases like null/undefined gracefully
|
|
28
|
+
* - Perfect for functional programming patterns
|
|
29
|
+
* - Ideal for Promise chains and async/await flows
|
|
30
|
+
* - Reduces try/catch boilerplate code
|
|
31
|
+
*
|
|
32
|
+
* Error Handling:
|
|
33
|
+
* - All errors maintain their original properties
|
|
34
|
+
* - Stack traces are automatically enhanced to show the tryFn call site
|
|
35
|
+
* - Errors from async operations are properly caught and formatted
|
|
36
|
+
*
|
|
37
|
+
* Common Use Cases:
|
|
38
|
+
* - API request wrappers
|
|
39
|
+
* - Database operations
|
|
40
|
+
* - File system operations
|
|
41
|
+
* - Data parsing and validation
|
|
42
|
+
* - Service integration points
|
|
43
|
+
*
|
|
44
|
+
* Examples:
|
|
45
|
+
* ```js
|
|
46
|
+
* // Handling synchronous operations
|
|
47
|
+
* const [ok, err, data] = tryFn(() => JSON.parse(jsonString));
|
|
48
|
+
*
|
|
49
|
+
* // Handling async operations
|
|
50
|
+
* const [ok, err, data] = await tryFn(async () => {
|
|
51
|
+
* const response = await fetch(url);
|
|
52
|
+
* return response.json();
|
|
53
|
+
* });
|
|
54
|
+
*
|
|
55
|
+
* // Direct promise handling
|
|
56
|
+
* const [ok, err, data] = await tryFn(fetch(url));
|
|
57
|
+
*
|
|
58
|
+
* // Value passthrough
|
|
59
|
+
* const [ok, err, data] = tryFn(42); // [true, null, 42]
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export function tryFn(fnOrPromise) {
|
|
63
|
+
if (fnOrPromise == null) {
|
|
64
|
+
const err = new Error('fnOrPromise cannot be null or undefined');
|
|
65
|
+
err.stack = new Error().stack;
|
|
66
|
+
return [false, err, undefined];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (typeof fnOrPromise === 'function') {
|
|
70
|
+
try {
|
|
71
|
+
const result = fnOrPromise();
|
|
72
|
+
|
|
73
|
+
if (result == null) {
|
|
74
|
+
return [true, null, result];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (typeof result.then === 'function') {
|
|
78
|
+
return result
|
|
79
|
+
.then(data => [true, null, data])
|
|
80
|
+
.catch(error => {
|
|
81
|
+
if (
|
|
82
|
+
error instanceof Error &&
|
|
83
|
+
Object.isExtensible(error)
|
|
84
|
+
) {
|
|
85
|
+
const desc = Object.getOwnPropertyDescriptor(error, 'stack');
|
|
86
|
+
if (
|
|
87
|
+
desc && desc.writable && desc.configurable && error.hasOwnProperty('stack')
|
|
88
|
+
) {
|
|
89
|
+
try {
|
|
90
|
+
error.stack = new Error().stack;
|
|
91
|
+
} catch (_) {}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return [false, error, undefined];
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return [true, null, result];
|
|
99
|
+
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (
|
|
102
|
+
error instanceof Error &&
|
|
103
|
+
Object.isExtensible(error)
|
|
104
|
+
) {
|
|
105
|
+
const desc = Object.getOwnPropertyDescriptor(error, 'stack');
|
|
106
|
+
if (
|
|
107
|
+
desc && desc.writable && desc.configurable && error.hasOwnProperty('stack')
|
|
108
|
+
) {
|
|
109
|
+
try {
|
|
110
|
+
error.stack = new Error().stack;
|
|
111
|
+
} catch (_) {}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return [false, error, undefined];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (typeof fnOrPromise.then === 'function') {
|
|
119
|
+
return Promise.resolve(fnOrPromise)
|
|
120
|
+
.then(data => [true, null, data])
|
|
121
|
+
.catch(error => {
|
|
122
|
+
if (
|
|
123
|
+
error instanceof Error &&
|
|
124
|
+
Object.isExtensible(error)
|
|
125
|
+
) {
|
|
126
|
+
const desc = Object.getOwnPropertyDescriptor(error, 'stack');
|
|
127
|
+
if (
|
|
128
|
+
desc && desc.writable && desc.configurable && error.hasOwnProperty('stack')
|
|
129
|
+
) {
|
|
130
|
+
try {
|
|
131
|
+
error.stack = new Error().stack;
|
|
132
|
+
} catch (_) {}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return [false, error, undefined];
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return [true, null, fnOrPromise];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function tryFnSync(fn) {
|
|
143
|
+
try {
|
|
144
|
+
const result = fn();
|
|
145
|
+
return [true, null, result];
|
|
146
|
+
} catch (err) {
|
|
147
|
+
return [false, err, null];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export default tryFn;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export const S3_DEFAULT_REGION = "us-east-1";
|
|
2
|
+
export const S3_DEFAULT_ENDPOINT = "https://s3.us-east-1.amazonaws.com";
|
|
3
|
+
|
|
4
|
+
import tryFn, { tryFnSync } from "./concerns/try-fn.js";
|
|
5
|
+
import { ConnectionStringError } from "./errors.js";
|
|
6
|
+
|
|
7
|
+
export class ConnectionString {
|
|
8
|
+
constructor(connectionString) {
|
|
9
|
+
let uri;
|
|
10
|
+
|
|
11
|
+
const [ok, err, parsed] = tryFn(() => new URL(connectionString));
|
|
12
|
+
if (!ok) {
|
|
13
|
+
throw new ConnectionStringError("Invalid connection string: " + connectionString, { original: err, input: connectionString });
|
|
14
|
+
}
|
|
15
|
+
uri = parsed;
|
|
16
|
+
// defaults:
|
|
17
|
+
this.region = S3_DEFAULT_REGION;
|
|
18
|
+
|
|
19
|
+
// config:
|
|
20
|
+
if (uri.protocol === "s3:") this.defineFromS3(uri);
|
|
21
|
+
else this.defineFromCustomUri(uri);
|
|
22
|
+
|
|
23
|
+
for (const [k, v] of uri.searchParams.entries()) {
|
|
24
|
+
this[k] = v;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
defineFromS3(uri) {
|
|
29
|
+
const [okBucket, errBucket, bucket] = tryFnSync(() => decodeURIComponent(uri.hostname));
|
|
30
|
+
if (!okBucket) throw new ConnectionStringError("Invalid bucket in connection string", { original: errBucket, input: uri.hostname });
|
|
31
|
+
this.bucket = bucket || 's3db';
|
|
32
|
+
const [okUser, errUser, user] = tryFnSync(() => decodeURIComponent(uri.username));
|
|
33
|
+
if (!okUser) throw new ConnectionStringError("Invalid accessKeyId in connection string", { original: errUser, input: uri.username });
|
|
34
|
+
this.accessKeyId = user;
|
|
35
|
+
const [okPass, errPass, pass] = tryFnSync(() => decodeURIComponent(uri.password));
|
|
36
|
+
if (!okPass) throw new ConnectionStringError("Invalid secretAccessKey in connection string", { original: errPass, input: uri.password });
|
|
37
|
+
this.secretAccessKey = pass;
|
|
38
|
+
this.endpoint = S3_DEFAULT_ENDPOINT;
|
|
39
|
+
|
|
40
|
+
if (["/", "", null].includes(uri.pathname)) {
|
|
41
|
+
this.keyPrefix = "";
|
|
42
|
+
} else {
|
|
43
|
+
let [, ...subpath] = uri.pathname.split("/");
|
|
44
|
+
this.keyPrefix = [...(subpath || [])].join("/");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
defineFromCustomUri(uri) {
|
|
49
|
+
this.forcePathStyle = true;
|
|
50
|
+
this.endpoint = uri.origin;
|
|
51
|
+
const [okUser, errUser, user] = tryFnSync(() => decodeURIComponent(uri.username));
|
|
52
|
+
if (!okUser) throw new ConnectionStringError("Invalid accessKeyId in connection string", { original: errUser, input: uri.username });
|
|
53
|
+
this.accessKeyId = user;
|
|
54
|
+
const [okPass, errPass, pass] = tryFnSync(() => decodeURIComponent(uri.password));
|
|
55
|
+
if (!okPass) throw new ConnectionStringError("Invalid secretAccessKey in connection string", { original: errPass, input: uri.password });
|
|
56
|
+
this.secretAccessKey = pass;
|
|
57
|
+
|
|
58
|
+
if (["/", "", null].includes(uri.pathname)) {
|
|
59
|
+
this.bucket = "s3db";
|
|
60
|
+
this.keyPrefix = "";
|
|
61
|
+
} else {
|
|
62
|
+
let [, bucket, ...subpath] = uri.pathname.split("/");
|
|
63
|
+
if (!bucket) {
|
|
64
|
+
this.bucket = "s3db";
|
|
65
|
+
} else {
|
|
66
|
+
const [okBucket, errBucket, bucketDecoded] = tryFnSync(() => decodeURIComponent(bucket));
|
|
67
|
+
if (!okBucket) throw new ConnectionStringError("Invalid bucket in connection string", { original: errBucket, input: bucket });
|
|
68
|
+
this.bucket = bucketDecoded;
|
|
69
|
+
}
|
|
70
|
+
this.keyPrefix = [...(subpath || [])].join("/");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export default ConnectionString;
|