s3db.js 13.6.0 → 14.0.2

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 (193) hide show
  1. package/README.md +139 -43
  2. package/dist/s3db.cjs +72425 -38970
  3. package/dist/s3db.cjs.map +1 -1
  4. package/dist/s3db.es.js +72177 -38764
  5. package/dist/s3db.es.js.map +1 -1
  6. package/mcp/lib/base-handler.js +157 -0
  7. package/mcp/lib/handlers/connection-handler.js +280 -0
  8. package/mcp/lib/handlers/query-handler.js +533 -0
  9. package/mcp/lib/handlers/resource-handler.js +428 -0
  10. package/mcp/lib/tool-registry.js +336 -0
  11. package/mcp/lib/tools/connection-tools.js +161 -0
  12. package/mcp/lib/tools/query-tools.js +267 -0
  13. package/mcp/lib/tools/resource-tools.js +404 -0
  14. package/package.json +94 -49
  15. package/src/clients/memory-client.class.js +346 -191
  16. package/src/clients/memory-storage.class.js +300 -84
  17. package/src/clients/s3-client.class.js +7 -6
  18. package/src/concerns/geo-encoding.js +19 -2
  19. package/src/concerns/ip.js +59 -9
  20. package/src/concerns/money.js +8 -1
  21. package/src/concerns/password-hashing.js +49 -8
  22. package/src/concerns/plugin-storage.js +186 -18
  23. package/src/concerns/storage-drivers/filesystem-driver.js +284 -0
  24. package/src/database.class.js +139 -29
  25. package/src/errors.js +332 -42
  26. package/src/plugins/api/auth/oidc-auth.js +66 -17
  27. package/src/plugins/api/auth/strategies/base-strategy.class.js +74 -0
  28. package/src/plugins/api/auth/strategies/factory.class.js +63 -0
  29. package/src/plugins/api/auth/strategies/global-strategy.class.js +44 -0
  30. package/src/plugins/api/auth/strategies/path-based-strategy.class.js +83 -0
  31. package/src/plugins/api/auth/strategies/path-rules-strategy.class.js +118 -0
  32. package/src/plugins/api/concerns/failban-manager.js +106 -57
  33. package/src/plugins/api/concerns/opengraph-helper.js +116 -0
  34. package/src/plugins/api/concerns/route-context.js +601 -0
  35. package/src/plugins/api/concerns/state-machine.js +288 -0
  36. package/src/plugins/api/index.js +180 -41
  37. package/src/plugins/api/routes/auth-routes.js +198 -30
  38. package/src/plugins/api/routes/resource-routes.js +19 -4
  39. package/src/plugins/api/server/health-manager.class.js +163 -0
  40. package/src/plugins/api/server/middleware-chain.class.js +310 -0
  41. package/src/plugins/api/server/router.class.js +472 -0
  42. package/src/plugins/api/server.js +280 -1303
  43. package/src/plugins/api/utils/custom-routes.js +17 -5
  44. package/src/plugins/api/utils/guards.js +76 -17
  45. package/src/plugins/api/utils/openapi-generator-cached.class.js +133 -0
  46. package/src/plugins/api/utils/openapi-generator.js +7 -6
  47. package/src/plugins/api/utils/template-engine.js +77 -3
  48. package/src/plugins/audit.plugin.js +30 -8
  49. package/src/plugins/backup.plugin.js +110 -14
  50. package/src/plugins/cache/cache.class.js +22 -5
  51. package/src/plugins/cache/filesystem-cache.class.js +116 -19
  52. package/src/plugins/cache/memory-cache.class.js +211 -57
  53. package/src/plugins/cache/multi-tier-cache.class.js +371 -0
  54. package/src/plugins/cache/partition-aware-filesystem-cache.class.js +168 -47
  55. package/src/plugins/cache/redis-cache.class.js +552 -0
  56. package/src/plugins/cache/s3-cache.class.js +17 -8
  57. package/src/plugins/cache.plugin.js +176 -61
  58. package/src/plugins/cloud-inventory/drivers/alibaba-driver.js +8 -1
  59. package/src/plugins/cloud-inventory/drivers/aws-driver.js +60 -29
  60. package/src/plugins/cloud-inventory/drivers/azure-driver.js +8 -1
  61. package/src/plugins/cloud-inventory/drivers/base-driver.js +16 -2
  62. package/src/plugins/cloud-inventory/drivers/cloudflare-driver.js +8 -1
  63. package/src/plugins/cloud-inventory/drivers/digitalocean-driver.js +8 -1
  64. package/src/plugins/cloud-inventory/drivers/hetzner-driver.js +8 -1
  65. package/src/plugins/cloud-inventory/drivers/linode-driver.js +8 -1
  66. package/src/plugins/cloud-inventory/drivers/mongodb-atlas-driver.js +8 -1
  67. package/src/plugins/cloud-inventory/drivers/vultr-driver.js +8 -1
  68. package/src/plugins/cloud-inventory/index.js +29 -8
  69. package/src/plugins/cloud-inventory/registry.js +64 -42
  70. package/src/plugins/cloud-inventory.plugin.js +240 -138
  71. package/src/plugins/concerns/plugin-dependencies.js +54 -0
  72. package/src/plugins/concerns/resource-names.js +100 -0
  73. package/src/plugins/consumers/index.js +10 -2
  74. package/src/plugins/consumers/sqs-consumer.js +12 -2
  75. package/src/plugins/cookie-farm-suite.plugin.js +278 -0
  76. package/src/plugins/cookie-farm.errors.js +73 -0
  77. package/src/plugins/cookie-farm.plugin.js +869 -0
  78. package/src/plugins/costs.plugin.js +7 -1
  79. package/src/plugins/eventual-consistency/analytics.js +94 -19
  80. package/src/plugins/eventual-consistency/config.js +15 -7
  81. package/src/plugins/eventual-consistency/consolidation.js +29 -11
  82. package/src/plugins/eventual-consistency/garbage-collection.js +3 -1
  83. package/src/plugins/eventual-consistency/helpers.js +39 -14
  84. package/src/plugins/eventual-consistency/install.js +21 -2
  85. package/src/plugins/eventual-consistency/utils.js +32 -10
  86. package/src/plugins/fulltext.plugin.js +38 -11
  87. package/src/plugins/geo.plugin.js +61 -9
  88. package/src/plugins/identity/concerns/config.js +61 -0
  89. package/src/plugins/identity/concerns/mfa-manager.js +15 -2
  90. package/src/plugins/identity/concerns/rate-limit.js +124 -0
  91. package/src/plugins/identity/concerns/resource-schemas.js +9 -1
  92. package/src/plugins/identity/concerns/token-generator.js +29 -4
  93. package/src/plugins/identity/drivers/auth-driver.interface.js +76 -0
  94. package/src/plugins/identity/drivers/client-credentials-driver.js +127 -0
  95. package/src/plugins/identity/drivers/index.js +18 -0
  96. package/src/plugins/identity/drivers/password-driver.js +122 -0
  97. package/src/plugins/identity/email-service.js +17 -2
  98. package/src/plugins/identity/index.js +413 -69
  99. package/src/plugins/identity/oauth2-server.js +413 -30
  100. package/src/plugins/identity/oidc-discovery.js +16 -8
  101. package/src/plugins/identity/rsa-keys.js +115 -35
  102. package/src/plugins/identity/server.js +166 -45
  103. package/src/plugins/identity/session-manager.js +53 -7
  104. package/src/plugins/identity/ui/pages/mfa-verification.js +17 -15
  105. package/src/plugins/identity/ui/routes.js +363 -255
  106. package/src/plugins/importer/index.js +153 -20
  107. package/src/plugins/index.js +9 -2
  108. package/src/plugins/kubernetes-inventory/index.js +6 -0
  109. package/src/plugins/kubernetes-inventory/k8s-driver.js +867 -0
  110. package/src/plugins/kubernetes-inventory/resource-types.js +274 -0
  111. package/src/plugins/kubernetes-inventory.plugin.js +980 -0
  112. package/src/plugins/metrics.plugin.js +64 -16
  113. package/src/plugins/ml/base-model.class.js +25 -15
  114. package/src/plugins/ml/regression-model.class.js +1 -1
  115. package/src/plugins/ml.errors.js +57 -25
  116. package/src/plugins/ml.plugin.js +28 -4
  117. package/src/plugins/namespace.js +210 -0
  118. package/src/plugins/plugin.class.js +180 -8
  119. package/src/plugins/puppeteer/console-monitor.js +729 -0
  120. package/src/plugins/puppeteer/cookie-manager.js +492 -0
  121. package/src/plugins/puppeteer/network-monitor.js +816 -0
  122. package/src/plugins/puppeteer/performance-manager.js +746 -0
  123. package/src/plugins/puppeteer/proxy-manager.js +478 -0
  124. package/src/plugins/puppeteer/stealth-manager.js +556 -0
  125. package/src/plugins/puppeteer.errors.js +81 -0
  126. package/src/plugins/puppeteer.plugin.js +1327 -0
  127. package/src/plugins/queue-consumer.plugin.js +69 -14
  128. package/src/plugins/recon/behaviors/uptime-behavior.js +691 -0
  129. package/src/plugins/recon/concerns/command-runner.js +148 -0
  130. package/src/plugins/recon/concerns/diff-detector.js +372 -0
  131. package/src/plugins/recon/concerns/fingerprint-builder.js +307 -0
  132. package/src/plugins/recon/concerns/process-manager.js +338 -0
  133. package/src/plugins/recon/concerns/report-generator.js +478 -0
  134. package/src/plugins/recon/concerns/security-analyzer.js +571 -0
  135. package/src/plugins/recon/concerns/target-normalizer.js +68 -0
  136. package/src/plugins/recon/config/defaults.js +321 -0
  137. package/src/plugins/recon/config/resources.js +370 -0
  138. package/src/plugins/recon/index.js +778 -0
  139. package/src/plugins/recon/managers/dependency-manager.js +174 -0
  140. package/src/plugins/recon/managers/scheduler-manager.js +179 -0
  141. package/src/plugins/recon/managers/storage-manager.js +745 -0
  142. package/src/plugins/recon/managers/target-manager.js +274 -0
  143. package/src/plugins/recon/stages/asn-stage.js +314 -0
  144. package/src/plugins/recon/stages/certificate-stage.js +84 -0
  145. package/src/plugins/recon/stages/dns-stage.js +107 -0
  146. package/src/plugins/recon/stages/dnsdumpster-stage.js +362 -0
  147. package/src/plugins/recon/stages/fingerprint-stage.js +71 -0
  148. package/src/plugins/recon/stages/google-dorks-stage.js +440 -0
  149. package/src/plugins/recon/stages/http-stage.js +89 -0
  150. package/src/plugins/recon/stages/latency-stage.js +148 -0
  151. package/src/plugins/recon/stages/massdns-stage.js +302 -0
  152. package/src/plugins/recon/stages/osint-stage.js +1373 -0
  153. package/src/plugins/recon/stages/ports-stage.js +169 -0
  154. package/src/plugins/recon/stages/screenshot-stage.js +94 -0
  155. package/src/plugins/recon/stages/secrets-stage.js +514 -0
  156. package/src/plugins/recon/stages/subdomains-stage.js +295 -0
  157. package/src/plugins/recon/stages/tls-audit-stage.js +78 -0
  158. package/src/plugins/recon/stages/vulnerability-stage.js +78 -0
  159. package/src/plugins/recon/stages/web-discovery-stage.js +113 -0
  160. package/src/plugins/recon/stages/whois-stage.js +349 -0
  161. package/src/plugins/recon.plugin.js +75 -0
  162. package/src/plugins/recon.plugin.js.backup +2635 -0
  163. package/src/plugins/relation.errors.js +87 -14
  164. package/src/plugins/replicator.plugin.js +514 -137
  165. package/src/plugins/replicators/base-replicator.class.js +89 -1
  166. package/src/plugins/replicators/bigquery-replicator.class.js +66 -22
  167. package/src/plugins/replicators/dynamodb-replicator.class.js +22 -15
  168. package/src/plugins/replicators/mongodb-replicator.class.js +22 -15
  169. package/src/plugins/replicators/mysql-replicator.class.js +52 -17
  170. package/src/plugins/replicators/planetscale-replicator.class.js +30 -4
  171. package/src/plugins/replicators/postgres-replicator.class.js +62 -27
  172. package/src/plugins/replicators/s3db-replicator.class.js +25 -18
  173. package/src/plugins/replicators/schema-sync.helper.js +3 -3
  174. package/src/plugins/replicators/sqs-replicator.class.js +8 -2
  175. package/src/plugins/replicators/turso-replicator.class.js +23 -3
  176. package/src/plugins/replicators/webhook-replicator.class.js +42 -4
  177. package/src/plugins/s3-queue.plugin.js +464 -65
  178. package/src/plugins/scheduler.plugin.js +20 -6
  179. package/src/plugins/state-machine.plugin.js +40 -9
  180. package/src/plugins/tfstate/README.md +126 -126
  181. package/src/plugins/tfstate/base-driver.js +28 -4
  182. package/src/plugins/tfstate/errors.js +65 -10
  183. package/src/plugins/tfstate/filesystem-driver.js +52 -8
  184. package/src/plugins/tfstate/index.js +163 -90
  185. package/src/plugins/tfstate/s3-driver.js +64 -6
  186. package/src/plugins/ttl.plugin.js +72 -17
  187. package/src/plugins/vector/distances.js +18 -12
  188. package/src/plugins/vector/kmeans.js +26 -4
  189. package/src/resource.class.js +115 -19
  190. package/src/testing/factory.class.js +20 -3
  191. package/src/testing/seeder.class.js +7 -1
  192. package/src/clients/memory-client.md +0 -917
  193. package/src/plugins/cloud-inventory/drivers/mock-drivers.js +0 -449
