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 { PluginError } from "../errors.js";
4
6
 
5
7
  // Time constants (in seconds)
6
8
  const ONE_MINUTE_SEC = 60;
@@ -171,7 +173,13 @@ export class TTLPlugin extends Plugin {
171
173
  this.isRunning = false;
172
174
 
173
175
  // Expiration index (plugin storage)
176
+ const resourceNamesOption = config.resourceNames || {};
174
177
  this.expirationIndex = null;
178
+ this._indexResourceDescriptor = {
179
+ defaultName: 'plg_ttl_expiration_index',
180
+ override: resourceNamesOption.index || config.indexResourceName
181
+ };
182
+ this.indexResourceName = this._resolveIndexResourceName();
175
183
  }
176
184
 
177
185
  /**
@@ -206,23 +214,44 @@ export class TTLPlugin extends Plugin {
206
214
  });
207
215
  }
208
216
 
217
+ _resolveIndexResourceName() {
218
+ return resolveResourceName('ttl', this._indexResourceDescriptor, {
219
+ namespace: this.namespace
220
+ });
221
+ }
222
+
223
+ onNamespaceChanged() {
224
+ if (!this._indexResourceDescriptor) return;
225
+ this.indexResourceName = this._resolveIndexResourceName();
226
+ }
227
+
209
228
  /**
210
229
  * Validate resource configuration
211
230
  */
