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
@@ -1,6 +1,8 @@
1
1
  import { Plugin } from "./plugin.class.js";
2
2
  import tryFn from "../concerns/try-fn.js";
3
3
  import { idGenerator } from "../concerns/id.js";
4
+ import { resolveResourceName } from "./concerns/resource-names.js";
5
+ import { QueueError } from "./queue.errors.js";
4
6
 
5
7
  /**
6
8
  * S3QueuePlugin - Distributed Queue System with ETag-based Atomicity
@@ -64,24 +66,58 @@ export class S3QueuePlugin extends Plugin {
64
66
  constructor(options = {}) {
65
67
  super(options);
66
68
 
69
+ const resourceNamesOption = options.resourceNames || {};
67
70
  if (!options.resource) {
68
- throw new Error('S3QueuePlugin requires "resource" option');
71
+ throw new QueueError('S3QueuePlugin requires "resource" option', {
72
+ pluginName: 'S3QueuePlugin',
73
+ operation: 'constructor',
74
+ statusCode: 400,
75
+ retriable: false,
76
+ suggestion: 'Provide the target resource name: new S3QueuePlugin({ resource: "orders", ... }).'
77
+ });
69
78
  }
70
79
 
71
80
  this.config = {
81
+ ...options,
72
82
  resource: options.resource,
73
- visibilityTimeout: options.visibilityTimeout || 30000, // 30 seconds
74
- pollInterval: options.pollInterval || 1000, // 1 second
75
- maxAttempts: options.maxAttempts || 3,
76
- concurrency: options.concurrency || 1,
77
- deadLetterResource: options.deadLetterResource || null,
83
+ visibilityTimeout: options.visibilityTimeout ?? 30000, // 30 seconds
84
+ pollInterval: options.pollInterval ?? 1000, // 1 second
85
+ maxAttempts: options.maxAttempts ?? 3,
86
+ concurrency: options.concurrency ?? 1,
87
+ deadLetterResource: options.deadLetterResource ?? null,
78
88
  autoStart: options.autoStart !== false,
79
89
  onMessage: options.onMessage,
80
90
  onError: options.onError,
81
91
  onComplete: options.onComplete,
82
- verbose: options.verbose || false,
83
- ...options
92
+ verbose: options.verbose ?? false
93
+ };
94
+ this.config.pollBatchSize = options.pollBatchSize ?? Math.max((this.config.concurrency || 1) * 4, 16);
95
+ this.config.recoveryInterval = options.recoveryInterval ?? 5000;
96
+ this.config.recoveryBatchSize = options.recoveryBatchSize ?? Math.max((this.config.concurrency || 1) * 2, 10);
97
+ this.config.processedCacheTTL = options.processedCacheTTL ?? 30000;
98
+ this.config.maxPollInterval = options.maxPollInterval ?? this.config.pollInterval;
99
+
100
+ this._queueResourceDescriptor = {
101
+ defaultName: `plg_s3queue_${this.config.resource}_queue`,
102
+ override: resourceNamesOption.queue || options.queueResource
84
103
  };
104
+ this.queueResourceName = this._resolveQueueResourceName();
105
+ this.config.queueResourceName = this.queueResourceName;
106
+
107
+ if (this.config.deadLetterResource) {
108
+ this._deadLetterDescriptor = {
109
+ defaultName: `plg_s3queue_${this.config.resource}_dead`,
110
+ override: resourceNamesOption.deadLetter || this.config.deadLetterResource
111
+ };
112
+ } else {
113
+ this._deadLetterDescriptor = null;
114
+ }
115
+
116
+ this.deadLetterResourceName = this._resolveDeadLetterResourceName();
117
+ this.config.deadLetterResource = this.deadLetterResourceName;
118
+
119
+ this.queueResourceAlias = options.queueResource || `${this.config.resource}_queue`;
120
+ this.deadLetterResourceAlias = options.deadLetterResource || null;
85
121
 
86
122
  this.queueResource = null; // Resource: <resource>_queue
87
123
  this.targetResource = null; // Resource original do usuário
@@ -92,20 +128,63 @@ export class S3QueuePlugin extends Plugin {
92
128
 
93
129
  // Deduplication cache to prevent S3 eventual consistency issues
94
130
  // Tracks recently processed messages to avoid reprocessing
95
- this.processedCache = new Map(); // queueId -> timestamp
131
+ this.processedCache = new Map(); // queueId -> expiresAt
96
132
  this.cacheCleanupInterval = null;
97
133
  this.lockCleanupInterval = null;
134
+ this.messageLocks = new Map();
135
+ this._lastRecovery = 0;
136
+ this._recoveryInFlight = false;
137
+ }
138
+
139
+ _resolveQueueResourceName() {
140
+ return resolveResourceName('s3queue', this._queueResourceDescriptor, {
141
+ namespace: this.namespace
142
+ });
143
+ }
144
+
145
+ _resolveDeadLetterResourceName() {
146
+ if (!this._deadLetterDescriptor) return null;
147
+ const { override, defaultName } = this._deadLetterDescriptor;
148
+ if (override) {
149
+ // Honor explicit overrides verbatim unless user opted into plg_* naming.
150
+ if (override.startsWith('plg_')) {
151
+ return resolveResourceName('s3queue', { override }, {
152
+ namespace: this.namespace,
153
+ applyNamespaceToOverrides: true
154
+ });
155
+ }
156
+ return override;
157
+ }
158
+ return resolveResourceName('s3queue', { defaultName }, {
159
+ namespace: this.namespace
160
+ });
161
+ }
162
+
163
+ onNamespaceChanged() {
164
+ if (!this._queueResourceDescriptor) return;
165
+ this.queueResourceName = this._resolveQueueResourceName();
166
+ this.config.queueResourceName = this.queueResourceName;
167
+ this.deadLetterResourceName = this._resolveDeadLetterResourceName();
168
+ this.config.deadLetterResource = this.deadLetterResourceName;
98
169
  }
99
170
 
100
171
  async onInstall() {
101
172
  // Get target resource
102
173
  this.targetResource = this.database.resources[this.config.resource];
103
174
  if (!this.targetResource) {
104
- throw new Error(`S3QueuePlugin: resource '${this.config.resource}' not found`);
175
+ throw new QueueError(`Resource '${this.config.resource}' not found`, {
176
+ pluginName: 'S3QueuePlugin',
177
+ operation: 'onInstall',
178
+ resourceName: this.config.resource,
179
+ statusCode: 404,
180
+ retriable: false,
181
+ suggestion: 'Create the resource before installing S3QueuePlugin or update the plugin configuration.',
182
+ availableResources: Object.keys(this.database.resources || {})
183
+ });
105
184
  }
106
185
 
107
186
  // Create queue metadata resource
108
- const queueName = `${this.config.resource}_queue`;
187
+ const queueName = this.queueResourceName;
109
188
  const [ok, err] = await tryFn(() =>
110
189
  this.database.createResource({
111
190
  name: queueName,
@@ -133,11 +212,30 @@ export class S3QueuePlugin extends Plugin {
133
212
  })
134
213
  );
135
214
 
136
- if (!ok && !this.database.resources[queueName]) {
137
- throw new Error(`Failed to create queue resource: ${err?.message}`);
215
+ if (ok) {
216
+ this.queueResource = this.database.resources[queueName];
217
+ } else {
218
+ this.queueResource = this.database.resources[queueName];
219
+ if (!this.queueResource) {
220
+ throw new QueueError(`Failed to create queue resource: ${err?.message}`, {
221
+ pluginName: 'S3QueuePlugin',
222
+ operation: 'createQueueResource',
223
+ queueName,
224
+ statusCode: 500,
225
+ retriable: false,
226
+ suggestion: 'Check database permissions and ensure createResource() was successful.',
227
+ original: err
228
+ });
229
+ }
138
230
  }
231
+ this.queueResourceName = this.queueResource.name;
139
232
 
140
- this.queueResource = this.database.resources[queueName];
233
+ if (this.queueResourceAlias) {
234
+ const existing = this.database.resources[this.queueResourceAlias];
235
+ if (!existing || existing === this.queueResource) {
236
+ this.database.resources[this.queueResourceAlias] = this.queueResource;
237
+ }
238
+ }
141
239
 
142
240
  // Locks are now managed by PluginStorage with TTL - no Resource needed
143
241
  // Lock acquisition is handled via storage.acquireLock() with automatic expiration
@@ -220,6 +318,17 @@ export class S3QueuePlugin extends Plugin {
220
318
  resource.stopProcessing = async function() {
221
319
  return await plugin.stopProcessing();
222
320
  };
321
+
322
+ /**
323
+ * Extend visibility timeout for a specific queue entry
324
+ */
325
+ resource.extendQueueVisibility = async function(queueId, extraMilliseconds) {
326
+ return await plugin.extendVisibility(queueId, extraMilliseconds);
327
+ };
328
+
329
+ resource.clearQueueCache = async function() {
330
+ plugin.clearProcessedCache();
331
+ };
223
332
  }