@@ -3,13 +3,6 @@
3
3
  *
4
4
  * Drop-in replacement for the standard S3 Client that stores everything in memory.
5
5
  * Implements the complete Client interface including all AWS SDK commands.
6
- *
7
- * Usage:
8
- * import { Database } from 's3db.js';
9
- * import { MemoryClient } from 's3db.js/plugins/emulator';
10
- *
11
- * const db = new Database({ client: new MemoryClient() });
12
- * await db.connect();
13
6
  */
14
7
 
15
8
  import path from 'path';
@@ -20,36 +13,45 @@ import { PromisePool } from '@supercharge/promise-pool';
20
13
  import tryFn from '../concerns/try-fn.js';
21
14
  import { idGenerator } from '../concerns/id.js';
22
15
  import { metadataEncode, metadataDecode } from '../concerns/metadata-encoding.js';
23
- import { mapAwsError } from '../errors.js';
16
+ import { mapAwsError, DatabaseError, BaseError } from '../errors.js';
24
17
  import { MemoryStorage } from './memory-storage.class.js';
25
18
 
26
- /**
27
- * MemoryClient - simulates S3Client entirely in memory
28
- */
19
+ const pathPosix = path.posix;
20
+
21
+ // Global storage registry - share storage between MemoryClient instances with same bucket
22
+ // This allows reconnection to work properly (simulates S3 persistence)
23
+ const globalStorageRegistry = new Map();
24
+
29
25
  export class MemoryClient extends EventEmitter {
30
26
  constructor(config = {}) {
31
27
  super();
32
28
 
33
29
  // Client configuration
34
30
  this.id = config.id || idGenerator(77);
35
- this.verbose = config.verbose || false;
31
+ this.verbose = Boolean(config.verbose);
36
32
  this.parallelism = config.parallelism || 10;
37
33
 
38
34
  // Storage configuration
39
35
  this.bucket = config.bucket || 's3db';
40
36
  this.keyPrefix = config.keyPrefix || '';
41
37
  this.region = config.region || 'us-east-1';
38
+ this._keyPrefixForStrip = this.keyPrefix ? pathPosix.join(this.keyPrefix, '') : '';
42
39
 
43
- // Create internal storage engine
44
- this.storage = new MemoryStorage({
45
- bucket: this.bucket,
46
- enforceLimits: config.enforceLimits || false,
47
- metadataLimit: config.metadataLimit || 2048,
48
- maxObjectSize: config.maxObjectSize || 5 * 1024 * 1024 * 1024,
49
- persistPath: config.persistPath,
50
- autoPersist: config.autoPersist || false,
51
- verbose: this.verbose
52
- });
40
+ // Get or create shared storage for this bucket
41
+ // This allows multiple MemoryClient instances to share the same data (simulating S3 persistence)
42
+ if (!globalStorageRegistry.has(this.bucket)) {
43
+ globalStorageRegistry.set(this.bucket, new MemoryStorage({
44
+ bucket: this.bucket,
45
+ enforceLimits: config.enforceLimits || false,
46
+ metadataLimit: config.metadataLimit || 2048,
47
+ maxObjectSize: config.maxObjectSize || 5 * 1024 * 1024 * 1024,
48
+ persistPath: config.persistPath,
49
+ autoPersist: config.autoPersist || false,
50
+ verbose: this.verbose
51
+ }));
52
+ }
53
+
54
+ this.storage = globalStorageRegistry.get(this.bucket);
53
55
 
54
56
  // Mock config object (for compatibility with Client interface)
55
57
  this.config = {
@@ -103,7 +105,12 @@ export class MemoryClient extends EventEmitter {
103
105
  response = await this._handleListObjects(input);
104
106
  break;
105
107
  default:
106
- throw new Error(`Unsupported command: ${commandName}`);
108
+ throw new DatabaseError(`Unsupported command: ${commandName}`, {
109
+ operation: 'sendCommand',
110
+ statusCode: 400,
111
+ retriable: false,
112
+ suggestion: 'Use one of the supported commands: PutObject, GetObject, HeadObject, CopyObject, DeleteObject, DeleteObjects, or ListObjectsV2.'
113
+ });
107
114
  }
108
115
 
109
116
  this.emit('cl:response', commandName, response, input);
@@ -111,6 +118,9 @@ export class MemoryClient extends EventEmitter {
111
118
  return response;
112
119
 
113
120
  } catch (error) {
121
+ if (error instanceof BaseError) {
122
+ throw error;
123
+ }
114
124
  // Map errors to AWS SDK format
115
125
  const mappedError = mapAwsError(error, {
116
126
  bucket: this.bucket,
@@ -126,13 +136,14 @@ export class MemoryClient extends EventEmitter {
126
136
  * PutObjectCommand handler
127
137
  */
128
138
  async _handlePutObject(input) {
129
- const key = input.Key;
130
- const metadata = input.Metadata || {};
139
+ const key = this._applyKeyPrefix(input.Key);
140
+ const metadata = this._encodeMetadata(input.Metadata || {});
131
141
  const contentType = input.ContentType;
132
142
  const body = input.Body;
133
143
  const contentEncoding = input.ContentEncoding;
134
144
  const contentLength = input.ContentLength;
135
145
  const ifMatch = input.IfMatch;
146
+ const ifNoneMatch = input.IfNoneMatch;
136
147
 
137
148
  return await this.storage.put(key, {
138
149
  body,
@@ -140,7 +151,8 @@ export class MemoryClient extends EventEmitter {
140
151
  contentType,
141
152
  contentEncoding,
142
153
  contentLength,
143
- ifMatch
154
+ ifMatch,
155
+ ifNoneMatch
144
156
  });
145
157
  }
146
158
 
@@ -148,36 +160,41 @@ export class MemoryClient extends EventEmitter {
148
160
  * GetObjectCommand handler
149
161
  */
150
162
  async _handleGetObject(input) {
151
- const key = input.Key;
152
- return await this.storage.get(key);
163
+ const key = this._applyKeyPrefix(input.Key);
164
+ const response = await this.storage.get(key);
165
+ return this._decodeMetadataResponse(response);
153
166
  }
154
167
 
155
168
  /**
156
169
  * HeadObjectCommand handler
157
170
  */
158
171
  async _handleHeadObject(input) {
159
- const key = input.Key;
160
- return await this.storage.head(key);
172
+ const key = this._applyKeyPrefix(input.Key);
173
+ const response = await this.storage.head(key);
174
+ return this._decodeMetadataResponse(response);
161
175
  }
162
176
 
163
177
  /**
164
178
  * CopyObjectCommand handler
165
179
  */
166
180
  async _handleCopyObject(input) {
167
- // Parse source: "bucket/key" format
168
- const copySource = input.CopySource;
169
- const parts = copySource.split('/');
170
- const sourceKey = parts.slice(1).join('/'); // Remove bucket part
171
-
172
- const destinationKey = input.Key;
173
- const metadata = input.Metadata;
174
- const metadataDirective = input.MetadataDirective;
175
- const contentType = input.ContentType;
181
+ const { sourceBucket, sourceKey } = this._parseCopySource(input.CopySource);
182
+
183
+ if (sourceBucket !== this.bucket) {
184
+ throw new DatabaseError(`Cross-bucket copy is not supported in MemoryClient (requested ${sourceBucket} ${this.bucket})`, {
185
+ operation: 'CopyObject',
186
+ retriable: false,
187
+ suggestion: 'Instantiate a MemoryClient with the desired bucket or copy within the same bucket.'
188
+ });
189
+ }
190
+
191
+ const destinationKey = this._applyKeyPrefix(input.Key);
192
+ const encodedMetadata = this._encodeMetadata(input.Metadata);
176
193
 
177
194
  return await this.storage.copy(sourceKey, destinationKey, {
178
- metadata,
179
- metadataDirective,
180
- contentType
195
+ metadata: encodedMetadata,
196
+ metadataDirective: input.MetadataDirective,
197
+ contentType: input.ContentType
181
198
  });
182
199
  }
183
200
 
@@ -185,7 +202,7 @@ export class MemoryClient extends EventEmitter {
185
202
  * DeleteObjectCommand handler
186
203
  */
187
204
  async _handleDeleteObject(input) {
188
- const key = input.Key;
205
+ const key = this._applyKeyPrefix(input.Key);
189
206
  return await this.storage.delete(key);
190
207
  }
191
208
 
@@ -194,7 +211,7 @@ export class MemoryClient extends EventEmitter {
194
211
  */
195
212
  async _handleDeleteObjects(input) {
196
213
  const objects = input.Delete?.Objects || [];
197
- const keys = objects.map(obj => obj.Key);
214
+ const keys = objects.map(obj => this._applyKeyPrefix(obj.Key));
198
215
  return await this.storage.deleteMultiple(keys);
199
216
  }
200
217
 
@@ -202,33 +219,30 @@ export class MemoryClient extends EventEmitter {
202
219
  * ListObjectsV2Command handler
203
220
  */
204
221
  async _handleListObjects(input) {
205
- const fullPrefix = this.keyPrefix && input.Prefix
206
- ? path.join(this.keyPrefix, input.Prefix)
207
- : (this.keyPrefix || input.Prefix || '');
208
-
209
- return await this.storage.list({
222
+ const fullPrefix = this._applyKeyPrefix(input.Prefix || '');
223
+ const params = {
210
224
  prefix: fullPrefix,
211
225
  delimiter: input.Delimiter,
212
226
  maxKeys: input.MaxKeys,
213
227
  continuationToken: input.ContinuationToken
214
- });
228
+ };
229
+
230
+ if (input.StartAfter) {
231
+ params.startAfter = this._applyKeyPrefix(input.StartAfter);
232
+ }
233
+
234
+ const response = await this.storage.list(params);
235
+ return this._normalizeListResponse(response);
215
236
  }
216
237
 
217
238
  /**
218
239
  * Put an object (Client interface method)
219
240
  */
220
- async putObject({ key, metadata, contentType, body, contentEncoding, contentLength, ifMatch }) {
221
- const fullKey = this.keyPrefix ? path.join(this.keyPrefix, key) : key;
241
+ async putObject({ key, metadata, contentType, body, contentEncoding, contentLength, ifMatch, ifNoneMatch }) {
242
+ const fullKey = this._applyKeyPrefix(key);
243
+ const stringMetadata = this._encodeMetadata(metadata) || {};
222
244
 
223
- // Encode metadata using s3db encoding
224
- const stringMetadata = {};
225
- if (metadata) {
226
- for (const [k, v] of Object.entries(metadata)) {
227
- const validKey = String(k).replace(/[^a-zA-Z0-9\-_]/g, '_');
228
- const { encoded } = metadataEncode(v);
229
- stringMetadata[validKey] = encoded;
230
- }
231
- }
245
+ const input = { Key: key, Metadata: metadata, ContentType: contentType, Body: body, ContentEncoding: contentEncoding, ContentLength: contentLength, IfMatch: ifMatch, IfNoneMatch: ifNoneMatch };
232
246
 
233
247
  const response = await this.storage.put(fullKey, {
234
248
  body,
@@ -236,10 +250,12 @@ export class MemoryClient extends EventEmitter {
236
250
  contentType,
237
251
  contentEncoding,
238
252
  contentLength,
239
- ifMatch
253
+ ifMatch,
254
+ ifNoneMatch
240
255
  });
241
256
 
242
- this.emit('cl:PutObject', null, { key, metadata, contentType, body, contentEncoding, contentLength });
257
+ // Emit cl:response event for CostsPlugin compatibility
258
+ this.emit('cl:response', 'PutObjectCommand', response, input);
243
259
 
244
260
  return response;
245
261
  }
@@ -248,64 +264,41 @@ export class MemoryClient extends EventEmitter {
248
264
  * Get an object (Client interface method)
249
265
  */
250
266
  async getObject(key) {
251
- const fullKey = this.keyPrefix ? path.join(this.keyPrefix, key) : key;
267
+ const fullKey = this._applyKeyPrefix(key);
268
+ const input = { Key: key };
252
269
  const response = await this.storage.get(fullKey);
270
+ const decodedResponse = this._decodeMetadataResponse(response);
253
271
 
254
- // Decode metadata
255
- const decodedMetadata = {};
256
- if (response.Metadata) {
257
- for (const [k, v] of Object.entries(response.Metadata)) {
258
- decodedMetadata[k] = metadataDecode(v);
259
- }
260
- }
261
-
262
- this.emit('cl:GetObject', null, { key });
272
+ // Emit cl:response event for CostsPlugin compatibility
273
+ this.emit('cl:response', 'GetObjectCommand', decodedResponse, input);
263
274
 
264
- return {
265
- ...response,
266
- Metadata: decodedMetadata
267
- };
275
+ return decodedResponse;
268
276
  }
269
277
 
270
278
  /**
271
279
  * Head object (get metadata only)
272
280
  */
273
281
  async headObject(key) {
274
- const fullKey = this.keyPrefix ? path.join(this.keyPrefix, key) : key;
282
+ const fullKey = this._applyKeyPrefix(key);
283
+ const input = { Key: key };
275
284
  const response = await this.storage.head(fullKey);
285
+ const decodedResponse = this._decodeMetadataResponse(response);
276
286
 
277
- // Decode metadata
278
- const decodedMetadata = {};
279
- if (response.Metadata) {
280
- for (const [k, v] of Object.entries(response.Metadata)) {
281
- decodedMetadata[k] = metadataDecode(v);
282
- }
283
- }
284
-
285
- this.emit('cl:HeadObject', null, { key });
287
+ // Emit cl:response event for CostsPlugin compatibility
288
+ this.emit('cl:response', 'HeadObjectCommand', decodedResponse, input);
286
289
 
287
- return {
288
- ...response,
289
- Metadata: decodedMetadata
290
- };
290
+ return decodedResponse;
291
291
  }
292
292
 
293
293
  /**
294
294
  * Copy an object
295
295
  */
296
296
  async copyObject({ from, to, metadata, metadataDirective, contentType }) {
297
- const fullFrom = this.keyPrefix ? path.join(this.keyPrefix, from) : from;
298
- const fullTo = this.keyPrefix ? path.join(this.keyPrefix, to) : to;
299
-
300
- // Encode new metadata if provided
301
- const encodedMetadata = {};
302
- if (metadata) {
303
- for (const [k, v] of Object.entries(metadata)) {
304
- const validKey = String(k).replace(/[^a-zA-Z0-9\-_]/g, '_');
305
- const { encoded } = metadataEncode(v);
306
- encodedMetadata[validKey] = encoded;
307
- }
308
- }
297
+ const fullFrom = this._applyKeyPrefix(from);
298
+ const fullTo = this._applyKeyPrefix(to);
299
+ const encodedMetadata = this._encodeMetadata(metadata);
300
+
301
+ const input = { CopySource: from, Key: to, Metadata: metadata, MetadataDirective: metadataDirective, ContentType: contentType };
309
302
 
310
303
  const response = await this.storage.copy(fullFrom, fullTo, {
311
304
  metadata: encodedMetadata,
@@ -313,7 +306,8 @@ export class MemoryClient extends EventEmitter {
313
306
  contentType
314
307
  });
315
308
 
316
- this.emit('cl:CopyObject', null, { from, to, metadata, metadataDirective });
309
+ // Emit cl:response event for CostsPlugin compatibility
310
+ this.emit('cl:response', 'CopyObjectCommand', response, input);
317
311
 
318
312
  return response;
319
313
  }
@@ -322,7 +316,7 @@ export class MemoryClient extends EventEmitter {
322
316
  * Check if object exists
323
317
  */
324
318
  async exists(key) {
325
- const fullKey = this.keyPrefix ? path.join(this.keyPrefix, key) : key;
319
+ const fullKey = this._applyKeyPrefix(key);
326
320
  return this.storage.exists(fullKey);
327
321
  }
328
322
 
@@ -330,10 +324,12 @@ export class MemoryClient extends EventEmitter {
330
324
  * Delete an object
331
325
  */
332
326
  async deleteObject(key) {
333
- const fullKey = this.keyPrefix ? path.join(this.keyPrefix, key) : key;
327
+ const fullKey = this._applyKeyPrefix(key);
328
+ const input = { Key: key };
334
329
  const response = await this.storage.delete(fullKey);
335
330
 
336
- this.emit('cl:DeleteObject', null, { key });
331
+ // Emit cl:response event for CostsPlugin compatibility
332
+ this.emit('cl:response', 'DeleteObjectCommand', response, input);
337
333
 
338
334
  return response;
339
335
  }
@@ -343,9 +339,9 @@ export class MemoryClient extends EventEmitter {
343
339
  */
344
340
  async deleteObjects(keys) {
345
341
  // Add keyPrefix to all keys
346
- const fullKeys = keys.map(key =>
347
- this.keyPrefix ? path.join(this.keyPrefix, key) : key
348
- );
342
+ const fullKeys = keys.map(key => this._applyKeyPrefix(key));
343
+
344
+ const input = { Delete: { Objects: keys.map(key => ({ Key: key })) } };
349
345
 
350
346
  // Split into batches for parallel processing
351
347
  const batches = chunk(fullKeys, this.parallelism);
@@ -360,11 +356,12 @@ export class MemoryClient extends EventEmitter {
360
356
 
361
357
  // Merge results
362
358
  for (const result of results) {
363
- allResults.Deleted.push(...result.Deleted);
359
+ allResults.Deleted.push(...result.Deleted.map(item => ({ Key: this._stripKeyPrefix(item.Key) })));
364
360
  allResults.Errors.push(...result.Errors);
365
361
  }
366
362
 
367
- this.emit('deleteObjects', null, { keys, count: allResults.Deleted.length });
363
+ // Emit cl:response event for CostsPlugin compatibility
364
+ this.emit('cl:response', 'DeleteObjectsCommand', allResults, input);
368
365
 
369
366
  return allResults;
370
367
  }
@@ -372,19 +369,28 @@ export class MemoryClient extends EventEmitter {
372
369
  /**
373
370
  * List objects with pagination support
374
371
  */
375
- async listObjects({ prefix = '', delimiter = null, maxKeys = 1000, continuationToken = null }) {
376
- const fullPrefix = this.keyPrefix ? path.join(this.keyPrefix, prefix) : prefix;
377
-
378
- const response = await this.storage.list({
372
+ async listObjects({ prefix = '', delimiter = null, maxKeys = 1000, continuationToken = null, startAfter = null } = {}) {
373
+ const fullPrefix = this._applyKeyPrefix(prefix || '');
374
+ const listParams = {
379
375
  prefix: fullPrefix,
380
376
  delimiter,
381
377
  maxKeys,
382
378
  continuationToken
383
- });
379
+ };
384
380
 
385
- this.emit('cl:ListObjects', null, { prefix, count: response.Contents.length });
381
+ if (startAfter) {
382
+ listParams.startAfter = this._applyKeyPrefix(startAfter);
383
+ }
386
384
 
387
- return response;
385
+ const input = { Prefix: prefix, Delimiter: delimiter, MaxKeys: maxKeys, ContinuationToken: continuationToken, StartAfter: startAfter };
386
+
387
+ const response = await this.storage.list(listParams);
388
+ const normalized = this._normalizeListResponse(response);
389
+
390
+ // Emit cl:response event for CostsPlugin compatibility
391
+ this.emit('cl:response', 'ListObjectsV2Command', normalized, input);
392
+
393
+ return normalized;
388
394
  }
389
395
 
390
396
  /**
@@ -398,22 +404,29 @@ export class MemoryClient extends EventEmitter {
398
404
 
399
405
  // If offset > 0, need to skip ahead
400
406
  if (offset > 0) {
401
- // For simplicity, fetch all up to offset + amount and slice
402
- const fullPrefix = this.keyPrefix ? path.join(this.keyPrefix, prefix) : prefix;
407
+ const fullPrefix = this._applyKeyPrefix(prefix || '');
403
408
  const response = await this.storage.list({
404
409
  prefix: fullPrefix,
405
410
  maxKeys: offset + amount
406
411
  });
407
- keys = response.Contents.map(x => x.Key).slice(offset, offset + amount);
412
+ keys = (response.Contents || [])
413
+ .map(x => this._stripKeyPrefix(x.Key))
414
+ .slice(offset, offset + amount);
415
+ truncated = Boolean(response.NextContinuationToken);
416
+ continuationToken = response.NextContinuationToken;
408
417
  } else {
409
418
  // Regular fetch with amount as maxKeys
410
419
  while (truncated) {
411
- const options = {
420
+ const remaining = amount - keys.length;
421
+ if (remaining <= 0) {
422
+ break;
423
+ }
424
+
425
+ const res = await this.listObjects({
412
426
  prefix,
413
427
  continuationToken,
414
- maxKeys: amount - keys.length
415
- };
416
- const res = await this.listObjects(options);
428
+ maxKeys: remaining
429
+ });
417
430
  if (res.Contents) {
418
431
  keys = keys.concat(res.Contents.map(x => x.Key));
419
432
  }
@@ -426,13 +439,6 @@ export class MemoryClient extends EventEmitter {
426
439
  }
427
440
  }
428
441
 
429
- // Strip keyPrefix from results
430
- if (this.keyPrefix) {
431
- keys = keys
432
- .map(x => x.replace(this.keyPrefix, ''))
433
- .map(x => (x.startsWith('/') ? x.replace('/', '') : x));
434
- }
435
-
436
442
  this.emit('cl:GetKeysPage', keys, params);
437
443
  return keys;
438
444
  }
@@ -441,20 +447,13 @@ export class MemoryClient extends EventEmitter {
441
447
  * Get all keys with a given prefix
442
448
  */
443
449
  async getAllKeys({ prefix = '' }) {
444
- const fullPrefix = this.keyPrefix ? path.join(this.keyPrefix, prefix) : prefix;
450
+ const fullPrefix = this._applyKeyPrefix(prefix || '');
445
451
  const response = await this.storage.list({
446
452
  prefix: fullPrefix,
447
- maxKeys: 100000 // Large number to get all
453
+ maxKeys: Number.MAX_SAFE_INTEGER
448
454
  });
449
455
 
450
- let keys = response.Contents.map(x => x.Key);
451
-
452
- // Strip keyPrefix from results
453
- if (this.keyPrefix) {
454
- keys = keys
455
- .map(x => x.replace(this.keyPrefix, ''))
456
- .map(x => (x.startsWith('/') ? x.replace('/', '') : x));
457
- }
456
+ const keys = (response.Contents || []).map(x => this._stripKeyPrefix(x.Key));
458
457
 
459
458
  this.emit('cl:GetAllKeys', keys, { prefix });
460
459
  return keys;
@@ -511,40 +510,47 @@ export class MemoryClient extends EventEmitter {
511
510
  }
512
511
 
513
512
  // Return the key at offset position as continuation token
514
- const token = keys[offset];
513
+ const keyForToken = keys[offset];
514
+ const fullKey = this._applyKeyPrefix(keyForToken || '');
515
+ const token = this._encodeContinuationTokenKey(fullKey);
515
516
  this.emit('cl:GetContinuationTokenAfterOffset', token, { prefix, offset });
516
517
  return token;
517
518
  }
518
519
 
519
520
  /**
520
- * Move an object from one key to another
521
+ * Move a single object (copy + delete)
521
522
  */
522
523
  async moveObject({ from, to }) {
523
- await this.copyObject({ from, to, metadataDirective: 'COPY' });
524
- await this.deleteObject(from);
524
+ const [ok, err] = await tryFn(async () => {
525
+ await this.copyObject({ from, to, metadataDirective: 'COPY' });
526
+ await this.deleteObject(from);
527
+ });
528
+
529
+ if (!ok) {
530
+ throw new DatabaseError('Unknown error in moveObject', {
531
+ bucket: this.bucket,
532
+ from,
533
+ to,
534
+ original: err
535
+ });
536
+ }
537
+
538
+ return true;
525
539
  }
526
540
 
527
541
  /**
528
- * Move all objects from one prefix to another
542
+ * Move all objects under a prefix
529
543
  */
530
544
  async moveAllObjects({ prefixFrom, prefixTo }) {
531
545
  const keys = await this.getAllKeys({ prefix: prefixFrom });
532
- const results = [];
533
- const errors = [];
534
-
535
- for (const key of keys) {
536
- try {
546
+ const { results, errors } = await PromisePool
547
+ .withConcurrency(this.parallelism)
548
+ .for(keys)
549
+ .process(async (key) => {
537
550
  const to = key.replace(prefixFrom, prefixTo);
538
551
  await this.moveObject({ from: key, to });
539
- results.push(to);
540
- } catch (error) {
541
- errors.push({
542
- message: error.message,
543
- raw: error,
544
- key
545
- });
546
- }
547
- }
552
+ return { from: key, to };
553
+ });
548
554
 
549
555
  this.emit('moveAllObjects', { results, errors });
550
556
 
@@ -595,15 +601,7 @@ export class MemoryClient extends EventEmitter {
595
601
  }
596
602
 
597
603
  /**
598
- * Export to BackupPlugin-compatible format (s3db.json + JSONL files)
599
- * Compatible with BackupPlugin for easy migration
600
- *
601
- * @param {string} outputDir - Output directory path
602
- * @param {Object} options - Export options
603
- * @param {Array<string>} options.resources - Resource names to export (default: all)
604
- * @param {boolean} options.compress - Use gzip compression (default: true)
605
- * @param {Object} options.database - Database instance for schema metadata
606
- * @returns {Promise<Object>} Export manifest with file paths and stats
604
+ * Export to BackupPlugin-compatible format
607
605
  */
608
606
  async exportBackup(outputDir, options = {}) {
609
607
  const { mkdir, writeFile } = await import('fs/promises');
@@ -648,7 +646,10 @@ export class MemoryClient extends EventEmitter {
648
646
  for (const key of keys) {
649
647
  // Extract id from key (e.g., resource=products/id=pr1 -> pr1)
650
648
  const idMatch = key.match(/\/id=([^/]+)/);
651
- const recordId = idMatch ? idMatch[1] : null;
649
+ let recordId = null;
650
+ if (idMatch && idMatch[1]) {
651
+ recordId = idMatch[1];
652
+ }
652
653
 
653
654
  let record;
654
655
 
@@ -656,8 +657,7 @@ export class MemoryClient extends EventEmitter {
656
657
  if (resource && recordId) {
657
658
  try {
658
659
  record = await resource.get(recordId);
659
- } catch (err) {
660
- // Fallback to manual reconstruction if get() fails
660
+ } catch {
661
661
  console.warn(`Failed to get record ${recordId} from resource ${resourceName}, using fallback`);
662
662
  record = null;
663
663
  }
@@ -776,14 +776,6 @@ export class MemoryClient extends EventEmitter {
776
776
 
777
777
  /**
778
778
  * Import from BackupPlugin-compatible format
779
- * Loads data from s3db.json + JSONL files created by BackupPlugin or exportBackup()
780
- *
781
- * @param {string} backupDir - Backup directory path containing s3db.json
782
- * @param {Object} options - Import options
783
- * @param {Array<string>} options.resources - Resource names to import (default: all)
784
- * @param {boolean} options.clear - Clear existing data first (default: false)
785
- * @param {Object} options.database - Database instance to recreate schemas
786
- * @returns {Promise<Object>} Import stats
787
779
  */
788
780
  async importBackup(backupDir, options = {}) {
789
781
  const { readFile, readdir } = await import('fs/promises');
@@ -812,7 +804,8 @@ export class MemoryClient extends EventEmitter {
812
804
  // Recreate resources if database instance provided
813
805
  if (database && metadata.resources) {
814
806
  for (const [resourceName, resourceMeta] of Object.entries(metadata.resources)) {
815
- if (resourceFilter && !resourceFilter.includes(resourceName)) continue;
807
+ /* c8 ignore next -- helper coverage exercised separately */
808
+ if (!this._shouldProcessResource(resourceFilter, resourceName)) continue;
816
809
 
817
810
  if (resourceMeta.schema) {
818
811
  try {
@@ -822,6 +815,9 @@ export class MemoryClient extends EventEmitter {
822
815
  });
823
816
  } catch (error) {
824
817
  // Resource might already exist, that's ok
818
+ if (this.verbose) {
819
+ console.warn(`Failed to create resource ${resourceName} during import: ${error.message}`);
820
+ }
825
821
  }
826
822
  }
827
823
  }
@@ -835,7 +831,8 @@ export class MemoryClient extends EventEmitter {
835
831
  if (!file.endsWith('.jsonl') && !file.endsWith('.jsonl.gz')) continue;
836
832
 
837
833
  const resourceName = file.replace(/\.jsonl(\.gz)?$/, '');
838
- if (resourceFilter && !resourceFilter.includes(resourceName)) continue;
834
+ /* c8 ignore next -- helper coverage exercised separately */
835
+ if (!this._shouldProcessResource(resourceFilter, resourceName)) continue;
839
836
 
840
837
  const filePath = `${backupDir}/${file}`;
841
838
  let content = await readFile(filePath);
@@ -854,18 +851,26 @@ export class MemoryClient extends EventEmitter {
854
851
  const record = JSON.parse(line);
855
852
 
856
853
  // Extract id or use generated one
857
- const id = record.id || record._id || `imported_${Date.now()}_${Math.random()}`;
854
+ let id;
855
+ if (record.id) {
856
+ id = record.id;
857
+ } else if (record._id) {
858
+ id = record._id;
859
+ } else {
860
+ id = `imported_${Date.now()}_${Math.random()}`;
861
+ }
858
862
 
859
863
  // Separate _body from other fields
860
- const { _body, id: _, _id: __, ...metadata } = record;
864
+ const { _body, id: _, _id: __, ...metadataRecord } = record;
865
+ let bodyBuffer;
866
+ if (typeof _body === 'string') {
867
+ bodyBuffer = Buffer.from(_body);
868
+ }
861
869
 
862
- // Store in MemoryClient
863
- // If _body exists, it's non-JSON body data
864
- // Otherwise, metadata contains all the data
865
870
  await this.putObject({
866
871
  key: `resource=${resourceName}/id=${id}`,
867
- metadata,
868
- body: _body ? Buffer.from(_body) : undefined
872
+ metadata: metadataRecord,
873
+ body: bodyBuffer
869
874
  });
870
875
 
871
876
  importStats.recordsImported++;
@@ -897,6 +902,156 @@ export class MemoryClient extends EventEmitter {
897
902
  clear() {
898
903
  this.storage.clear();
899
904
  }
905
+
906
+ /**
907
+ * Encode metadata values using s3db metadata encoding
908
+ * Note: S3 metadata keys are case-insensitive and stored as lowercase
909
+ */
910
+ _encodeMetadata(metadata) {
911
+ if (!metadata) return undefined;
912
+
913
+ const encoded = {};
914
+ for (const [rawKey, value] of Object.entries(metadata)) {
915
+ const validKey = String(rawKey).replace(/[^a-zA-Z0-9\-_]/g, '_').toLowerCase();
916
+ const { encoded: encodedValue } = metadataEncode(value);
917
+ encoded[validKey] = encodedValue;
918
+ }
919
+ return encoded;
920
+ }
921
+
922
+ _shouldProcessResource(resourceFilter, resourceName) {
923
+ if (!Array.isArray(resourceFilter) || resourceFilter.length === 0) {
924
+ return true;
925
+ }
926
+
927
+ return resourceFilter.includes(resourceName);
928
+ }
929
+
930
+ /**
931
+ * Decode metadata in S3 responses
932
+ */
933
+ _decodeMetadataResponse(response) {
934
+ const decodedMetadata = {};
935
+ if (response.Metadata) {
936
+ for (const [k, v] of Object.entries(response.Metadata)) {
937
+ decodedMetadata[k] = metadataDecode(v);
938
+ }
939
+ }
940
+
941
+ return {
942
+ ...response,
943
+ Metadata: decodedMetadata
944
+ };
945
+ }
946
+
947
+ /**
948
+ * Apply configured keyPrefix to a storage key
949
+ */
950
+ /* c8 ignore start */
951
+ _applyKeyPrefix(key = '') {
952
+ if (!this.keyPrefix) {
953
+ if (key === undefined || key === null) {
954
+ return '';
955
+ }
956
+ return key;
957
+ }
958
+ if (key === undefined || key === null || key === '') {
959
+ return pathPosix.join(this.keyPrefix, '');
960
+ }
961
+
962
+ return pathPosix.join(this.keyPrefix, key);
963
+ }
964
+ /* c8 ignore end */
965
+
966
+ /**
967
+ * Strip configured keyPrefix from a storage key
968
+ */
969
+ _stripKeyPrefix(key = '') {
970
+ if (!this.keyPrefix) {
971
+ return key;
972
+ }
973
+
974
+ const normalizedPrefix = this._keyPrefixForStrip;
975
+ if (normalizedPrefix && key.startsWith(normalizedPrefix)) {
976
+ return key.slice(normalizedPrefix.length).replace(/^\/+/, '');
977
+ }
978
+
979
+ return key;
980
+ }
981
+
982
+ /**
983
+ * Encode continuation token (base64) to mimic AWS S3
984
+ */
985
+ _encodeContinuationTokenKey(key) {
986
+ return Buffer.from(String(key), 'utf8').toString('base64');
987
+ }
988
+
989
+ /**
990
+ * Parse CopySource header and return bucket/key
991
+ */
992
+ _parseCopySource(copySource = '') {
993
+ const trimmedSource = String(copySource).replace(/^\//, '');
994
+ const [sourcePath] = trimmedSource.split('?');
995
+ const decodedSource = decodeURIComponent(sourcePath);
996
+ const [sourceBucket, ...sourceKeyParts] = decodedSource.split('/');
997
+
998
+ if (!sourceBucket || sourceKeyParts.length === 0) {
999
+ throw new DatabaseError(`Invalid CopySource value: ${copySource}`, {
1000
+ operation: 'CopyObject',
1001
+ retriable: false,
1002
+ suggestion: 'Provide CopySource in the format "<bucket>/<key>" as expected by AWS S3.'
1003
+ });
1004
+ }
1005
+
1006
+ return {
1007
+ sourceBucket,
1008
+ sourceKey: sourceKeyParts.join('/')
1009
+ };
1010
+ }
1011
+
1012
+ /**
1013
+ * Normalize storage list response into client-level structure
1014
+ */
1015
+ _normalizeListResponse(response) {
1016
+ const rawContents = Array.isArray(response.Contents) ? response.Contents : [];
1017
+ const contents = rawContents.map(item => ({
1018
+ ...item,
1019
+ Key: this._stripKeyPrefix(item.Key)
1020
+ }));
1021
+
1022
+ const rawPrefixes = Array.isArray(response.CommonPrefixes) ? response.CommonPrefixes : [];
1023
+ const commonPrefixes = rawPrefixes.map(({ Prefix }) => ({
1024
+ Prefix: this._stripKeyPrefix(Prefix)
1025
+ }));
1026
+
1027
+ return {
1028
+ Contents: contents,
1029
+ CommonPrefixes: commonPrefixes,
1030
+ IsTruncated: response.IsTruncated,
1031
+ ContinuationToken: response.ContinuationToken,
1032
+ NextContinuationToken: response.NextContinuationToken,
1033
+ KeyCount: contents.length,
1034
+ MaxKeys: response.MaxKeys,
1035
+ Prefix: this.keyPrefix ? undefined : response.Prefix,
1036
+ Delimiter: response.Delimiter,
1037
+ StartAfter: response.StartAfter
1038
+ };
1039
+ }
1040
+
1041
+ /**
1042
+ * Clear all shared storage for a specific bucket (useful for testing)
1043
+ * @param {string} bucket - Bucket name to clear
1044
+ */
1045
+ static clearBucketStorage(bucket) {
1046
+ globalStorageRegistry.delete(bucket);
1047
+ }
1048
+
1049
+ /**
1050
+ * Clear ALL shared storage (useful for test cleanup)
1051
+ */
1052
+ static clearAllStorage() {
1053
+ globalStorageRegistry.clear();
1054
+ }
900
1055
  }
901
1056
 
902
1057
  export default MemoryClient;