212
231
  _validateResourceConfig(resourceName, config) {
213
232
  // Must have either ttl or field
214
233
  if (!config.ttl && !config.field) {
215
- throw new Error(
216
- `[TTLPlugin] Resource "${resourceName}" must have either "ttl" (seconds) or "field" (timestamp field name)`
217
- );
234
+ throw new PluginError('[TTLPlugin] Missing TTL configuration', {
235
+ pluginName: 'TTLPlugin',
236
+ operation: 'validateResourceConfig',
237
+ resourceName,
238
+ statusCode: 400,
239
+ retriable: false,
240
+ suggestion: 'Provide either ttl (in seconds) or field (absolute expiration timestamp) for each resource.'
241
+ });
218
242
  }
219
243
 
220
244
  const validStrategies = ['soft-delete', 'hard-delete', 'archive', 'callback'];
221
245
  if (!config.onExpire || !validStrategies.includes(config.onExpire)) {
222
- throw new Error(
223
- `[TTLPlugin] Resource "${resourceName}" must have an "onExpire" value. ` +
224
- `Valid options: ${validStrategies.join(', ')}`
225
- );
246
+ throw new PluginError('[TTLPlugin] Invalid onExpire strategy', {
247
+ pluginName: 'TTLPlugin',
248
+ operation: 'validateResourceConfig',
249
+ resourceName,
250
+ statusCode: 400,
251
+ retriable: false,
252
+ suggestion: `Set onExpire to one of: ${validStrategies.join(', ')}`,
253
+ onExpire: config.onExpire
254
+ });
226
255
  }
227
256
 
228
257
  if (config.onExpire === 'soft-delete' && !config.deleteField) {
@@ -230,15 +259,27 @@ export class TTLPlugin extends Plugin {
230
259
  }
231
260
 
232
261
  if (config.onExpire === 'archive' && !config.archiveResource) {
233
- throw new Error(
234
- `[TTLPlugin] Resource "${resourceName}" with onExpire="archive" must have an "archiveResource" specified`
235
- );
262
+ throw new PluginError('[TTLPlugin] Archive resource required', {
263
+ pluginName: 'TTLPlugin',
264
+ operation: 'validateResourceConfig',
265
+ resourceName,
266
+ statusCode: 400,
267
+ retriable: false,
268
+ suggestion: 'Provide archiveResource pointing to the resource that stores archived records.',
269
+ onExpire: config.onExpire
270
+ });
236
271
  }
237
272
 
238
273
  if (config.onExpire === 'callback' && typeof config.callback !== 'function') {
239
- throw new Error(
240
- `[TTLPlugin] Resource "${resourceName}" with onExpire="callback" must have a "callback" function`
241
- );
274
+ throw new PluginError('[TTLPlugin] Callback handler required', {
275
+ pluginName: 'TTLPlugin',
276
+ operation: 'validateResourceConfig',
277
+ resourceName,
278
+ statusCode: 400,
279
+ retriable: false,
280
+ suggestion: 'Provide a callback function: { onExpire: "callback", callback: async (ctx) => {...} }',
281
+ onExpire: config.onExpire
282
+ });
242
283
  }
243
284
 
244
285
  // Set default field if not specified
@@ -249,7 +290,7 @@ export class TTLPlugin extends Plugin {
249
290
  // Validate timestamp field availability
250
291
  if (config.field === '_createdAt' && this.database) {
251
292
  const resource = this.database.resources[resourceName];
252
- if (resource && resource.config && resource.config.timestamps === false) {
293
+ if (resource && resource.$schema.timestamps === false) {
253
294
  console.warn(
254
295
  `[TTLPlugin] WARNING: Resource "${resourceName}" uses TTL with field "_createdAt" ` +
255
296
  `but timestamps are disabled. TTL will be calculated from indexing time, not creation time.`
@@ -266,7 +307,7 @@ export class TTLPlugin extends Plugin {
266
307
  */
267
308
  async _createExpirationIndex() {
268
309
  this.expirationIndex = await this.database.createResource({
269
- name: 'plg_ttl_expiration_index',
310
+ name: this.indexResourceName,
270
311
  attributes: {
271
312
  resourceName: 'string|required',
272
313
  recordId: 'string|required',
@@ -626,7 +667,14 @@ export class TTLPlugin extends Plugin {
626
667
  async _archive(resource, record, config) {
627
668
  // Check if archive resource exists
628
669
  if (!this.database.resources[config.archiveResource]) {
629
- throw new Error(`Archive resource "${config.archiveResource}" not found`);
670
+ throw new PluginError(`Archive resource "${config.archiveResource}" not found`, {
671
+ pluginName: 'TTLPlugin',
672
+ operation: '_archive',
673
+ resourceName: config.archiveResource,
674
+ statusCode: 404,
675
+ retriable: false,
676
+ suggestion: 'Create the archive resource before using onExpire: "archive" or update archiveResource config.'
677
+ });
630
678
  }
631
679
 
632
680
  const archiveResource = this.database.resources[config.archiveResource];
@@ -666,7 +714,14 @@ export class TTLPlugin extends Plugin {
666
714
  async cleanupResource(resourceName) {
667
715
  const config = this.resources[resourceName];
668
716
  if (!config) {
669
- throw new Error(`Resource "${resourceName}" not configured in TTLPlugin`);
717
+ throw new PluginError(`Resource "${resourceName}" not configured in TTLPlugin`, {
718
+ pluginName: 'TTLPlugin',
719
+ operation: 'cleanupResource',
720
+ resourceName,
721
+ statusCode: 404,
722
+ retriable: false,
723
+ suggestion: 'Add the resource under TTLPlugin configuration before invoking cleanupResource.'
724
+ });
670
725
  }
671
726
 
672
727
  const granularity = config.granularity;
@@ -1,3 +1,5 @@
1
+ import { ValidationError } from '../../errors.js';
2
+
1
3
  /**
2
4
  * Vector Distance Functions
3
5
  *
@@ -5,6 +7,18 @@
5
7
  * All distance functions return lower values for more similar vectors.
6
8
  */
7
9
 
10
+ function assertSameDimensions(a, b, operation) {
11
+ if (a.length !== b.length) {
12
+ throw new ValidationError(`Dimension mismatch: ${a.length} vs ${b.length}`, {
13
+ operation,
14
+ pluginName: 'VectorPlugin',
15
+ retriable: false,
16
+ suggestion: 'Ensure both vectors have identical lengths before calling distance utilities.',
17
+ metadata: { vectorALength: a.length, vectorBLength: b.length }
18
+ });
19
+ }
20
+ }
21
+
8
22
  /**
9
23
  * Calculate cosine distance between two vectors
10
24
  *
@@ -17,9 +31,7 @@
17
31
  * @throws {Error} If vectors have different dimensions
18
32
  */
19
33
  export function cosineDistance(a, b) {
20
- if (a.length !== b.length) {
21
- throw new Error(`Dimension mismatch: ${a.length} vs ${b.length}`);
22
- }
34
+ assertSameDimensions(a, b, 'cosineDistance');
23
35
 
24
36
  let dotProduct = 0;
25
37
  let normA = 0;
@@ -56,9 +68,7 @@ export function cosineDistance(a, b) {
56
68
  * @throws {Error} If vectors have different dimensions
57
69
  */
58
70
  export function euclideanDistance(a, b) {
59
- if (a.length !== b.length) {
60
- throw new Error(`Dimension mismatch: ${a.length} vs ${b.length}`);
61
- }
71
+ assertSameDimensions(a, b, 'euclideanDistance');
62
72
 
63
73
  let sum = 0;
64
74
  for (let i = 0; i < a.length; i++) {
@@ -81,9 +91,7 @@ export function euclideanDistance(a, b) {
81
91
  * @throws {Error} If vectors have different dimensions
82
92
  */
83
93
  export function manhattanDistance(a, b) {
84
- if (a.length !== b.length) {
85
- throw new Error(`Dimension mismatch: ${a.length} vs ${b.length}`);
86
- }
94
+ assertSameDimensions(a, b, 'manhattanDistance');
87
95
 
88
96
  let sum = 0;
89
97
  for (let i = 0; i < a.length; i++) {
@@ -104,9 +112,7 @@ export function manhattanDistance(a, b) {
104
112
  * @throws {Error} If vectors have different dimensions
105
113
  */
106
114
  export function dotProduct(a, b) {
107
- if (a.length !== b.length) {
108
- throw new Error(`Dimension mismatch: ${a.length} vs ${b.length}`);
109
- }
115
+ assertSameDimensions(a, b, 'dotProduct');
110
116
 
111
117
  let sum = 0;
112
118
  for (let i = 0; i < a.length; i++) {
@@ -5,6 +5,7 @@
5
5
  * and comprehensive optimal K analysis using multiple metrics.
6
6
  */
7
7
 
8
+ import { ValidationError } from '../../errors.js';
8
9
  import { euclideanDistance } from './distances.js';
9
10
 
10
11
  /**
@@ -31,15 +32,27 @@ export function kmeans(vectors, k, options = {}) {
31
32
  } = options;
32
33
 
33
34
  if (vectors.length === 0) {
34
- throw new Error('Cannot cluster empty vector array');
35
+ throw new ValidationError('Cannot cluster empty vector array', {
36
+ operation: 'kmeans',
37
+ retriable: false,
38
+ suggestion: 'Provide at least one vector before invoking k-means.'
39
+ });
35
40
  }
36
41
 
37
42
  if (k < 1) {
38
- throw new Error(`k must be at least 1, got ${k}`);
43
+ throw new ValidationError(`k must be at least 1, got ${k}`, {
44
+ operation: 'kmeans',
45
+ retriable: false,
46
+ suggestion: 'Use a positive integer for the number of clusters.'
47
+ });
39
48
  }
40
49
 
41
50
  if (k > vectors.length) {
42
- throw new Error(`k (${k}) cannot be greater than number of vectors (${vectors.length})`);
51
+ throw new ValidationError(`k (${k}) cannot be greater than number of vectors (${vectors.length})`, {
52
+ operation: 'kmeans',
53
+ retriable: false,
54
+ suggestion: 'Reduce k or provide more vectors before clustering.'
55
+ });
43
56
  }
44
57
 
45
58
  const dimensions = vectors[0].length;
@@ -47,7 +60,16 @@ export function kmeans(vectors, k, options = {}) {
47
60
  // Validate all vectors have same dimensions
48
61
  for (let i = 1; i < vectors.length; i++) {
49
62
  if (vectors[i].length !== dimensions) {
50
- throw new Error(`All vectors must have same dimensions. Expected ${dimensions}, got ${vectors[i].length} at index ${i}`);
63
+ throw new ValidationError('All vectors must have same dimensions.', {
64
+ operation: 'kmeans',
65
+ retriable: false,
66
+ suggestion: 'Pad or trim vectors so every row has identical dimensionality.',
67
+ metadata: {
68
+ expectedDimensions: dimensions,
69
+ actualDimensions: vectors[i].length,
70
+ index: i
71
+ }
72
+ });
51
73
  }
52
74
  }
53
75
 
@@ -191,6 +191,15 @@ export class Resource extends AsyncEventEmitter {
191
191
  createdBy,
192
192
  };
193
193
 
194
+ // Store raw schema definition (accessible as resource.$schema)
195
+ // This is the LITERAL object passed to createResource()
196
+ // Useful for plugins, documentation, and introspection
197
+ this.$schema = cloneDeep(config);
198
+
199
+ // Add metadata timestamps
200
+ this.$schema._createdAt = Date.now();
201
+ this.$schema._updatedAt = Date.now();
202
+
194
203
  // Initialize hooks system - expanded to cover ALL methods
195
204
  this.hooks = {
196
205
  // Insert hooks
@@ -1175,7 +1184,14 @@ export class Resource extends AsyncEventEmitter {
1175
1184
  // LOG: body e contentType antes do putObject
1176
1185
  // Only throw if behavior is 'body-only' and body is empty
1177
1186
  if (this.behavior === 'body-only' && (!body || body === "")) {
1178
- throw new Error(`[Resource.insert] Attempt to save object without body! Data: id=${finalId}, resource=${this.name}`);
1187
+ throw new ResourceError('Body required for body-only behavior', {
1188
+ resourceName: this.name,
1189
+ operation: 'insert',
1190
+ id: finalId,
1191
+ statusCode: 400,
1192
+ retriable: false,
1193
+ suggestion: 'Include a request body when using behavior "body-only" or switch to "body-overflow".'
1194
+ });
1179
1195
  }
1180
1196
  // For other behaviors, allow empty body (all data in metadata)
1181
1197
 
@@ -1272,8 +1288,22 @@ export class Resource extends AsyncEventEmitter {
1272
1288
  * const user = await resource.get('user-123');
1273
1289
  */
1274
1290
  async get(id) {
1275
- if (isObject(id)) throw new Error(`id cannot be an object`);
1276
- if (isEmpty(id)) throw new Error('id cannot be empty');
1291
+ if (isObject(id)) {
1292
+ throw new ValidationError('Resource id must be a string', {
1293
+ field: 'id',
1294
+ statusCode: 400,
1295
+ retriable: false,
1296
+ suggestion: 'Pass the resource id as a string value (e.g. "user-123").'
1297
+ });
1298
+ }
1299
+ if (isEmpty(id)) {
1300
+ throw new ValidationError('Resource id cannot be empty', {
1301
+ field: 'id',
1302
+ statusCode: 400,
1303
+ retriable: false,
1304
+ suggestion: 'Provide a non-empty id when calling resource methods.'
1305
+ });
1306
+ }
1277
1307
 
1278
1308
  // Execute beforeGet hooks
1279
1309
  await this.executeHooks('beforeGet', { id });
@@ -1454,12 +1484,23 @@ export class Resource extends AsyncEventEmitter {
1454
1484
  */
1455
1485
  async update(id, attributes) {
1456
1486
  if (isEmpty(id)) {
1457
- throw new Error('id cannot be empty');
1487
+ throw new ValidationError('Resource id cannot be empty', {
1488
+ field: 'id',
1489
+ statusCode: 400,
1490
+ retriable: false,
1491
+ suggestion: 'Provide the target id when calling update().'
1492
+ });
1458
1493
  }
1459
1494
  // Garante que o recurso existe antes de atualizar
1460
1495
  const exists = await this.exists(id);
1461
1496
  if (!exists) {
1462
- throw new Error(`Resource with id '${id}' does not exist`);
1497
+ throw new ResourceError(`Resource with id '${id}' does not exist`, {
1498
+ resourceName: this.name,
1499
+ id,
1500
+ statusCode: 404,
1501
+ retriable: false,
1502
+ suggestion: 'Ensure the record exists or create it before attempting an update.'
1503
+ });
1463
1504
  }
1464
1505
  const originalData = await this.get(id);
1465
1506
  const attributesClone = cloneDeep(attributes);
@@ -1685,11 +1726,21 @@ export class Resource extends AsyncEventEmitter {
1685
1726
  */
1686
1727
  async patch(id, fields, options = {}) {
1687
1728
  if (isEmpty(id)) {
1688
- throw new Error('id cannot be empty');
1729
+ throw new ValidationError('Resource id cannot be empty', {
1730
+ field: 'id',
1731
+ statusCode: 400,
1732
+ retriable: false,
1733
+ suggestion: 'Provide the target id when calling patch().'
1734
+ });
1689
1735
  }
1690
1736
 
1691
1737
  if (!fields || typeof fields !== 'object') {
1692
- throw new Error('fields must be a non-empty object');
1738
+ throw new ValidationError('fields must be a non-empty object', {
1739
+ field: 'fields',
1740
+ statusCode: 400,
1741
+ retriable: false,
1742
+ suggestion: 'Pass a plain object with the fields to update (e.g. { status: "active" }).'
1743
+ });
1693
1744
  }
1694
1745
 
1695
1746
  // Execute beforePatch hooks
@@ -1843,11 +1894,21 @@ export class Resource extends AsyncEventEmitter {
1843
1894
  */
1844
1895
  async replace(id, fullData, options = {}) {
1845
1896
  if (isEmpty(id)) {
1846
- throw new Error('id cannot be empty');
1897
+ throw new ValidationError('Resource id cannot be empty', {
1898
+ field: 'id',
1899
+ statusCode: 400,
1900
+ retriable: false,
1901
+ suggestion: 'Provide the target id when calling replace().'
1902
+ });
1847
1903
  }
1848
1904
 
1849
1905
  if (!fullData || typeof fullData !== 'object') {
1850
- throw new Error('fullData must be a non-empty object');
1906
+ throw new ValidationError('fullData must be a non-empty object', {
1907
+ field: 'fullData',
1908
+ statusCode: 400,
1909
+ retriable: false,
1910
+ suggestion: 'Pass a plain object containing the full resource payload to replace().'
1911
+ });
1851
1912
  }
1852
1913
 
1853
1914
  // Execute beforeReplace hooks
@@ -1921,7 +1982,14 @@ export class Resource extends AsyncEventEmitter {
1921
1982
 
1922
1983
  // Only throw if behavior is 'body-only' and body is empty
1923
1984
  if (this.behavior === 'body-only' && (!body || body === "")) {
1924
- throw new Error(`[Resource.replace] Attempt to save object without body! Data: id=${id}, resource=${this.name}`);
1985
+ throw new ResourceError('Body required for body-only behavior', {
1986
+ resourceName: this.name,
1987
+ operation: 'replace',
1988
+ id,
1989
+ statusCode: 400,
1990
+ retriable: false,
1991
+ suggestion: 'Include a request body when using behavior "body-only" or switch to "body-overflow".'
1992
+ });
1925
1993
  }
1926
1994
 
1927
1995
  // Store to S3 (overwrites if exists, creates if not - true replace/upsert)
@@ -2000,12 +2068,22 @@ export class Resource extends AsyncEventEmitter {
2000
2068
  */
2001
2069
  async updateConditional(id, attributes, options = {}) {
2002
2070
  if (isEmpty(id)) {
2003
- throw new Error('id cannot be empty');
2071
+ throw new ValidationError('Resource id cannot be empty', {
2072
+ field: 'id',
2073
+ statusCode: 400,
2074
+ retriable: false,
2075
+ suggestion: 'Provide the target id when calling updateConditional().'
2076
+ });
2004
2077
  }
2005
2078
 
2006
2079
  const { ifMatch } = options;
2007
2080
  if (!ifMatch) {
2008
- throw new Error('updateConditional requires ifMatch option with ETag value');
2081
+ throw new ValidationError('updateConditional requires ifMatch option with ETag value', {
2082
+ field: 'ifMatch',
2083
+ statusCode: 428,
2084
+ retriable: false,
2085
+ suggestion: 'Pass the current object ETag in options.ifMatch to enable conditional updates.'
2086
+ });
2009
2087
  }
2010
2088
 
2011
2089
  // Check if resource exists
@@ -2214,7 +2292,12 @@ export class Resource extends AsyncEventEmitter {
2214
2292
  */
2215
2293
  async delete(id) {
2216
2294
  if (isEmpty(id)) {
2217
- throw new Error('id cannot be empty');
2295
+ throw new ValidationError('Resource id cannot be empty', {
2296
+ field: 'id',
2297
+ statusCode: 400,
2298
+ retriable: false,
2299
+ suggestion: 'Provide the target id when calling delete().'
2300
+ });
2218
2301
  }
2219
2302
 
2220
2303
  let objectData;
@@ -3006,7 +3089,8 @@ export class Resource extends AsyncEventEmitter {
3006
3089
  const key = this.getResourceKey(id);
3007
3090
  const [ok, err, response] = await tryFn(() => this.client.getObject(key));
3008
3091
  if (!ok) {
3009
- if (err.name === "NoSuchKey") {
3092
+ // Check multiple ways the error might indicate "not found"
3093
+ if (err.name === "NoSuchKey" || err.code === "NoSuchKey" || err.Code === "NoSuchKey" || err.statusCode === 404) {
3010
3094
  return {
3011
3095
  buffer: null,
3012
3096
  contentType: null
@@ -3801,7 +3885,15 @@ export class Resource extends AsyncEventEmitter {
3801
3885
  let idx = -1;
3802
3886
  const stack = this._middlewares.get(method);
3803
3887
  const dispatch = async (i) => {
3804
- if (i <= idx) throw new Error('next() called multiple times');
3888
+ if (i <= idx) {
3889
+ throw new ResourceError('Resource middleware next() called multiple times', {
3890
+ resourceName: this.name,
3891
+ operation: method,
3892
+ statusCode: 500,
3893
+ retriable: false,
3894
+ suggestion: 'Ensure each middleware awaits next() at most once.'
3895
+ });
3896
+ }
3805
3897
  idx = i;
3806
3898
  if (i < stack.length) {
3807
3899
  return await stack[i](ctx, () => dispatch(i + 1));
@@ -3866,10 +3958,14 @@ export class Resource extends AsyncEventEmitter {
3866
3958
 
3867
3959
  const throwIfNoStateMachine = () => {
3868
3960
  if (!resource._stateMachine) {
3869
- throw new Error(
3870
- `No state machine configured for resource '${resource.name}'. ` +
3871
- `Ensure StateMachinePlugin is installed and configured for this resource.`
3872
- );
3961
+ throw new ResourceError(`State machine not configured for resource '${resource.name}'`, {
3962
+ resourceName: resource.name,
3963
+ operation: 'stateMachine',
3964
+ statusCode: 400,
3965
+ retriable: false,
3966
+ suggestion: 'Install and configure the StateMachinePlugin before calling resource.state.* methods.',
3967
+ docs: 'https://github.com/forattini-dev/s3db.js/blob/main/docs/plugins/state-machine.md'
3968
+ });
3873
3969
  }
3874
3970
  };
3875
3971
 
@@ -19,6 +19,8 @@
19
19
  * const users = await UserFactory.createMany(10);
20
20
  */
21
21
 
22
+ import { ValidationError } from '../errors.js';
23
+
22
24
  export class Factory {
23
25
  /**
24
26
  * Global sequence counter
@@ -161,7 +163,13 @@ export class Factory {
161
163
  for (const traitName of traits) {
162
164
  const trait = this.traits.get(traitName);
163
165
  if (!trait) {
164
- throw new Error(`Trait '${traitName}' not found in factory '${this.resourceName}'`);
166
+ throw new ValidationError(`Trait '${traitName}' not found in factory '${this.resourceName}'`, {
167
+ field: 'trait',
168
+ value: traitName,
169
+ resourceName: this.resourceName,
170
+ retriable: false,
171
+ suggestion: `Define the trait with Factory.define('${this.resourceName}').trait('${traitName}', ...) before using it.`
172
+ });
165
173
  }
166
174
 
167
175
  const traitAttrs = typeof trait === 'function'
@@ -194,7 +202,11 @@ export class Factory {
194
202
  const { database = Factory._database } = options;
195
203
 
196
204
  if (!database) {
197
- throw new Error('Database not set. Use Factory.setDatabase(db) or pass database option');
205
+ throw new ValidationError('Database not set for factory', {
206
+ field: 'database',
207
+ retriable: false,
208
+ suggestion: 'Call Factory.setDatabase(db) globally or pass { database } when invoking create().'
209
+ });
198
210
  }
199
211
 
200
212
  // Build attributes
@@ -208,7 +220,12 @@ export class Factory {
208
220
  // Get resource
209
221
  const resource = database.resources[this.resourceName];
210
222
  if (!resource) {
211
- throw new Error(`Resource '${this.resourceName}' not found in database`);
223
+ throw new ValidationError(`Resource '${this.resourceName}' not found in database`, {
224
+ field: 'resourceName',
225
+ value: this.resourceName,
226
+ retriable: false,
227
+ suggestion: `Ensure the resource is created in the database before using Factory '${this.resourceName}'.`
228
+ });
212
229
  }
213
230
 
214
231
  // Create in database
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { Factory } from './factory.class.js';
19
+ import { ValidationError } from '../errors.js';
19
20
 
20
21
  export class Seeder {
21
22
  /**
@@ -59,7 +60,12 @@ export class Seeder {
59
60
 
60
61
  const factory = Factory.get(resourceName);
61
62
  if (!factory) {
62
- throw new Error(`Factory for '${resourceName}' not found. Define it with Factory.define()`);
63
+ throw new ValidationError(`Factory for '${resourceName}' not found`, {
64
+ field: 'resourceName',
65
+ value: resourceName,
66
+ retriable: false,
67
+ suggestion: `Register a factory with Factory.define('${resourceName}', ...) before seeding.`
68
+ });
63
69
  }
64
70
 
65
71
  created[resourceName] = await factory.createMany(count, {}, { database: this.database });