s3db.js 11.3.1 → 12.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +102 -8
  2. package/dist/s3db.cjs.js +36664 -15480
  3. package/dist/s3db.cjs.js.map +1 -1
  4. package/dist/s3db.d.ts +57 -0
  5. package/dist/s3db.es.js +36661 -15531
  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 +27 -6
  11. package/src/behaviors/user-managed.js +13 -6
  12. package/src/client.class.js +41 -46
  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 +39 -19
  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 +539 -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 +350 -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/replicators/bigquery-replicator.class.js +180 -8
  55. package/src/plugins/replicators/dynamodb-replicator.class.js +383 -0
  56. package/src/plugins/replicators/index.js +28 -3
  57. package/src/plugins/replicators/mongodb-replicator.class.js +391 -0
  58. package/src/plugins/replicators/mysql-replicator.class.js +558 -0
  59. package/src/plugins/replicators/planetscale-replicator.class.js +409 -0
  60. package/src/plugins/replicators/postgres-replicator.class.js +182 -7
  61. package/src/plugins/replicators/s3db-replicator.class.js +1 -12
  62. package/src/plugins/replicators/schema-sync.helper.js +601 -0
  63. package/src/plugins/replicators/sqs-replicator.class.js +11 -9
  64. package/src/plugins/replicators/turso-replicator.class.js +416 -0
  65. package/src/plugins/replicators/webhook-replicator.class.js +612 -0
  66. package/src/plugins/state-machine.plugin.js +122 -68
  67. package/src/plugins/tfstate/README.md +745 -0
  68. package/src/plugins/tfstate/base-driver.js +80 -0
  69. package/src/plugins/tfstate/errors.js +112 -0
  70. package/src/plugins/tfstate/filesystem-driver.js +129 -0
  71. package/src/plugins/tfstate/index.js +2660 -0
  72. package/src/plugins/tfstate/s3-driver.js +192 -0
  73. package/src/plugins/ttl.plugin.js +536 -0
  74. package/src/resource.class.js +14 -10
  75. package/src/s3db.d.ts +57 -0
  76. package/src/schema.class.js +366 -32
  77. package/SECURITY.md +0 -76
  78. package/mcp/CLAUDE_CLI_SETUP.md +0 -302
  79. package/mcp/Dockerfile +0 -45
  80. package/mcp/Makefile +0 -162
  81. package/mcp/NPX_SETUP.md +0 -327
  82. package/mcp/PUBLISHING.md +0 -281
  83. package/mcp/README.md +0 -125
  84. package/mcp/docker-compose.yml +0 -120
  85. package/mcp/examples/test-filesystem-cache.js +0 -147
  86. package/mcp/examples/test-mcp.js +0 -433
  87. package/mcp/package.json +0 -66
  88. package/src/partition-drivers/base-partition-driver.js +0 -106
  89. package/src/partition-drivers/index.js +0 -66
  90. package/src/partition-drivers/memory-partition-driver.js +0 -289
  91. package/src/partition-drivers/sqs-partition-driver.js +0 -337
  92. 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
- try {
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
- } finally {
104
- this.isProcessing = false;
105
-
106
- // Continue processing if more items
107
- if (this.insertBuffer.length > 0) {
108
- setImmediate(() => this.flush());
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
- try {
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 [ok, err, result] = await tryFn(() => this.resource.insert(data));
131
-
132
- if (!ok) {
133
- return { success: false, error: err };
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: result,
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: result };
151
-
152
- } catch (error) {
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
- try {
177
- await this.resource.createPartitionReferences(item.data);
179
+ const [ok, err] = await tryFn(() => this.resource.createPartitionReferences(item.data));
180
+
181
+ if (ok) {
178
182
  this.stats.partitionsPending--;
179
- } catch (err) {
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
+ };