224
333
 
225
334
  async startProcessing(handler = null, options = {}) {
@@ -232,7 +341,14 @@ export class S3QueuePlugin extends Plugin {
232
341
 
233
342
  const messageHandler = handler || this.config.onMessage;
234
343
  if (!messageHandler) {
235
- throw new Error('S3QueuePlugin: onMessage handler required');
344
+ throw new QueueError('onMessage handler required', {
345
+ pluginName: 'S3QueuePlugin',
346
+ operation: 'startProcessing',
347
+ queueName: this.queueResourceName,
348
+ statusCode: 400,
349
+ retriable: false,
350
+ suggestion: 'Pass a handler: resource.startProcessing(async msg => {...}) or configure onMessage in plugin options.'
351
+ });
236
352
  }
237
353
 
238
354
  this.isRunning = true;
@@ -241,16 +357,17 @@ export class S3QueuePlugin extends Plugin {
241
357
  // Start cache cleanup (every 5 seconds, remove entries older than 30 seconds)
242
358
  this.cacheCleanupInterval = setInterval(() => {
243
359
  const now = Date.now();
244
- const maxAge = 30000; // 30 seconds
360
+ const ttl = this.config.processedCacheTTL;
245
361
 
246
- for (const [queueId, timestamp] of this.processedCache.entries()) {
247
- if (now - timestamp > maxAge) {
362
+ for (const [queueId, expiresAt] of this.processedCache.entries()) {
363
+ if (expiresAt <= now || expiresAt - now > ttl * 4) {
248
364
  this.processedCache.delete(queueId);
249
365
  }
250
366
  }
251
367
  }, 5000);
252
368
 
253
369
  // Lock cleanup no longer needed - TTL handles expiration automatically
370
+ this._lastRecovery = 0;
254
371
 
255
372
  // Start N workers
256
373
  for (let i = 0; i < concurrency; i++) {
@@ -294,24 +411,28 @@ export class S3QueuePlugin extends Plugin {
294
411
 
295
412
  createWorker(handler, workerIndex) {
296
413
  return (async () => {
414
+ let idleStreak = 0;
297
415
  while (this.isRunning) {
298
416
  try {
299
417
  // Try to claim a message
300
418
  const message = await this.claimMessage();
301
419
 
302
420
  if (message) {
421
+ idleStreak = 0;
303
422
  // Process the claimed message
304
423
  await this.processMessage(message, handler);
305
424
  } else {
306
425
  // No messages available, wait before polling again
307
- await new Promise(resolve => setTimeout(resolve, this.config.pollInterval));
426
+ idleStreak = Math.min(idleStreak + 1, 10);
427
+ const delay = this._computeIdleDelay(idleStreak);
428
+ await this._sleep(delay);
308
429
  }
309
430
  } catch (error) {
310
431
  if (this.config.verbose) {
311
432
  console.error(`[Worker ${workerIndex}] Error:`, error.message);
312
433
  }
313
434
  // Wait a bit before retrying on error
314
- await new Promise(resolve => setTimeout(resolve, 1000));
435
+ await this._sleep(1000);
315
436
  }
316
437
  }
317
438
  })();
@@ -320,10 +441,15 @@ export class S3QueuePlugin extends Plugin {
320
441
  async claimMessage() {
321
442
  const now = Date.now();
322
443
 
444
+ await this.recoverStalledMessages(now);
445
+
323
446
  // Query for available messages
324
447
  const [ok, err, messages] = await tryFn(() =>
325
448
  this.queueResource.query({
326
- status: 'pending'
449
+ status: 'pending',
450
+ visibleAt: { '<=': now }
451
+ }, {
452
+ limit: this.config.pollBatchSize
327
453
  })
328
454
  );
329
455
 
@@ -352,40 +478,63 @@ export class S3QueuePlugin extends Plugin {
352
478
  * Acquire a distributed lock using PluginStorage TTL
353
479
  * This ensures only one worker can claim a message at a time
354
480
  */
481
+ _lockNameForMessage(messageId) {
482
+ return `msg-${messageId}`;
483
+ }
484
+
355
485
  async acquireLock(messageId) {
356
486
  const storage = this.getStorage();
357
- const lockKey = `msg-${messageId}`;
487
+ const lockName = this._lockNameForMessage(messageId);
358
488
 
359
489
  try {
360
- const lock = await storage.acquireLock(lockKey, {
490
+ const lock = await storage.acquireLock(lockName, {
361
491
  ttl: 5, // 5 seconds
362
492
  timeout: 0, // Don't wait if locked
363
493
  workerId: this.workerId
364
494
  });
365
495
 
366
- return lock !== null;
496
+ if (lock) {
497
+ this.messageLocks.set(lock.name, lock);
498
+ }
499
+
500
+ return lock;
367
501
  } catch (error) {
368
502
  // On any error, skip this message
369
503
  if (this.config.verbose) {
370
504
  console.log(`[acquireLock] Error: ${error.message}`);
371
505
  }
372
- return false;
506
+ return null;
373
507
  }
374
508
  }
375
509
 
376
510
  /**
377
511
  * Release a distributed lock via PluginStorage
378
512
  */
379
- async releaseLock(messageId) {
513
+ async releaseLock(lockOrMessageId) {
380
514
  const storage = this.getStorage();
381
- const lockKey = `msg-${messageId}`;
515
+ let lock = null;
516
+
517
+ if (lockOrMessageId && typeof lockOrMessageId === 'object') {
518
+ lock = lockOrMessageId;
519
+ } else {
520
+ const lockName = this._lockNameForMessage(lockOrMessageId);
521
+ lock = this.messageLocks.get(lockName) || null;
522
+ }
523
+
524
+ if (!lock) {
525
+ return;
526
+ }
382
527
 
383
528
  try {
384
- await storage.releaseLock(lockKey);
529
+ await storage.releaseLock(lock);
385
530
  } catch (error) {
386
531
  // Ignore errors on release (lock may have expired or been cleaned up)
387
532
  if (this.config.verbose) {
388
- console.log(`[releaseLock] Failed to release lock for ${messageId}: ${error.message}`);
533
+ console.log(`[releaseLock] Failed to release lock '${lock.name}': ${error.message}`);
534
+ }
535
+ } finally {
536
+ if (lock?.name) {
537
+ this.messageLocks.delete(lock.name);
389
538
  }
390
539
  }
391
540
  }
@@ -404,28 +553,29 @@ export class S3QueuePlugin extends Plugin {
404
553
 
405
554
  // Try to acquire distributed lock for cache check
406
555
  // This prevents race condition where multiple workers check cache simultaneously
407
- const lockAcquired = await this.acquireLock(msg.id);
556
+ const lock = await this.acquireLock(msg.id);
408
557
 
409
- if (!lockAcquired) {
558
+ if (!lock) {
410
559
  // Another worker is checking/claiming this message, skip it
411
560
  return null;
412
561
  }
413
562
 
414
- // Check deduplication cache (protected by lock)
415
- if (this.processedCache.has(msg.id)) {
416
- await this.releaseLock(msg.id);
417
- if (this.config.verbose) {
418
- console.log(`[attemptClaim] Message ${msg.id} already processed (in cache)`);
563
+ try {
564
+ // Check deduplication cache (protected by lock)
565
+ const alreadyProcessed = await this._isRecentlyProcessed(msg.id);
566
+ if (alreadyProcessed) {
567
+ if (this.config.verbose) {
568
+ console.log(`[attemptClaim] Message ${msg.id} already processed (in cache)`);
569
+ }
570
+ return null;
419
571
  }
420
- return null;
421
- }
422
572
 
423
- // Add to cache immediately (while still holding lock)
424
- // This prevents other workers from claiming this message
425
- this.processedCache.set(msg.id, Date.now());
426
-
427
- // Release lock now that cache is updated
428
- await this.releaseLock(msg.id);
573
+ // Add to cache immediately (while still holding lock)
574
+ // This prevents other workers from claiming this message
575
+ await this._markMessageProcessed(msg.id);
576
+ } finally {
577
+ await this.releaseLock(lock);
578
+ }
429
579
 
430
580
  // Fetch the message with ETag (query doesn't return _etag)
431
581
  const [okGet, errGet, msgWithETag] = await tryFn(() =>
@@ -434,7 +584,7 @@ export class S3QueuePlugin extends Plugin {
434
584
 
435
585
  if (!okGet || !msgWithETag) {
436
586
  // Message was deleted or not found - remove from cache
437
- this.processedCache.delete(msg.id);
587
+ await this._clearProcessedMarker(msg.id);
438
588
  if (this.config.verbose) {
439
589
  console.log(`[attemptClaim] Message ${msg.id} not found or error: ${errGet?.message}`);
440
590
  }
@@ -575,6 +725,7 @@ export class S3QueuePlugin extends Plugin {
575
725
  status: 'failed',
576
726
  error
577
727
  });
728
+ await this._clearProcessedMarker(queueId);
578
729
  }
579
730
 
580
731
  async retryMessage(queueId, attempts, error) {
@@ -588,7 +739,7 @@ export class S3QueuePlugin extends Plugin {
588
739
  });
589
740
 
590
741
  // Remove from cache so it can be retried
591
- this.processedCache.delete(queueId);
742
+ await this._clearProcessedMarker(queueId);
592
743
  }
593
744
 
594
745
  async moveToDeadLetter(queueId, record, error) {
@@ -614,22 +765,13 @@ export class S3QueuePlugin extends Plugin {
614
765
  });
615
766
 
616
767
  // Note: message already in cache from attemptClaim()
768
+ await this._clearProcessedMarker(queueId);
617
769
  }
618
770
 
619
771
  async getStats() {
620
- const [ok, err, allMessages] = await tryFn(() =>
621
- this.queueResource.list()
622
- );
623
-
624
- if (!ok) {
625
- if (this.config.verbose) {
626
- console.warn('[S3QueuePlugin] Failed to get stats:', err.message);
627
- }
628
- return null;
629
- }
630
-
772
+ const statusKeys = ['pending', 'processing', 'completed', 'failed', 'dead'];
631
773
  const stats = {
632
- total: allMessages.length,
774
+ total: 0,
633
775
  pending: 0,
634
776
  processing: 0,
635
777
  completed: 0,
@@ -637,9 +779,29 @@ export class S3QueuePlugin extends Plugin {
637
779
  dead: 0
638
780
  };
639
781
 
640
- for (const msg of allMessages) {
641
- if (stats[msg.status] !== undefined) {
642
- stats[msg.status]++;
782
+ const counts = await Promise.all(
783
+ statusKeys.map(status => tryFn(() => this.queueResource.count({ status })))
784
+ );
785
+
786
+ let derivedTotal = 0;
787
+
788
+ counts.forEach(([ok, err, count], index) => {
789
+ const status = statusKeys[index];
790
+ if (ok) {
791
+ stats[status] = count || 0;
792
+ derivedTotal += count || 0;
793
+ } else if (this.config.verbose) {
794
+ console.warn(`[S3QueuePlugin] Failed to count status '${status}':`, err?.message);
795
+ }
796
+ });
797
+
798
+ const [totalOk, totalErr, totalCount] = await tryFn(() => this.queueResource.count());
799
+ if (totalOk) {
800
+ stats.total = totalCount || 0;
801
+ } else {
802
+ stats.total = derivedTotal;
803
+ if (this.config.verbose) {
804
+ console.warn('[S3QueuePlugin] Failed to count total messages:', totalErr?.message);
643
805
  }
644
806
  }
645
807
 
@@ -647,9 +809,12 @@ export class S3QueuePlugin extends Plugin {
647
809
  }
648
810
 
649
811
  async createDeadLetterResource() {
812
+ if (!this.config.deadLetterResource) return;
813
+
814
+ const resourceName = this.config.deadLetterResource;
650
815
  const [ok, err] = await tryFn(() =>
651
816
  this.database.createResource({
652
- name: this.config.deadLetterResource,
817
+ name: resourceName,
653
818
  attributes: {
654
819
  id: 'string|required',
655
820
  originalId: 'string|required',
@@ -664,12 +829,246 @@ export class S3QueuePlugin extends Plugin {
664
829
  })
665
830
  );
666
831
 
667
- if (ok || this.database.resources[this.config.deadLetterResource]) {
668
- this.deadLetterResourceObj = this.database.resources[this.config.deadLetterResource];
832
+ if (ok) {
833
+ this.deadLetterResourceObj = this.database.resources[resourceName];
834
+ } else {
835
+ this.deadLetterResourceObj = this.database.resources[resourceName];
836
+ if (!this.deadLetterResourceObj) {
837
+ throw err;
838
+ }
839
+ }
840
+
841
+ this.deadLetterResourceName = this.deadLetterResourceObj.name;
842
+
843
+ if (this.deadLetterResourceAlias) {
844
+ const existing = this.database.resources[this.deadLetterResourceAlias];
845
+ if (!existing || existing === this.deadLetterResourceObj) {
846
+ this.database.resources[this.deadLetterResourceAlias] = this.deadLetterResourceObj;
847
+ }
848
+ }
849
+
850
+ if (this.config.verbose) {
851
+ console.log(`[S3QueuePlugin] Dead letter queue ready: ${this.deadLetterResourceName}`);
852
+ }
853
+ }
854
+
855
+ async extendVisibility(queueId, extraMilliseconds) {
856
+ if (!queueId || !extraMilliseconds || extraMilliseconds <= 0) {
857
+ return false;
858
+ }
669
859
 
860
+ const [okGet, errGet, entry] = await tryFn(() => this.queueResource.get(queueId));
861
+ if (!okGet || !entry) {
670
862
  if (this.config.verbose) {
671
- console.log(`[S3QueuePlugin] Dead letter queue created: ${this.config.deadLetterResource}`);
863
+ console.warn('[S3QueuePlugin] extendVisibility failed to load entry:', errGet?.message);
672
864
  }
865
+ return false;
866
+ }
867
+
868
+ const baseTime = Math.max(entry.visibleAt || 0, Date.now());
869
+ const newVisibleAt = baseTime + extraMilliseconds;
870
+
871
+ const [okUpdate, errUpdate, result] = await tryFn(() =>
872
+ this.queueResource.updateConditional(queueId, {
873
+ visibleAt: newVisibleAt,
874
+ claimedAt: entry.claimedAt || Date.now()
875
+ }, {
876
+ ifMatch: entry._etag
877
+ })
878
+ );
879
+
880
+ if (!okUpdate || !result?.success) {
881
+ if (this.config.verbose) {
882
+ console.warn('[S3QueuePlugin] extendVisibility conditional update failed:', errUpdate?.message || result?.error);
883
+ }
884
+ return false;
885
+ }
886
+
887
+ return true;
888
+ }
889
+
890
+ async recoverStalledMessages(now) {
891
+ if (this.config.recoveryInterval <= 0) return;
892
+ if (this._recoveryInFlight) return;
893
+ if (this._lastRecovery && now - this._lastRecovery < this.config.recoveryInterval) {
894
+ return;
895
+ }
896
+
897
+ this._recoveryInFlight = true;
898
+ this._lastRecovery = now;
899
+
900
+ try {
901
+ const [ok, err, candidates] = await tryFn(() =>
902
+ this.queueResource.query({
903
+ status: 'processing',
904
+ visibleAt: { '<=': now }
905
+ }, {
906
+ limit: this.config.recoveryBatchSize
907
+ })
908
+ );
909
+
910
+ if (!ok) {
911
+ if (this.config.verbose) {
912
+ console.warn('[S3QueuePlugin] Failed to query stalled messages:', err?.message);
913
+ }
914
+ return;
915
+ }
916
+
917
+ if (!candidates || candidates.length === 0) {
918
+ return;
919
+ }
920
+
921
+ for (const candidate of candidates) {
922
+ await this._recoverSingleMessage(candidate, now);
923
+ }
924
+ } finally {
925
+ this._recoveryInFlight = false;
926
+ }
927
+ }
928
+
929
+ async _recoverSingleMessage(candidate, now) {
930
+ const [okGet, errGet, queueEntry] = await tryFn(() => this.queueResource.get(candidate.id));
931
+ if (!okGet || !queueEntry) {
932
+ if (this.config.verbose) {
933
+ console.warn('[S3QueuePlugin] Failed to load stalled message:', errGet?.message);
934
+ }
935
+ return;
936
+ }
937
+
938
+ if (queueEntry.status !== 'processing' || queueEntry.visibleAt > now) {
939
+ return;
940
+ }
941
+
942
+ // If max attempts reached, move to dead letter
943
+ if (queueEntry.maxAttempts !== undefined && queueEntry.attempts >= queueEntry.maxAttempts) {
944
+ let record = null;
945
+ const [okRecord, , original] = await tryFn(() => this.targetResource.get(queueEntry.originalId));
946
+ if (okRecord && original) {
947
+ record = original;
948
+ } else {
949
+ record = { id: queueEntry.originalId, _missing: true };
950
+ }
951
+
952
+ await this.moveToDeadLetter(queueEntry.id, record, 'visibility-timeout exceeded max attempts');
953
+ this.emit('plg:s3-queue:message-dead', {
954
+ queueId: queueEntry.id,
955
+ originalId: queueEntry.originalId,
956
+ error: 'visibility-timeout exceeded max attempts'
957
+ });
958
+ return;
959
+ }
960
+
961
+ const [okUpdate, errUpdate, result] = await tryFn(() =>
962
+ this.queueResource.updateConditional(queueEntry.id, {
963
+ status: 'pending',
964
+ visibleAt: now,
965
+ claimedBy: null,
966
+ claimedAt: null,
967
+ error: 'Recovered after visibility timeout'
968
+ }, {
969
+ ifMatch: queueEntry._etag
970
+ })
971
+ );
972
+
973
+ if (!okUpdate || !result?.success) {
974
+ if (this.config.verbose) {
975
+ console.warn('[S3QueuePlugin] Failed to recover message:', errUpdate?.message || result?.error);
976
+ }
977
+ return;
978
+ }
979
+
980
+ await this._clearProcessedMarker(queueEntry.id);
981
+ this.emit('plg:s3-queue:message-recovered', {
982
+ queueId: queueEntry.id,
983
+ originalId: queueEntry.originalId
984
+ });
985
+ }
986
+
987
+ _computeIdleDelay(idleStreak) {
988
+ const base = this.config.pollInterval;
989
+ const maxInterval = Math.max(base, this.config.maxPollInterval || base);
990
+ if (maxInterval <= base) {
991
+ return base;
992
+ }
993
+ const factor = Math.pow(2, Math.max(0, idleStreak - 1));
994
+ const delay = base * factor;
995
+ return Math.min(delay, maxInterval);
996
+ }
997
+
998
+ async _sleep(ms) {
999
+ if (!ms || ms <= 0) return;
1000
+ await new Promise(resolve => setTimeout(resolve, ms));
1001
+ }
1002
+
1003
+ clearProcessedCache() {
1004
+ this.processedCache.clear();
1005
+ }
1006
+
1007
+ async _markMessageProcessed(messageId) {
1008
+ const ttl = Math.max(1000, this.config.processedCacheTTL);
1009
+ const expiresAt = Date.now() + ttl;
1010
+ this.processedCache.set(messageId, expiresAt);
1011
+
1012
+ const storage = this.getStorage();
1013
+ const key = storage.getPluginKey(null, 'cache', 'processed', messageId);
1014
+ const ttlSeconds = Math.max(1, Math.ceil(ttl / 1000));
1015
+
1016
+ const payload = {
1017
+ workerId: this.workerId,
1018
+ markedAt: Date.now()
1019
+ };
1020
+
1021
+ const [ok, err] = await tryFn(() =>
1022
+ storage.set(key, payload, {
1023
+ ttl: ttlSeconds,
1024
+ behavior: 'body-only'
1025
+ })
1026
+ );
1027
+
1028
+ if (!ok && this.config.verbose) {
1029
+ console.warn('[S3QueuePlugin] Failed to persist processed marker:', err?.message);
1030
+ }
1031
+ }
1032
+
1033
+ async _isRecentlyProcessed(messageId) {
1034
+ const now = Date.now();
1035
+ const localExpiresAt = this.processedCache.get(messageId);
1036
+ if (localExpiresAt && localExpiresAt > now) {
1037
+ return true;
1038
+ }
1039
+ if (localExpiresAt && localExpiresAt <= now) {
1040
+ this.processedCache.delete(messageId);
1041
+ }
1042
+
1043
+ const storage = this.getStorage();
1044
+ const key = storage.getPluginKey(null, 'cache', 'processed', messageId);
1045
+ const [ok, err, data] = await tryFn(() => storage.get(key));
1046
+
1047
+ if (!ok) {
1048
+ if (err && err.code !== 'NoSuchKey' && err.code !== 'NotFound' && this.config.verbose) {
1049
+ console.warn('[S3QueuePlugin] Failed to read processed marker:', err.message || err);
1050
+ }
1051
+ return false;
1052
+ }
1053
+
1054
+ if (!data) {
1055
+ return false;
1056
+ }
1057
+
1058
+ const ttl = Math.max(1000, this.config.processedCacheTTL);
1059
+ this.processedCache.set(messageId, now + ttl);
1060
+ return true;
1061
+ }
1062
+
1063
+ async _clearProcessedMarker(messageId) {
1064
+ this.processedCache.delete(messageId);
1065
+
1066
+ const storage = this.getStorage();
1067
+ const key = storage.getPluginKey(null, 'cache', 'processed', messageId);
1068
+
1069
+ const [ok, err] = await tryFn(() => storage.delete(key));
1070
+ if (!ok && err && err.code !== 'NoSuchKey' && err.code !== 'NotFound' && this.config.verbose) {
1071
+ console.warn('[S3QueuePlugin] Failed to delete processed marker:', err.message || err);
673
1072
  }
674
1073
  }
675
1074
  }