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,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Geographic Coordinate Encoding - Normalized Fixed-Point
|
|
3
|
+
*
|
|
4
|
+
* Optimizes storage of latitude/longitude by:
|
|
5
|
+
* 1. Normalizing to positive range (eliminates negative sign)
|
|
6
|
+
* 2. Using fixed-point integer encoding
|
|
7
|
+
* 3. Base62 compression
|
|
8
|
+
*
|
|
9
|
+
* Achieves 45-55% compression vs JSON floats.
|
|
10
|
+
*
|
|
11
|
+
* Examples:
|
|
12
|
+
* Latitude -23.550519 → "~18kPxZ" (8 bytes vs 15 bytes = 47% savings)
|
|
13
|
+
* Longitude -46.633309 → "~36WqLj" (8 bytes vs 16 bytes = 50% savings)
|
|
14
|
+
*
|
|
15
|
+
* Precision:
|
|
16
|
+
* 6 decimals = ~11cm accuracy (GPS standard)
|
|
17
|
+
* 5 decimals = ~1.1m accuracy (sufficient for most apps)
|
|
18
|
+
* 4 decimals = ~11m accuracy (building-level)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { encode, decode } from './base62.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Encode latitude with normalized range
|
|
25
|
+
* Range: -90 to +90 → normalized to 0 to 180
|
|
26
|
+
*
|
|
27
|
+
* @param {number} lat - Latitude value (-90 to 90)
|
|
28
|
+
* @param {number} precision - Decimal places to preserve (default: 6)
|
|
29
|
+
* @returns {string} Encoded string with '~' prefix
|
|
30
|
+
*
|
|
31
|
+
* @throws {Error} If latitude is out of valid range
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* encodeGeoLat(-23.550519, 6) // → "~18kPxZ"
|
|
35
|
+
* encodeGeoLat(40.7128, 6) // → "~2i8pYw"
|
|
36
|
+
*/
|
|
37
|
+
export function encodeGeoLat(lat, precision = 6) {
|
|
38
|
+
if (lat === null || lat === undefined) return lat;
|
|
39
|
+
if (typeof lat !== 'number' || isNaN(lat)) return lat;
|
|
40
|
+
if (!isFinite(lat)) return lat;
|
|
41
|
+
|
|
42
|
+
// Validate range
|
|
43
|
+
if (lat < -90 || lat > 90) {
|
|
44
|
+
throw new Error(`Latitude out of range [-90, 90]: ${lat}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Normalize: -90 to +90 → 0 to 180
|
|
48
|
+
const normalized = lat + 90;
|
|
49
|
+
|
|
50
|
+
// Convert to fixed-point integer
|
|
51
|
+
const scale = Math.pow(10, precision);
|
|
52
|
+
const scaled = Math.round(normalized * scale);
|
|
53
|
+
|
|
54
|
+
// Encode with '~' prefix to identify as geo coordinate
|
|
55
|
+
return '~' + encode(scaled);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Decode latitude from encoded string
|
|
60
|
+
*
|
|
61
|
+
* @param {string} encoded - Encoded string (must start with '~')
|
|
62
|
+
* @param {number} precision - Decimal places used in encoding (default: 6)
|
|
63
|
+
* @returns {number} Decoded latitude value
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* decodeGeoLat('~18kPxZ', 6) // → -23.550519
|
|
67
|
+
*/
|
|
68
|
+
export function decodeGeoLat(encoded, precision = 6) {
|
|
69
|
+
if (typeof encoded !== 'string') return encoded;
|
|
70
|
+
if (!encoded.startsWith('~')) return encoded;
|
|
71
|
+
|
|
72
|
+
const scaled = decode(encoded.slice(1));
|
|
73
|
+
if (isNaN(scaled)) return NaN;
|
|
74
|
+
|
|
75
|
+
const scale = Math.pow(10, precision);
|
|
76
|
+
const normalized = scaled / scale;
|
|
77
|
+
|
|
78
|
+
// Denormalize: 0 to 180 → -90 to +90
|
|
79
|
+
return normalized - 90;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Encode longitude with normalized range
|
|
84
|
+
* Range: -180 to +180 → normalized to 0 to 360
|
|
85
|
+
*
|
|
86
|
+
* @param {number} lon - Longitude value (-180 to 180)
|
|
87
|
+
* @param {number} precision - Decimal places to preserve (default: 6)
|
|
88
|
+
* @returns {string} Encoded string with '~' prefix
|
|
89
|
+
*
|
|
90
|
+
* @throws {Error} If longitude is out of valid range
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* encodeGeoLon(-46.633309, 6) // → "~36WqLj"
|
|
94
|
+
* encodeGeoLon(-74.0060, 6) // → "~2xKqrO"
|
|
95
|
+
*/
|
|
96
|
+
export function encodeGeoLon(lon, precision = 6) {
|
|
97
|
+
if (lon === null || lon === undefined) return lon;
|
|
98
|
+
if (typeof lon !== 'number' || isNaN(lon)) return lon;
|
|
99
|
+
if (!isFinite(lon)) return lon;
|
|
100
|
+
|
|
101
|
+
// Validate range
|
|
102
|
+
if (lon < -180 || lon > 180) {
|
|
103
|
+
throw new Error(`Longitude out of range [-180, 180]: ${lon}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Normalize: -180 to +180 → 0 to 360
|
|
107
|
+
const normalized = lon + 180;
|
|
108
|
+
|
|
109
|
+
// Convert to fixed-point integer
|
|
110
|
+
const scale = Math.pow(10, precision);
|
|
111
|
+
const scaled = Math.round(normalized * scale);
|
|
112
|
+
|
|
113
|
+
// Encode with '~' prefix
|
|
114
|
+
return '~' + encode(scaled);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Decode longitude from encoded string
|
|
119
|
+
*
|
|
120
|
+
* @param {string} encoded - Encoded string (must start with '~')
|
|
121
|
+
* @param {number} precision - Decimal places used in encoding (default: 6)
|
|
122
|
+
* @returns {number} Decoded longitude value
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* decodeGeoLon('~36WqLj', 6) // → -46.633309
|
|
126
|
+
*/
|
|
127
|
+
export function decodeGeoLon(encoded, precision = 6) {
|
|
128
|
+
if (typeof encoded !== 'string') return encoded;
|
|
129
|
+
if (!encoded.startsWith('~')) return encoded;
|
|
130
|
+
|
|
131
|
+
const scaled = decode(encoded.slice(1));
|
|
132
|
+
if (isNaN(scaled)) return NaN;
|
|
133
|
+
|
|
134
|
+
const scale = Math.pow(10, precision);
|
|
135
|
+
const normalized = scaled / scale;
|
|
136
|
+
|
|
137
|
+
// Denormalize: 0 to 360 → -180 to +180
|
|
138
|
+
return normalized - 180;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Encode a lat/lon point as a single string
|
|
143
|
+
* Format: {lat}{lon} (both with '~' prefix)
|
|
144
|
+
*
|
|
145
|
+
* @param {number} lat - Latitude
|
|
146
|
+
* @param {number} lon - Longitude
|
|
147
|
+
* @param {number} precision - Decimal places (default: 6)
|
|
148
|
+
* @returns {string} Encoded point
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* encodeGeoPoint(-23.550519, -46.633309, 6)
|
|
152
|
+
* // → "~18kPxZ~36WqLj"
|
|
153
|
+
*/
|
|
154
|
+
export function encodeGeoPoint(lat, lon, precision = 6) {
|
|
155
|
+
const latEncoded = encodeGeoLat(lat, precision);
|
|
156
|
+
const lonEncoded = encodeGeoLon(lon, precision);
|
|
157
|
+
|
|
158
|
+
// Return concatenated (both have '~' prefix for easy parsing)
|
|
159
|
+
return latEncoded + lonEncoded;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Decode a lat/lon point from encoded string
|
|
164
|
+
*
|
|
165
|
+
* @param {string} encoded - Encoded point string
|
|
166
|
+
* @param {number} precision - Decimal places (default: 6)
|
|
167
|
+
* @returns {Object} { latitude, longitude }
|
|
168
|
+
*
|
|
169
|
+
* @example
|
|
170
|
+
* decodeGeoPoint('~18kPxZ~36WqLj', 6)
|
|
171
|
+
* // → { latitude: -23.550519, longitude: -46.633309 }
|
|
172
|
+
*/
|
|
173
|
+
export function decodeGeoPoint(encoded, precision = 6) {
|
|
174
|
+
if (typeof encoded !== 'string') return { latitude: NaN, longitude: NaN };
|
|
175
|
+
|
|
176
|
+
// Split by '~' and filter empty strings
|
|
177
|
+
const parts = encoded.split('~').filter(p => p.length > 0);
|
|
178
|
+
|
|
179
|
+
if (parts.length !== 2) {
|
|
180
|
+
return { latitude: NaN, longitude: NaN };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Decode each part (re-add '~' prefix)
|
|
184
|
+
const latitude = decodeGeoLat('~' + parts[0], precision);
|
|
185
|
+
const longitude = decodeGeoLon('~' + parts[1], precision);
|
|
186
|
+
|
|
187
|
+
return { latitude, longitude };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Validate if coordinates are within valid ranges
|
|
192
|
+
* @param {number} lat - Latitude
|
|
193
|
+
* @param {number} lon - Longitude
|
|
194
|
+
* @returns {boolean} True if valid
|
|
195
|
+
*/
|
|
196
|
+
export function isValidCoordinate(lat, lon) {
|
|
197
|
+
return (
|
|
198
|
+
typeof lat === 'number' &&
|
|
199
|
+
typeof lon === 'number' &&
|
|
200
|
+
!isNaN(lat) &&
|
|
201
|
+
!isNaN(lon) &&
|
|
202
|
+
isFinite(lat) &&
|
|
203
|
+
isFinite(lon) &&
|
|
204
|
+
lat >= -90 &&
|
|
205
|
+
lat <= 90 &&
|
|
206
|
+
lon >= -180 &&
|
|
207
|
+
lon <= 180
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Calculate precision level based on desired accuracy
|
|
213
|
+
*
|
|
214
|
+
* @param {number} accuracyMeters - Desired accuracy in meters
|
|
215
|
+
* @returns {number} Recommended decimal places
|
|
216
|
+
*
|
|
217
|
+
* Precision levels:
|
|
218
|
+
* - 0 decimals: ~111 km
|
|
219
|
+
* - 1 decimal: ~11 km
|
|
220
|
+
* - 2 decimals: ~1.1 km
|
|
221
|
+
* - 3 decimals: ~110 m
|
|
222
|
+
* - 4 decimals: ~11 m
|
|
223
|
+
* - 5 decimals: ~1.1 m (GPS consumer)
|
|
224
|
+
* - 6 decimals: ~11 cm (GPS precision)
|
|
225
|
+
* - 7 decimals: ~1.1 cm
|
|
226
|
+
*/
|
|
227
|
+
export function getPrecisionForAccuracy(accuracyMeters) {
|
|
228
|
+
if (accuracyMeters >= 111000) return 0;
|
|
229
|
+
if (accuracyMeters >= 11000) return 1;
|
|
230
|
+
if (accuracyMeters >= 1100) return 2;
|
|
231
|
+
if (accuracyMeters >= 110) return 3;
|
|
232
|
+
if (accuracyMeters >= 11) return 4;
|
|
233
|
+
if (accuracyMeters >= 1.1) return 5;
|
|
234
|
+
if (accuracyMeters >= 0.11) return 6;
|
|
235
|
+
return 7;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Get accuracy in meters for a precision level
|
|
240
|
+
* @param {number} precision - Decimal places
|
|
241
|
+
* @returns {number} Approximate accuracy in meters
|
|
242
|
+
*/
|
|
243
|
+
export function getAccuracyForPrecision(precision) {
|
|
244
|
+
const accuracies = {
|
|
245
|
+
0: 111000,
|
|
246
|
+
1: 11000,
|
|
247
|
+
2: 1100,
|
|
248
|
+
3: 110,
|
|
249
|
+
4: 11,
|
|
250
|
+
5: 1.1,
|
|
251
|
+
6: 0.11,
|
|
252
|
+
7: 0.011
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
return accuracies[precision] || 111000;
|
|
256
|
+
}
|
|
@@ -79,8 +79,8 @@ export class HighPerformanceInserter {
|
|
|
79
79
|
// Take current buffer and reset
|
|
80
80
|
const batch = this.insertBuffer.splice(0, this.batchSize);
|
|
81
81
|
const startTime = Date.now();
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
|
|
83
|
+
const [ok, err] = await tryFn(async () => {
|
|
84
84
|
// Process inserts in parallel with connection pooling
|
|
85
85
|
const { results, errors } = await PromisePool
|
|
86
86
|
.for(batch)
|
|
@@ -88,25 +88,25 @@ export class HighPerformanceInserter {
|
|
|
88
88
|
.process(async (item) => {
|
|
89
89
|
return await this.performInsert(item);
|
|
90
90
|
});
|
|
91
|
-
|
|
91
|
+
|
|
92
92
|
// Update stats
|
|
93
93
|
const duration = Date.now() - startTime;
|
|
94
94
|
this.stats.inserted += results.filter(r => r.success).length;
|
|
95
95
|
this.stats.failed += errors.length;
|
|
96
96
|
this.stats.avgInsertTime = duration / batch.length;
|
|
97
|
-
|
|
97
|
+
|
|
98
98
|
// Process partition queue separately (non-blocking)
|
|
99
99
|
if (!this.disablePartitions && this.partitionQueue.length > 0) {
|
|
100
100
|
this.processPartitionsAsync();
|
|
101
101
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Always execute (finally equivalent)
|
|
105
|
+
this.isProcessing = false;
|
|
106
|
+
|
|
107
|
+
// Continue processing if more items
|
|
108
|
+
if (this.insertBuffer.length > 0) {
|
|
109
|
+
setImmediate(() => this.flush());
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
|
|
@@ -115,43 +115,46 @@ export class HighPerformanceInserter {
|
|
|
115
115
|
*/
|
|
116
116
|
async performInsert(item) {
|
|
117
117
|
const { data } = item;
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
|
|
119
|
+
const [ok, error, result] = await tryFn(async () => {
|
|
120
120
|
// Temporarily disable partitions for the insert
|
|
121
121
|
const originalAsyncPartitions = this.resource.config.asyncPartitions;
|
|
122
122
|
const originalPartitions = this.resource.config.partitions;
|
|
123
|
-
|
|
123
|
+
|
|
124
124
|
if (this.disablePartitions) {
|
|
125
125
|
// Completely bypass partitions during insert
|
|
126
126
|
this.resource.config.partitions = {};
|
|
127
127
|
}
|
|
128
|
-
|
|
128
|
+
|
|
129
129
|
// Perform insert
|
|
130
|
-
const [
|
|
131
|
-
|
|
132
|
-
if (!
|
|
133
|
-
|
|
130
|
+
const [insertOk, insertErr, insertResult] = await tryFn(() => this.resource.insert(data));
|
|
131
|
+
|
|
132
|
+
if (!insertOk) {
|
|
133
|
+
throw insertErr; // Re-throw to be caught by outer tryFn
|
|
134
134
|
}
|
|
135
|
-
|
|
135
|
+
|
|
136
136
|
// Queue partition creation for later (if not disabled)
|
|
137
137
|
if (!this.disablePartitions && originalPartitions && Object.keys(originalPartitions).length > 0) {
|
|
138
138
|
this.partitionQueue.push({
|
|
139
139
|
operation: 'create',
|
|
140
|
-
data:
|
|
140
|
+
data: insertResult,
|
|
141
141
|
partitions: originalPartitions
|
|
142
142
|
});
|
|
143
143
|
this.stats.partitionsPending++;
|
|
144
144
|
}
|
|
145
|
-
|
|
145
|
+
|
|
146
146
|
// Restore original config
|
|
147
147
|
this.resource.config.partitions = originalPartitions;
|
|
148
148
|
this.resource.config.asyncPartitions = originalAsyncPartitions;
|
|
149
|
-
|
|
150
|
-
return { success: true, data:
|
|
151
|
-
|
|
152
|
-
|
|
149
|
+
|
|
150
|
+
return { success: true, data: insertResult };
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
if (!ok) {
|
|
153
154
|
return { success: false, error };
|
|
154
155
|
}
|
|
156
|
+
|
|
157
|
+
return result;
|
|
155
158
|
}
|
|
156
159
|
|
|
157
160
|
/**
|
|
@@ -173,10 +176,11 @@ export class HighPerformanceInserter {
|
|
|
173
176
|
.for(batch)
|
|
174
177
|
.withConcurrency(10) // Lower concurrency for partitions
|
|
175
178
|
.process(async (item) => {
|
|
176
|
-
|
|
177
|
-
|
|
179
|
+
const [ok, err] = await tryFn(() => this.resource.createPartitionReferences(item.data));
|
|
180
|
+
|
|
181
|
+
if (ok) {
|
|
178
182
|
this.stats.partitionsPending--;
|
|
179
|
-
}
|
|
183
|
+
} else {
|
|
180
184
|
// Silently handle partition errors
|
|
181
185
|
this.resource.emit('partitionIndexError', {
|
|
182
186
|
operation: 'bulk-insert',
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IP Address Encoding/Decoding Utilities
|
|
3
|
+
*
|
|
4
|
+
* Provides compact binary encoding for IPv4 and IPv6 addresses
|
|
5
|
+
* to save space in S3 metadata.
|
|
6
|
+
*
|
|
7
|
+
* Savings:
|
|
8
|
+
* - IPv4: "192.168.1.1" (11-15 chars) → 4 bytes → ~8 chars Base64 (47% savings)
|
|
9
|
+
* - IPv6: "2001:db8::1" (up to 39 chars) → 16 bytes → ~22 chars Base64 (44% savings)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import tryFn from './try-fn.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Validate IPv4 address format
|
|
16
|
+
* @param {string} ip - IP address string
|
|
17
|
+
* @returns {boolean} True if valid IPv4
|
|
18
|
+
*/
|
|
19
|
+
export function isValidIPv4(ip) {
|
|
20
|
+
if (typeof ip !== 'string') return false;
|
|
21
|
+
|
|
22
|
+
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
|
23
|
+
const match = ip.match(ipv4Regex);
|
|
24
|
+
|
|
25
|
+
if (!match) return false;
|
|
26
|
+
|
|
27
|
+
// Check each octet is 0-255
|
|
28
|
+
for (let i = 1; i <= 4; i++) {
|
|
29
|
+
const octet = parseInt(match[i], 10);
|
|
30
|
+
if (octet < 0 || octet > 255) return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Validate IPv6 address format
|
|
38
|
+
* @param {string} ip - IP address string
|
|
39
|
+
* @returns {boolean} True if valid IPv6
|
|
40
|
+
*/
|
|
41
|
+
export function isValidIPv6(ip) {
|
|
42
|
+
if (typeof ip !== 'string') return false;
|
|
43
|
+
|
|
44
|
+
// IPv6 regex (simplified, covers most cases)
|
|
45
|
+
const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))$/;
|
|
46
|
+
|
|
47
|
+
return ipv6Regex.test(ip);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Encode IPv4 address to Base64 binary representation
|
|
52
|
+
* @param {string} ip - IPv4 address (e.g., "192.168.1.1")
|
|
53
|
+
* @returns {string} Base64-encoded binary (e.g., "wKgBAQ==")
|
|
54
|
+
*/
|
|
55
|
+
export function encodeIPv4(ip) {
|
|
56
|
+
if (!isValidIPv4(ip)) {
|
|
57
|
+
throw new Error(`Invalid IPv4 address: ${ip}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const octets = ip.split('.').map(octet => parseInt(octet, 10));
|
|
61
|
+
const buffer = Buffer.from(octets);
|
|
62
|
+
|
|
63
|
+
return buffer.toString('base64');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Decode Base64 binary to IPv4 address
|
|
68
|
+
* @param {string} encoded - Base64-encoded binary
|
|
69
|
+
* @returns {string} IPv4 address (e.g., "192.168.1.1")
|
|
70
|
+
*/
|
|
71
|
+
export function decodeIPv4(encoded) {
|
|
72
|
+
if (typeof encoded !== 'string') {
|
|
73
|
+
throw new Error('Encoded IPv4 must be a string');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const [ok, err, result] = tryFn(() => {
|
|
77
|
+
const buffer = Buffer.from(encoded, 'base64');
|
|
78
|
+
|
|
79
|
+
if (buffer.length !== 4) {
|
|
80
|
+
throw new Error(`Invalid encoded IPv4 length: ${buffer.length} (expected 4)`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return Array.from(buffer).join('.');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (!ok) {
|
|
87
|
+
throw new Error(`Failed to decode IPv4: ${err.message}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Normalize IPv6 address to full expanded form
|
|
95
|
+
* @param {string} ip - IPv6 address (may be compressed)
|
|
96
|
+
* @returns {string} Expanded IPv6 address
|
|
97
|
+
*/
|
|
98
|
+
export function expandIPv6(ip) {
|
|
99
|
+
if (!isValidIPv6(ip)) {
|
|
100
|
+
throw new Error(`Invalid IPv6 address: ${ip}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Handle :: expansion
|
|
104
|
+
let expanded = ip;
|
|
105
|
+
|
|
106
|
+
// Special case: ::
|
|
107
|
+
if (expanded === '::') {
|
|
108
|
+
return '0000:0000:0000:0000:0000:0000:0000:0000';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Expand ::
|
|
112
|
+
if (expanded.includes('::')) {
|
|
113
|
+
const parts = expanded.split('::');
|
|
114
|
+
const leftParts = parts[0] ? parts[0].split(':') : [];
|
|
115
|
+
const rightParts = parts[1] ? parts[1].split(':') : [];
|
|
116
|
+
const missingGroups = 8 - leftParts.length - rightParts.length;
|
|
117
|
+
|
|
118
|
+
const middleParts = Array(missingGroups).fill('0');
|
|
119
|
+
expanded = [...leftParts, ...middleParts, ...rightParts].join(':');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Pad each group to 4 digits
|
|
123
|
+
const groups = expanded.split(':');
|
|
124
|
+
const paddedGroups = groups.map(group => group.padStart(4, '0'));
|
|
125
|
+
|
|
126
|
+
return paddedGroups.join(':');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Compress IPv6 address (remove leading zeros and use ::)
|
|
131
|
+
* @param {string} ip - Full IPv6 address
|
|
132
|
+
* @returns {string} Compressed IPv6 address
|
|
133
|
+
*/
|
|
134
|
+
export function compressIPv6(ip) {
|
|
135
|
+
// Remove leading zeros
|
|
136
|
+
let compressed = ip.split(':').map(group => {
|
|
137
|
+
return parseInt(group, 16).toString(16);
|
|
138
|
+
}).join(':');
|
|
139
|
+
|
|
140
|
+
// Find longest sequence of consecutive 0 groups
|
|
141
|
+
const zeroSequences = [];
|
|
142
|
+
let currentSequence = { start: -1, length: 0 };
|
|
143
|
+
|
|
144
|
+
compressed.split(':').forEach((group, index) => {
|
|
145
|
+
if (group === '0') {
|
|
146
|
+
if (currentSequence.start === -1) {
|
|
147
|
+
currentSequence.start = index;
|
|
148
|
+
currentSequence.length = 1;
|
|
149
|
+
} else {
|
|
150
|
+
currentSequence.length++;
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
if (currentSequence.length > 0) {
|
|
154
|
+
zeroSequences.push({ ...currentSequence });
|
|
155
|
+
currentSequence = { start: -1, length: 0 };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (currentSequence.length > 0) {
|
|
161
|
+
zeroSequences.push(currentSequence);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Find longest sequence (must be at least 2 groups)
|
|
165
|
+
const longestSequence = zeroSequences
|
|
166
|
+
.filter(seq => seq.length >= 2)
|
|
167
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
168
|
+
|
|
169
|
+
if (longestSequence) {
|
|
170
|
+
const parts = compressed.split(':');
|
|
171
|
+
const before = parts.slice(0, longestSequence.start).join(':');
|
|
172
|
+
const after = parts.slice(longestSequence.start + longestSequence.length).join(':');
|
|
173
|
+
|
|
174
|
+
if (before && after) {
|
|
175
|
+
compressed = `${before}::${after}`;
|
|
176
|
+
} else if (before) {
|
|
177
|
+
compressed = `${before}::`;
|
|
178
|
+
} else if (after) {
|
|
179
|
+
compressed = `::${after}`;
|
|
180
|
+
} else {
|
|
181
|
+
compressed = '::';
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return compressed;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Encode IPv6 address to Base64 binary representation
|
|
190
|
+
*
|
|
191
|
+
* Always encodes to ensure consistent format in storage.
|
|
192
|
+
* IPv6 addresses are normalized to 16 bytes and Base64-encoded to 24 characters.
|
|
193
|
+
*
|
|
194
|
+
* @param {string} ip - IPv6 address (e.g., "2001:db8::1")
|
|
195
|
+
* @returns {string} Base64-encoded binary (24 chars)
|
|
196
|
+
*/
|
|
197
|
+
export function encodeIPv6(ip) {
|
|
198
|
+
if (!isValidIPv6(ip)) {
|
|
199
|
+
throw new Error(`Invalid IPv6 address: ${ip}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Always encode for consistency (like IPv4)
|
|
203
|
+
// Expand to full notation first
|
|
204
|
+
const expanded = expandIPv6(ip);
|
|
205
|
+
const groups = expanded.split(':');
|
|
206
|
+
|
|
207
|
+
// Convert each group to 2 bytes
|
|
208
|
+
const bytes = [];
|
|
209
|
+
for (const group of groups) {
|
|
210
|
+
const value = parseInt(group, 16);
|
|
211
|
+
bytes.push((value >> 8) & 0xFF); // High byte
|
|
212
|
+
bytes.push(value & 0xFF); // Low byte
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const buffer = Buffer.from(bytes);
|
|
216
|
+
return buffer.toString('base64');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Decode Base64 binary to IPv6 address
|
|
221
|
+
*
|
|
222
|
+
* Handles both encoded and unencoded IPv6 addresses for backwards compatibility.
|
|
223
|
+
* - If input is a valid unencoded IPv6 address → return it (optionally expanded)
|
|
224
|
+
* - Otherwise → decode from Base64 binary (24 chars)
|
|
225
|
+
*
|
|
226
|
+
* @param {string} encoded - Base64-encoded binary (24 chars) or unencoded IPv6
|
|
227
|
+
* @param {boolean} compress - Whether to compress the output (default: true)
|
|
228
|
+
* @returns {string} IPv6 address
|
|
229
|
+
*/
|
|
230
|
+
export function decodeIPv6(encoded, compress = true) {
|
|
231
|
+
if (typeof encoded !== 'string') {
|
|
232
|
+
throw new Error('Encoded IPv6 must be a string');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// SMART DETECTION: Check if this is unencoded IPv6
|
|
236
|
+
// If it's not 24 chars AND it's a valid IPv6, treat as unencoded
|
|
237
|
+
if (encoded.length !== 24 && isValidIPv6(encoded)) {
|
|
238
|
+
// Not encoded - was kept as original compressed form
|
|
239
|
+
// Respect the compress parameter
|
|
240
|
+
return compress ? encoded : expandIPv6(encoded);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Try to decode as Base64 - works for both 24-char encoded AND invalid inputs
|
|
244
|
+
const [ok, err, result] = tryFn(() => {
|
|
245
|
+
const buffer = Buffer.from(encoded, 'base64');
|
|
246
|
+
|
|
247
|
+
if (buffer.length !== 16) {
|
|
248
|
+
throw new Error(`Invalid encoded IPv6 length: ${buffer.length} (expected 16)`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const groups = [];
|
|
252
|
+
for (let i = 0; i < 16; i += 2) {
|
|
253
|
+
const value = (buffer[i] << 8) | buffer[i + 1];
|
|
254
|
+
groups.push(value.toString(16).padStart(4, '0'));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const fullAddress = groups.join(':');
|
|
258
|
+
|
|
259
|
+
return compress ? compressIPv6(fullAddress) : fullAddress;
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
if (!ok) {
|
|
263
|
+
throw new Error(`Failed to decode IPv6: ${err.message}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Detect IP version from string
|
|
271
|
+
* @param {string} ip - IP address string
|
|
272
|
+
* @returns {'ipv4'|'ipv6'|null} IP version or null if invalid
|
|
273
|
+
*/
|
|
274
|
+
export function detectIPVersion(ip) {
|
|
275
|
+
if (isValidIPv4(ip)) return 'ipv4';
|
|
276
|
+
if (isValidIPv6(ip)) return 'ipv6';
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Calculate savings percentage for IP encoding
|
|
282
|
+
* @param {string} ip - IP address
|
|
283
|
+
* @returns {Object} Savings information
|
|
284
|
+
*/
|
|
285
|
+
export function calculateIPSavings(ip) {
|
|
286
|
+
const version = detectIPVersion(ip);
|
|
287
|
+
|
|
288
|
+
if (!version) {
|
|
289
|
+
return { version: null, originalSize: 0, encodedSize: 0, savings: 0 };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const originalSize = ip.length;
|
|
293
|
+
let encodedSize;
|
|
294
|
+
|
|
295
|
+
if (version === 'ipv4') {
|
|
296
|
+
const encoded = encodeIPv4(ip);
|
|
297
|
+
encodedSize = encoded.length;
|
|
298
|
+
} else {
|
|
299
|
+
const encoded = encodeIPv6(ip);
|
|
300
|
+
encodedSize = encoded.length;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const savings = ((originalSize - encodedSize) / originalSize) * 100;
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
version,
|
|
307
|
+
originalSize,
|
|
308
|
+
encodedSize,
|
|
309
|
+
savings: Math.round(savings * 100) / 100,
|
|
310
|
+
savingsPercent: `${Math.round(savings)}%`
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export default {
|
|
315
|
+
isValidIPv4,
|
|
316
|
+
isValidIPv6,
|
|
317
|
+
encodeIPv4,
|
|
318
|
+
decodeIPv4,
|
|
319
|
+
encodeIPv6,
|
|
320
|
+
decodeIPv6,
|
|
321
|
+
expandIPv6,
|
|
322
|
+
compressIPv6,
|
|
323
|
+
detectIPVersion,
|
|
324
|
+
calculateIPSavings
|
|
325
|
+
};
|