s3db.js 11.2.2 → 11.2.4

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 (46) hide show
  1. package/dist/s3db.cjs.js +1650 -136
  2. package/dist/s3db.cjs.js.map +1 -1
  3. package/dist/s3db.es.js +1644 -137
  4. package/dist/s3db.es.js.map +1 -1
  5. package/package.json +1 -1
  6. package/src/behaviors/enforce-limits.js +28 -4
  7. package/src/behaviors/index.js +6 -1
  8. package/src/client.class.js +11 -1
  9. package/src/concerns/partition-queue.js +7 -1
  10. package/src/concerns/plugin-storage.js +75 -13
  11. package/src/database.class.js +22 -4
  12. package/src/errors.js +414 -24
  13. package/src/partition-drivers/base-partition-driver.js +12 -2
  14. package/src/partition-drivers/index.js +7 -1
  15. package/src/partition-drivers/memory-partition-driver.js +20 -5
  16. package/src/partition-drivers/sqs-partition-driver.js +6 -1
  17. package/src/plugins/audit.errors.js +46 -0
  18. package/src/plugins/backup/base-backup-driver.class.js +36 -6
  19. package/src/plugins/backup/filesystem-backup-driver.class.js +55 -7
  20. package/src/plugins/backup/index.js +40 -9
  21. package/src/plugins/backup/multi-backup-driver.class.js +69 -9
  22. package/src/plugins/backup/s3-backup-driver.class.js +48 -6
  23. package/src/plugins/backup.errors.js +45 -0
  24. package/src/plugins/cache/cache.class.js +8 -1
  25. package/src/plugins/cache/memory-cache.class.js +216 -33
  26. package/src/plugins/cache.errors.js +47 -0
  27. package/src/plugins/cache.plugin.js +94 -3
  28. package/src/plugins/eventual-consistency/analytics.js +145 -0
  29. package/src/plugins/eventual-consistency/index.js +203 -1
  30. package/src/plugins/fulltext.errors.js +46 -0
  31. package/src/plugins/fulltext.plugin.js +15 -3
  32. package/src/plugins/metrics.errors.js +46 -0
  33. package/src/plugins/queue-consumer.plugin.js +31 -4
  34. package/src/plugins/queue.errors.js +46 -0
  35. package/src/plugins/replicator.errors.js +46 -0
  36. package/src/plugins/replicator.plugin.js +40 -5
  37. package/src/plugins/replicators/base-replicator.class.js +19 -3
  38. package/src/plugins/replicators/index.js +9 -3
  39. package/src/plugins/replicators/s3db-replicator.class.js +38 -8
  40. package/src/plugins/scheduler.errors.js +46 -0
  41. package/src/plugins/scheduler.plugin.js +79 -19
  42. package/src/plugins/state-machine.errors.js +47 -0
  43. package/src/plugins/state-machine.plugin.js +86 -17
  44. package/src/resource.class.js +8 -1
  45. package/src/stream/index.js +6 -1
  46. package/src/stream/resource-reader.class.js +6 -1
package/dist/s3db.es.js CHANGED
@@ -11,6 +11,7 @@ import jsonStableStringify from 'json-stable-stringify';
11
11
  import { Transform, Writable } from 'stream';
12
12
  import { PromisePool } from '@supercharge/promise-pool';
13
13
  import { ReadableStream } from 'node:stream/web';
14
+ import os$1 from 'node:os';
14
15
  import { chunk, merge, isString, isEmpty, invert, uniq, cloneDeep, get, set, isObject, isFunction } from 'lodash-es';
15
16
  import { Agent } from 'http';
16
17
  import { Agent as Agent$1 } from 'https';
@@ -217,7 +218,7 @@ function calculateEffectiveLimit(config = {}) {
217
218
  }
218
219
 
219
220
  class BaseError extends Error {
220
- constructor({ verbose, bucket, key, message, code, statusCode, requestId, awsMessage, original, commandName, commandInput, metadata, suggestion, ...rest }) {
221
+ constructor({ verbose, bucket, key, message, code, statusCode, requestId, awsMessage, original, commandName, commandInput, metadata, description, ...rest }) {
221
222
  if (verbose) message = message + `
222
223
 
223
224
  Verbose:
@@ -242,7 +243,7 @@ ${JSON.stringify(rest, null, 2)}`;
242
243
  this.commandName = commandName;
243
244
  this.commandInput = commandInput;
244
245
  this.metadata = metadata;
245
- this.suggestion = suggestion;
246
+ this.description = description;
246
247
  this.data = { bucket, key, ...rest, verbose, message };
247
248
  }
248
249
  toJson() {
@@ -259,7 +260,7 @@ ${JSON.stringify(rest, null, 2)}`;
259
260
  commandName: this.commandName,
260
261
  commandInput: this.commandInput,
261
262
  metadata: this.metadata,
262
- suggestion: this.suggestion,
263
+ description: this.description,
263
264
  data: this.data,
264
265
  original: this.original,
265
266
  stack: this.stack
@@ -399,26 +400,26 @@ function mapAwsError(err, context = {}) {
399
400
  const metadata = err.$metadata ? { ...err.$metadata } : void 0;
400
401
  const commandName = context.commandName;
401
402
  const commandInput = context.commandInput;
402
- let suggestion;
403
+ let description;
403
404
  if (code === "NoSuchKey" || code === "NotFound") {
404
- suggestion = "Check if the key exists in the specified bucket and if your credentials have permission.";
405
- return new NoSuchKey({ ...context, original: err, metadata, commandName, commandInput, suggestion });
405
+ description = "The specified key does not exist in the bucket. Check if the key exists and if your credentials have permission to access it.";
406
+ return new NoSuchKey({ ...context, original: err, metadata, commandName, commandInput, description });
406
407
  }
407
408
  if (code === "NoSuchBucket") {
408
- suggestion = "Check if the bucket exists and if your credentials have permission.";
409
- return new NoSuchBucket({ ...context, original: err, metadata, commandName, commandInput, suggestion });
409
+ description = "The specified bucket does not exist. Check if the bucket name is correct and if your credentials have permission to access it.";
410
+ return new NoSuchBucket({ ...context, original: err, metadata, commandName, commandInput, description });
410
411
  }
411
412
  if (code === "AccessDenied" || err.statusCode === 403 || code === "Forbidden") {
412
- suggestion = "Check your credentials and bucket policy.";
413
- return new PermissionError("Access denied", { ...context, original: err, metadata, commandName, commandInput, suggestion });
413
+ description = "Access denied. Check your AWS credentials, IAM permissions, and bucket policy.";
414
+ return new PermissionError("Access denied", { ...context, original: err, metadata, commandName, commandInput, description });
414
415
  }
415
416
  if (code === "ValidationError" || err.statusCode === 400) {
416
- suggestion = "Check the request parameters and payload.";
417
- return new ValidationError("Validation error", { ...context, original: err, metadata, commandName, commandInput, suggestion });
417
+ description = "Validation error. Check the request parameters and payload format.";
418
+ return new ValidationError("Validation error", { ...context, original: err, metadata, commandName, commandInput, description });
418
419
  }
419
420
  if (code === "MissingMetadata") {
420
- suggestion = "Check if the object metadata is present and valid.";
421
- return new MissingMetadata({ ...context, original: err, metadata, commandName, commandInput, suggestion });
421
+ description = "Object metadata is missing or invalid. Check if the object was uploaded correctly.";
422
+ return new MissingMetadata({ ...context, original: err, metadata, commandName, commandInput, description });
422
423
  }
423
424
  const errorDetails = [
424
425
  `Unknown error: ${err.message || err.toString()}`,
@@ -426,33 +427,388 @@ function mapAwsError(err, context = {}) {
426
427
  err.statusCode && `Status: ${err.statusCode}`,
427
428
  err.stack && `Stack: ${err.stack.split("\n")[0]}`
428
429
  ].filter(Boolean).join(" | ");
429
- suggestion = `Check the error details and AWS documentation. Original error: ${err.message || err.toString()}`;
430
- return new UnknownError(errorDetails, { ...context, original: err, metadata, commandName, commandInput, suggestion });
430
+ description = `Check the error details and AWS documentation. Original error: ${err.message || err.toString()}`;
431
+ return new UnknownError(errorDetails, { ...context, original: err, metadata, commandName, commandInput, description });
431
432
  }
432
433
  class ConnectionStringError extends S3dbError {
433
434
  constructor(message, details = {}) {
434
- super(message, { ...details, suggestion: "Check the connection string format and credentials." });
435
+ const description = details.description || "Invalid connection string format. Check the connection string syntax and credentials.";
436
+ super(message, { ...details, description });
435
437
  }
436
438
  }
437
439
  class CryptoError extends S3dbError {
438
440
  constructor(message, details = {}) {
439
- super(message, { ...details, suggestion: "Check if the crypto library is available and input is valid." });
441
+ const description = details.description || "Cryptography operation failed. Check if the crypto library is available and input is valid.";
442
+ super(message, { ...details, description });
440
443
  }
441
444
  }
442
445
  class SchemaError extends S3dbError {
443
446
  constructor(message, details = {}) {
444
- super(message, { ...details, suggestion: "Check schema definition and input data." });
447
+ const description = details.description || "Schema validation failed. Check schema definition and input data format.";
448
+ super(message, { ...details, description });
445
449
  }
446
450
  }
447
451
  class ResourceError extends S3dbError {
448
452
  constructor(message, details = {}) {
449
- super(message, { ...details, suggestion: details.suggestion || "Check resource configuration, attributes, and operation context." });
453
+ const description = details.description || "Resource operation failed. Check resource configuration, attributes, and operation context.";
454
+ super(message, { ...details, description });
450
455
  Object.assign(this, details);
451
456
  }
452
457
  }
453
458
  class PartitionError extends S3dbError {
454
459
  constructor(message, details = {}) {
455
- super(message, { ...details, suggestion: details.suggestion || "Check partition definition, fields, and input values." });
460
+ let description = details.description;
461
+ if (!description && details.resourceName && details.partitionName && details.fieldName) {
462
+ const { resourceName, partitionName, fieldName, availableFields = [] } = details;
463
+ description = `
464
+ Partition Field Validation Error
465
+
466
+ Resource: ${resourceName}
467
+ Partition: ${partitionName}
468
+ Missing Field: ${fieldName}
469
+
470
+ Available fields in schema:
471
+ ${availableFields.map((f) => ` \u2022 ${f}`).join("\n") || " (no fields defined)"}
472
+
473
+ Possible causes:
474
+ 1. Field was removed from schema but partition still references it
475
+ 2. Typo in partition field name
476
+ 3. Nested field path is incorrect (use dot notation like 'utm.source')
477
+
478
+ Solution:
479
+ ${details.strictValidation === false ? " \u2022 Update partition definition to use existing fields" : ` \u2022 Add missing field to schema, OR
480
+ \u2022 Update partition definition to use existing fields, OR
481
+ \u2022 Use strictValidation: false to skip this check during testing`}
482
+
483
+ Docs: https://github.com/forattini-dev/s3db.js/blob/main/docs/README.md#partitions
484
+ `.trim();
485
+ }
486
+ super(message, {
487
+ ...details,
488
+ description
489
+ });
490
+ }
491
+ }
492
+ class AnalyticsNotEnabledError extends S3dbError {
493
+ constructor(details = {}) {
494
+ const {
495
+ pluginName = "EventualConsistency",
496
+ resourceName = "unknown",
497
+ field = "unknown",
498
+ configuredResources = [],
499
+ registeredResources = [],
500
+ pluginInitialized = false,
501
+ ...rest
502
+ } = details;
503
+ const message = `Analytics not enabled for ${resourceName}.${field}`;
504
+ const description = `
505
+ Analytics Not Enabled
506
+
507
+ Plugin: ${pluginName}
508
+ Resource: ${resourceName}
509
+ Field: ${field}
510
+
511
+ Diagnostics:
512
+ \u2022 Plugin initialized: ${pluginInitialized ? "\u2713 Yes" : "\u2717 No"}
513
+ \u2022 Analytics resources created: ${registeredResources.length}/${configuredResources.length}
514
+ ${configuredResources.map((r) => {
515
+ const exists = registeredResources.includes(r);
516
+ return ` ${exists ? "\u2713" : "\u2717"} ${r}${!exists ? " (missing)" : ""}`;
517
+ }).join("\n")}
518
+
519
+ Possible causes:
520
+ 1. Resource not created yet - Analytics resources are created when db.createResource() is called
521
+ 2. Resource created before plugin initialization - Plugin must be initialized before resources
522
+ 3. Field not configured in analytics.resources config
523
+
524
+ Correct initialization order:
525
+ 1. Create database: const db = new Database({ ... })
526
+ 2. Install plugins: await db.connect() (triggers plugin.install())
527
+ 3. Create resources: await db.createResource({ name: '${resourceName}', ... })
528
+ 4. Analytics resources are auto-created by plugin
529
+
530
+ Example fix:
531
+ const db = new Database({
532
+ bucket: 'my-bucket',
533
+ plugins: [new EventualConsistencyPlugin({
534
+ resources: {
535
+ '${resourceName}': {
536
+ fields: {
537
+ '${field}': { type: 'counter', analytics: true }
538
+ }
539
+ }
540
+ }
541
+ })]
542
+ });
543
+
544
+ await db.connect(); // Plugin initialized here
545
+ await db.createResource({ name: '${resourceName}', ... }); // Analytics resource created here
546
+
547
+ Docs: https://github.com/forattini-dev/s3db.js/blob/main/docs/plugins/eventual-consistency.md
548
+ `.trim();
549
+ super(message, {
550
+ ...rest,
551
+ pluginName,
552
+ resourceName,
553
+ field,
554
+ configuredResources,
555
+ registeredResources,
556
+ pluginInitialized,
557
+ description
558
+ });
559
+ }
560
+ }
561
+ class PluginError extends S3dbError {
562
+ constructor(message, details = {}) {
563
+ const {
564
+ pluginName = "Unknown",
565
+ operation = "unknown",
566
+ ...rest
567
+ } = details;
568
+ let description = details.description;
569
+ if (!description) {
570
+ description = `
571
+ Plugin Error
572
+
573
+ Plugin: ${pluginName}
574
+ Operation: ${operation}
575
+
576
+ Possible causes:
577
+ 1. Plugin not properly initialized
578
+ 2. Plugin configuration is invalid
579
+ 3. Plugin dependencies not met
580
+ 4. Plugin method called before installation
581
+
582
+ Solution:
583
+ Ensure plugin is added to database and connect() is called before usage.
584
+
585
+ Example:
586
+ const db = new Database({
587
+ bucket: 'my-bucket',
588
+ plugins: [new ${pluginName}({ /* config */ })]
589
+ });
590
+
591
+ await db.connect(); // Plugin installed here
592
+ // Now plugin methods are available
593
+
594
+ Docs: https://github.com/forattini-dev/s3db.js/blob/main/docs/plugins/README.md
595
+ `.trim();
596
+ }
597
+ super(message, {
598
+ ...rest,
599
+ pluginName,
600
+ operation,
601
+ description
602
+ });
603
+ }
604
+ }
605
+ class PluginStorageError extends S3dbError {
606
+ constructor(message, details = {}) {
607
+ const {
608
+ pluginSlug = "unknown",
609
+ key = "",
610
+ operation = "unknown",
611
+ ...rest
612
+ } = details;
613
+ let description = details.description;
614
+ if (!description) {
615
+ description = `
616
+ Plugin Storage Error
617
+
618
+ Plugin: ${pluginSlug}
619
+ Key: ${key}
620
+ Operation: ${operation}
621
+
622
+ Possible causes:
623
+ 1. Storage not initialized (plugin not installed)
624
+ 2. Invalid key format
625
+ 3. S3 operation failed
626
+ 4. Permissions issue
627
+
628
+ Solution:
629
+ Ensure plugin has access to storage and key is valid.
630
+
631
+ Docs: https://github.com/forattini-dev/s3db.js/blob/main/docs/plugins/README.md#plugin-storage
632
+ `.trim();
633
+ }
634
+ super(message, {
635
+ ...rest,
636
+ pluginSlug,
637
+ key,
638
+ operation,
639
+ description
640
+ });
641
+ }
642
+ }
643
+ class PartitionDriverError extends S3dbError {
644
+ constructor(message, details = {}) {
645
+ const {
646
+ driver = "unknown",
647
+ operation = "unknown",
648
+ queueSize,
649
+ maxQueueSize,
650
+ ...rest
651
+ } = details;
652
+ let description = details.description;
653
+ if (!description && queueSize !== void 0 && maxQueueSize !== void 0) {
654
+ description = `
655
+ Partition Driver Error
656
+
657
+ Driver: ${driver}
658
+ Operation: ${operation}
659
+ Queue Status: ${queueSize}/${maxQueueSize}
660
+
661
+ Possible causes:
662
+ 1. Queue is full (backpressure)
663
+ 2. Driver not properly configured
664
+ 3. SQS permissions issue (if using SQS driver)
665
+
666
+ Solution:
667
+ ${queueSize >= maxQueueSize ? "Wait for queue to drain or increase maxQueueSize" : "Check driver configuration and permissions"}
668
+
669
+ Docs: https://github.com/forattini-dev/s3db.js/blob/main/docs/README.md#partition-drivers
670
+ `.trim();
671
+ } else if (!description) {
672
+ description = `
673
+ Partition Driver Error
674
+
675
+ Driver: ${driver}
676
+ Operation: ${operation}
677
+
678
+ Check driver configuration and permissions.
679
+
680
+ Docs: https://github.com/forattini-dev/s3db.js/blob/main/docs/README.md#partition-drivers
681
+ `.trim();
682
+ }
683
+ super(message, {
684
+ ...rest,
685
+ driver,
686
+ operation,
687
+ queueSize,
688
+ maxQueueSize,
689
+ description
690
+ });
691
+ }
692
+ }
693
+ class BehaviorError extends S3dbError {
694
+ constructor(message, details = {}) {
695
+ const {
696
+ behavior = "unknown",
697
+ availableBehaviors = [],
698
+ ...rest
699
+ } = details;
700
+ let description = details.description;
701
+ if (!description) {
702
+ description = `
703
+ Behavior Error
704
+
705
+ Requested: ${behavior}
706
+ Available: ${availableBehaviors.join(", ") || "body-overflow, body-only, truncate-data, enforce-limits, user-managed"}
707
+
708
+ Possible causes:
709
+ 1. Behavior name misspelled
710
+ 2. Custom behavior not registered
711
+
712
+ Solution:
713
+ Use one of the available behaviors or register custom behavior.
714
+
715
+ Docs: https://github.com/forattini-dev/s3db.js/blob/main/docs/README.md#behaviors
716
+ `.trim();
717
+ }
718
+ super(message, {
719
+ ...rest,
720
+ behavior,
721
+ availableBehaviors,
722
+ description
723
+ });
724
+ }
725
+ }
726
+ class StreamError extends S3dbError {
727
+ constructor(message, details = {}) {
728
+ const {
729
+ operation = "unknown",
730
+ resource,
731
+ ...rest
732
+ } = details;
733
+ let description = details.description;
734
+ if (!description) {
735
+ description = `
736
+ Stream Error
737
+
738
+ Operation: ${operation}
739
+ ${resource ? `Resource: ${resource}` : ""}
740
+
741
+ Possible causes:
742
+ 1. Stream not properly initialized
743
+ 2. Resource not available
744
+ 3. Network error during streaming
745
+
746
+ Solution:
747
+ Check stream configuration and resource availability.
748
+
749
+ Docs: https://github.com/forattini-dev/s3db.js/blob/main/docs/README.md#streaming
750
+ `.trim();
751
+ }
752
+ super(message, {
753
+ ...rest,
754
+ operation,
755
+ resource,
756
+ description
757
+ });
758
+ }
759
+ }
760
+ class MetadataLimitError extends S3dbError {
761
+ constructor(message, details = {}) {
762
+ const {
763
+ totalSize,
764
+ effectiveLimit,
765
+ absoluteLimit = 2047,
766
+ excess,
767
+ resourceName,
768
+ operation,
769
+ ...rest
770
+ } = details;
771
+ let description = details.description;
772
+ if (!description && totalSize && effectiveLimit) {
773
+ description = `
774
+ S3 Metadata Size Limit Exceeded
775
+
776
+ Current Size: ${totalSize} bytes
777
+ Effective Limit: ${effectiveLimit} bytes
778
+ Absolute Limit: ${absoluteLimit} bytes
779
+ ${excess ? `Excess: ${excess} bytes` : ""}
780
+ ${resourceName ? `Resource: ${resourceName}` : ""}
781
+ ${operation ? `Operation: ${operation}` : ""}
782
+
783
+ S3 has a hard limit of 2KB (2047 bytes) for object metadata.
784
+
785
+ Solutions:
786
+ 1. Use 'body-overflow' behavior to store excess in body
787
+ 2. Use 'body-only' behavior to store everything in body
788
+ 3. Reduce number of fields
789
+ 4. Use shorter field values
790
+ 5. Enable advanced metadata encoding
791
+
792
+ Example:
793
+ await db.createResource({
794
+ name: '${resourceName || "myResource"}',
795
+ behavior: 'body-overflow', // Automatically handles overflow
796
+ attributes: { ... }
797
+ });
798
+
799
+ Docs: https://github.com/forattini-dev/s3db.js/blob/main/docs/README.md#metadata-size-limits
800
+ `.trim();
801
+ }
802
+ super(message, {
803
+ ...rest,
804
+ totalSize,
805
+ effectiveLimit,
806
+ absoluteLimit,
807
+ excess,
808
+ resourceName,
809
+ operation,
810
+ description
811
+ });
456
812
  }
457
813
  }
458
814
 
@@ -795,10 +1151,17 @@ class PluginStorage {
795
1151
  */
796
1152
  constructor(client, pluginSlug) {
797
1153
  if (!client) {
798
- throw new Error("PluginStorage requires a client instance");
1154
+ throw new PluginStorageError("PluginStorage requires a client instance", {
1155
+ operation: "constructor",
1156
+ pluginSlug,
1157
+ suggestion: "Pass a valid S3db Client instance when creating PluginStorage"
1158
+ });
799
1159
  }
800
1160
  if (!pluginSlug) {
801
- throw new Error("PluginStorage requires a pluginSlug");
1161
+ throw new PluginStorageError("PluginStorage requires a pluginSlug", {
1162
+ operation: "constructor",
1163
+ suggestion: 'Provide a plugin slug (e.g., "eventual-consistency", "cache", "audit")'
1164
+ });
802
1165
  }
803
1166
  this.client = client;
804
1167
  this.pluginSlug = pluginSlug;
@@ -851,7 +1214,15 @@ class PluginStorage {
851
1214
  }
852
1215
  const [ok, err] = await tryFn(() => this.client.putObject(putParams));
853
1216
  if (!ok) {
854
- throw new Error(`PluginStorage.set failed for key ${key}: ${err.message}`);
1217
+ throw new PluginStorageError(`Failed to save plugin data`, {
1218
+ pluginSlug: this.pluginSlug,
1219
+ key,
1220
+ operation: "set",
1221
+ behavior,
1222
+ ttl,
1223
+ original: err,
1224
+ suggestion: "Check S3 permissions and key format"
1225
+ });
855
1226
  }
856
1227
  }
857
1228
  /**
@@ -873,7 +1244,13 @@ class PluginStorage {
873
1244
  if (err.name === "NoSuchKey" || err.Code === "NoSuchKey") {
874
1245
  return null;
875
1246
  }
876
- throw new Error(`PluginStorage.get failed for key ${key}: ${err.message}`);
1247
+ throw new PluginStorageError(`Failed to retrieve plugin data`, {
1248
+ pluginSlug: this.pluginSlug,
1249
+ key,
1250
+ operation: "get",
1251
+ original: err,
1252
+ suggestion: "Check if the key exists and S3 permissions are correct"
1253
+ });
877
1254
  }
878
1255
  const metadata = response.Metadata || {};
879
1256
  const parsedMetadata = this._parseMetadataValues(metadata);
@@ -886,7 +1263,13 @@ class PluginStorage {
886
1263
  data = { ...parsedMetadata, ...body };
887
1264
  }
888
1265
  } catch (parseErr) {
889
- throw new Error(`PluginStorage.get failed to parse body for key ${key}: ${parseErr.message}`);
1266
+ throw new PluginStorageError(`Failed to parse JSON body`, {
1267
+ pluginSlug: this.pluginSlug,
1268
+ key,
1269
+ operation: "get",
1270
+ original: parseErr,
1271
+ suggestion: "Body content may be corrupted. Check S3 object integrity"
1272
+ });
890
1273
  }
891
1274
  }
892
1275
  const expiresAt = data._expiresat || data._expiresAt;
@@ -947,7 +1330,15 @@ class PluginStorage {
947
1330
  () => this.client.listObjects({ prefix: fullPrefix, maxKeys: limit })
948
1331
  );
949
1332
  if (!ok) {
950
- throw new Error(`PluginStorage.list failed: ${err.message}`);
1333
+ throw new PluginStorageError(`Failed to list plugin data`, {
1334
+ pluginSlug: this.pluginSlug,
1335
+ operation: "list",
1336
+ prefix,
1337
+ fullPrefix,
1338
+ limit,
1339
+ original: err,
1340
+ suggestion: "Check S3 permissions and bucket configuration"
1341
+ });
951
1342
  }
952
1343
  const keys = result.Contents?.map((item) => item.Key) || [];
953
1344
  return this._removeKeyPrefix(keys);
@@ -967,7 +1358,16 @@ class PluginStorage {
967
1358
  () => this.client.listObjects({ prefix: fullPrefix, maxKeys: limit })
968
1359
  );
969
1360
  if (!ok) {
970
- throw new Error(`PluginStorage.listForResource failed: ${err.message}`);
1361
+ throw new PluginStorageError(`Failed to list resource data`, {
1362
+ pluginSlug: this.pluginSlug,
1363
+ operation: "listForResource",
1364
+ resourceName,
1365
+ subPrefix,
1366
+ fullPrefix,
1367
+ limit,
1368
+ original: err,
1369
+ suggestion: "Check resource name and S3 permissions"
1370
+ });
971
1371
  }
972
1372
  const keys = result.Contents?.map((item) => item.Key) || [];
973
1373
  return this._removeKeyPrefix(keys);
@@ -1107,7 +1507,13 @@ class PluginStorage {
1107
1507
  async delete(key) {
1108
1508
  const [ok, err] = await tryFn(() => this.client.deleteObject(key));
1109
1509
  if (!ok) {
1110
- throw new Error(`PluginStorage.delete failed for key ${key}: ${err.message}`);
1510
+ throw new PluginStorageError(`Failed to delete plugin data`, {
1511
+ pluginSlug: this.pluginSlug,
1512
+ key,
1513
+ operation: "delete",
1514
+ original: err,
1515
+ suggestion: "Check S3 delete permissions"
1516
+ });
1111
1517
  }
1112
1518
  }
1113
1519
  /**
@@ -1294,16 +1700,28 @@ class PluginStorage {
1294
1700
  const valueSize = calculateUTF8Bytes(encoded);
1295
1701
  currentSize += keySize + valueSize;
1296
1702
  if (currentSize > effectiveLimit) {
1297
- throw new Error(
1298
- `Data exceeds metadata limit (${currentSize} > ${effectiveLimit} bytes). Use 'body-overflow' or 'body-only' behavior.`
1299
- );
1703
+ throw new MetadataLimitError(`Data exceeds metadata limit with enforce-limits behavior`, {
1704
+ totalSize: currentSize,
1705
+ effectiveLimit,
1706
+ absoluteLimit: S3_METADATA_LIMIT,
1707
+ excess: currentSize - effectiveLimit,
1708
+ operation: "PluginStorage.set",
1709
+ pluginSlug: this.pluginSlug,
1710
+ suggestion: "Use 'body-overflow' or 'body-only' behavior to handle large data"
1711
+ });
1300
1712
  }
1301
1713
  metadata[key] = jsonValue;
1302
1714
  }
1303
1715
  break;
1304
1716
  }
1305
1717
  default:
1306
- throw new Error(`Unknown behavior: ${behavior}. Use 'body-overflow', 'body-only', or 'enforce-limits'.`);
1718
+ throw new BehaviorError(`Unknown behavior: ${behavior}`, {
1719
+ behavior,
1720
+ availableBehaviors: ["body-overflow", "body-only", "enforce-limits"],
1721
+ operation: "PluginStorage._applyBehavior",
1722
+ pluginSlug: this.pluginSlug,
1723
+ suggestion: "Use 'body-overflow', 'body-only', or 'enforce-limits'"
1724
+ });
1307
1725
  }
1308
1726
  return { metadata, body };
1309
1727
  }
@@ -1868,6 +2286,35 @@ class AuditPlugin extends Plugin {
1868
2286
  }
1869
2287
  }
1870
2288
 
2289
+ class BackupError extends S3dbError {
2290
+ constructor(message, details = {}) {
2291
+ const { driver = "unknown", operation = "unknown", backupId, ...rest } = details;
2292
+ let description = details.description;
2293
+ if (!description) {
2294
+ description = `
2295
+ Backup Operation Error
2296
+
2297
+ Driver: ${driver}
2298
+ Operation: ${operation}
2299
+ ${backupId ? `Backup ID: ${backupId}` : ""}
2300
+
2301
+ Common causes:
2302
+ 1. Invalid backup driver configuration
2303
+ 2. Destination storage not accessible
2304
+ 3. Insufficient permissions
2305
+ 4. Network connectivity issues
2306
+ 5. Invalid backup file format
2307
+
2308
+ Solution:
2309
+ Check driver configuration and ensure destination storage is accessible.
2310
+
2311
+ Docs: https://github.com/forattini-dev/s3db.js/blob/main/docs/plugins/backup.md
2312
+ `.trim();
2313
+ }
2314
+ super(message, { ...rest, driver, operation, backupId, description });
2315
+ }
2316
+ }
2317
+
1871
2318
  class BaseBackupDriver {
1872
2319
  constructor(config = {}) {
1873
2320
  this.config = {
@@ -1898,7 +2345,12 @@ class BaseBackupDriver {
1898
2345
  * @returns {Object} Upload result with destination info
1899
2346
  */
1900
2347
  async upload(filePath, backupId, manifest) {
1901
- throw new Error("upload() method must be implemented by subclass");
2348
+ throw new BackupError("upload() method must be implemented by subclass", {
2349
+ operation: "upload",
2350
+ driver: this.constructor.name,
2351
+ backupId,
2352
+ suggestion: "Extend BaseBackupDriver and implement the upload() method"
2353
+ });
1902
2354
  }
1903
2355
  /**
1904
2356
  * Download a backup file from the destination
@@ -1908,7 +2360,12 @@ class BaseBackupDriver {
1908
2360
  * @returns {string} Path to downloaded file
1909
2361
  */
1910
2362
  async download(backupId, targetPath, metadata) {
1911
- throw new Error("download() method must be implemented by subclass");
2363
+ throw new BackupError("download() method must be implemented by subclass", {
2364
+ operation: "download",
2365
+ driver: this.constructor.name,
2366
+ backupId,
2367
+ suggestion: "Extend BaseBackupDriver and implement the download() method"
2368
+ });
1912
2369
  }
1913
2370
  /**
1914
2371
  * Delete a backup from the destination
@@ -1916,7 +2373,12 @@ class BaseBackupDriver {
1916
2373
  * @param {Object} metadata - Backup metadata
1917
2374
  */
1918
2375
  async delete(backupId, metadata) {
1919
- throw new Error("delete() method must be implemented by subclass");
2376
+ throw new BackupError("delete() method must be implemented by subclass", {
2377
+ operation: "delete",
2378
+ driver: this.constructor.name,
2379
+ backupId,
2380
+ suggestion: "Extend BaseBackupDriver and implement the delete() method"
2381
+ });
1920
2382
  }
1921
2383
  /**
1922
2384
  * List backups available in the destination
@@ -1924,7 +2386,11 @@ class BaseBackupDriver {
1924
2386
  * @returns {Array} List of backup metadata
1925
2387
  */
1926
2388
  async list(options = {}) {
1927
- throw new Error("list() method must be implemented by subclass");
2389
+ throw new BackupError("list() method must be implemented by subclass", {
2390
+ operation: "list",
2391
+ driver: this.constructor.name,
2392
+ suggestion: "Extend BaseBackupDriver and implement the list() method"
2393
+ });
1928
2394
  }
1929
2395
  /**
1930
2396
  * Verify backup integrity
@@ -1934,14 +2400,23 @@ class BaseBackupDriver {
1934
2400
  * @returns {boolean} True if backup is valid
1935
2401
  */
1936
2402
  async verify(backupId, expectedChecksum, metadata) {
1937
- throw new Error("verify() method must be implemented by subclass");
2403
+ throw new BackupError("verify() method must be implemented by subclass", {
2404
+ operation: "verify",
2405
+ driver: this.constructor.name,
2406
+ backupId,
2407
+ suggestion: "Extend BaseBackupDriver and implement the verify() method"
2408
+ });
1938
2409
  }
1939
2410
  /**
1940
2411
  * Get driver type identifier
1941
2412
  * @returns {string} Driver type
1942
2413
  */
1943
2414
  getType() {
1944
- throw new Error("getType() method must be implemented by subclass");
2415
+ throw new BackupError("getType() method must be implemented by subclass", {
2416
+ operation: "getType",
2417
+ driver: this.constructor.name,
2418
+ suggestion: "Extend BaseBackupDriver and implement the getType() method"
2419
+ });
1945
2420
  }
1946
2421
  /**
1947
2422
  * Get driver-specific storage info
@@ -1983,7 +2458,11 @@ class FilesystemBackupDriver extends BaseBackupDriver {
1983
2458
  }
1984
2459
  async onSetup() {
1985
2460
  if (!this.config.path) {
1986
- throw new Error("FilesystemBackupDriver: path configuration is required");
2461
+ throw new BackupError("FilesystemBackupDriver: path configuration is required", {
2462
+ operation: "onSetup",
2463
+ driver: "filesystem",
2464
+ suggestion: 'Provide a path in config: new FilesystemBackupDriver({ path: "/path/to/backups" })'
2465
+ });
1987
2466
  }
1988
2467
  this.log(`Initialized with path: ${this.config.path}`);
1989
2468
  }
@@ -2007,11 +2486,26 @@ class FilesystemBackupDriver extends BaseBackupDriver {
2007
2486
  () => mkdir(targetDir, { recursive: true, mode: this.config.directoryPermissions })
2008
2487
  );
2009
2488
  if (!createDirOk) {
2010
- throw new Error(`Failed to create backup directory: ${createDirErr.message}`);
2489
+ throw new BackupError("Failed to create backup directory", {
2490
+ operation: "upload",
2491
+ driver: "filesystem",
2492
+ backupId,
2493
+ targetDir,
2494
+ original: createDirErr,
2495
+ suggestion: "Check directory permissions and disk space"
2496
+ });
2011
2497
  }
2012
2498
  const [copyOk, copyErr] = await tryFn(() => copyFile(filePath, targetPath));
2013
2499
  if (!copyOk) {
2014
- throw new Error(`Failed to copy backup file: ${copyErr.message}`);
2500
+ throw new BackupError("Failed to copy backup file", {
2501
+ operation: "upload",
2502
+ driver: "filesystem",
2503
+ backupId,
2504
+ filePath,
2505
+ targetPath,
2506
+ original: copyErr,
2507
+ suggestion: "Check file permissions and disk space"
2508
+ });
2015
2509
  }
2016
2510
  const [manifestOk, manifestErr] = await tryFn(
2017
2511
  () => import('fs/promises').then((fs) => fs.writeFile(
@@ -2022,7 +2516,14 @@ class FilesystemBackupDriver extends BaseBackupDriver {
2022
2516
  );
2023
2517
  if (!manifestOk) {
2024
2518
  await tryFn(() => unlink(targetPath));
2025
- throw new Error(`Failed to write manifest: ${manifestErr.message}`);
2519
+ throw new BackupError("Failed to write manifest file", {
2520
+ operation: "upload",
2521
+ driver: "filesystem",
2522
+ backupId,
2523
+ manifestPath,
2524
+ original: manifestErr,
2525
+ suggestion: "Check directory permissions and disk space"
2526
+ });
2026
2527
  }
2027
2528
  const [statOk, , stats] = await tryFn(() => stat(targetPath));
2028
2529
  const size = statOk ? stats.size : 0;
@@ -2041,13 +2542,27 @@ class FilesystemBackupDriver extends BaseBackupDriver {
2041
2542
  );
2042
2543
  const [existsOk] = await tryFn(() => access(sourcePath));
2043
2544
  if (!existsOk) {
2044
- throw new Error(`Backup file not found: ${sourcePath}`);
2545
+ throw new BackupError("Backup file not found", {
2546
+ operation: "download",
2547
+ driver: "filesystem",
2548
+ backupId,
2549
+ sourcePath,
2550
+ suggestion: "Check if backup exists using list() method"
2551
+ });
2045
2552
  }
2046
2553
  const targetDir = path.dirname(targetPath);
2047
2554
  await tryFn(() => mkdir(targetDir, { recursive: true }));
2048
2555
  const [copyOk, copyErr] = await tryFn(() => copyFile(sourcePath, targetPath));
2049
2556
  if (!copyOk) {
2050
- throw new Error(`Failed to download backup: ${copyErr.message}`);
2557
+ throw new BackupError("Failed to download backup", {
2558
+ operation: "download",
2559
+ driver: "filesystem",
2560
+ backupId,
2561
+ sourcePath,
2562
+ targetPath,
2563
+ original: copyErr,
2564
+ suggestion: "Check file permissions and disk space"
2565
+ });
2051
2566
  }
2052
2567
  this.log(`Downloaded backup ${backupId} from ${sourcePath} to ${targetPath}`);
2053
2568
  return targetPath;
@@ -2064,7 +2579,14 @@ class FilesystemBackupDriver extends BaseBackupDriver {
2064
2579
  const [deleteBackupOk] = await tryFn(() => unlink(backupPath));
2065
2580
  const [deleteManifestOk] = await tryFn(() => unlink(manifestPath));
2066
2581
  if (!deleteBackupOk && !deleteManifestOk) {
2067
- throw new Error(`Failed to delete backup files for ${backupId}`);
2582
+ throw new BackupError("Failed to delete backup files", {
2583
+ operation: "delete",
2584
+ driver: "filesystem",
2585
+ backupId,
2586
+ backupPath,
2587
+ manifestPath,
2588
+ suggestion: "Check file permissions"
2589
+ });
2068
2590
  }
2069
2591
  this.log(`Deleted backup ${backupId}`);
2070
2592
  }
@@ -2169,10 +2691,18 @@ class S3BackupDriver extends BaseBackupDriver {
2169
2691
  this.config.bucket = this.database.bucket;
2170
2692
  }
2171
2693
  if (!this.config.client) {
2172
- throw new Error("S3BackupDriver: client is required (either via config or database)");
2694
+ throw new BackupError("S3BackupDriver: client is required", {
2695
+ operation: "onSetup",
2696
+ driver: "s3",
2697
+ suggestion: "Provide a client in config or ensure database has a client configured"
2698
+ });
2173
2699
  }
2174
2700
  if (!this.config.bucket) {
2175
- throw new Error("S3BackupDriver: bucket is required (either via config or database)");
2701
+ throw new BackupError("S3BackupDriver: bucket is required", {
2702
+ operation: "onSetup",
2703
+ driver: "s3",
2704
+ suggestion: "Provide a bucket in config or ensure database has a bucket configured"
2705
+ });
2176
2706
  }
2177
2707
  this.log(`Initialized with bucket: ${this.config.bucket}, path: ${this.config.path}`);
2178
2708
  }
@@ -2214,7 +2744,15 @@ class S3BackupDriver extends BaseBackupDriver {
2214
2744
  });
2215
2745
  });
2216
2746
  if (!uploadOk) {
2217
- throw new Error(`Failed to upload backup file: ${uploadErr.message}`);
2747
+ throw new BackupError("Failed to upload backup file to S3", {
2748
+ operation: "upload",
2749
+ driver: "s3",
2750
+ backupId,
2751
+ bucket: this.config.bucket,
2752
+ key: backupKey,
2753
+ original: uploadErr,
2754
+ suggestion: "Check S3 permissions and bucket configuration"
2755
+ });
2218
2756
  }
2219
2757
  const [manifestOk, manifestErr] = await tryFn(
2220
2758
  () => this.config.client.uploadObject({
@@ -2235,7 +2773,15 @@ class S3BackupDriver extends BaseBackupDriver {
2235
2773
  bucket: this.config.bucket,
2236
2774
  key: backupKey
2237
2775
  }));
2238
- throw new Error(`Failed to upload manifest: ${manifestErr.message}`);
2776
+ throw new BackupError("Failed to upload manifest to S3", {
2777
+ operation: "upload",
2778
+ driver: "s3",
2779
+ backupId,
2780
+ bucket: this.config.bucket,
2781
+ manifestKey,
2782
+ original: manifestErr,
2783
+ suggestion: "Check S3 permissions and bucket configuration"
2784
+ });
2239
2785
  }
2240
2786
  this.log(`Uploaded backup ${backupId} to s3://${this.config.bucket}/${backupKey} (${fileSize} bytes)`);
2241
2787
  return {
@@ -2258,7 +2804,16 @@ class S3BackupDriver extends BaseBackupDriver {
2258
2804
  })
2259
2805
  );
2260
2806
  if (!downloadOk) {
2261
- throw new Error(`Failed to download backup: ${downloadErr.message}`);
2807
+ throw new BackupError("Failed to download backup from S3", {
2808
+ operation: "download",
2809
+ driver: "s3",
2810
+ backupId,
2811
+ bucket: this.config.bucket,
2812
+ key: backupKey,
2813
+ targetPath,
2814
+ original: downloadErr,
2815
+ suggestion: "Check if backup exists and S3 permissions are correct"
2816
+ });
2262
2817
  }
2263
2818
  this.log(`Downloaded backup ${backupId} from s3://${this.config.bucket}/${backupKey} to ${targetPath}`);
2264
2819
  return targetPath;
@@ -2279,7 +2834,15 @@ class S3BackupDriver extends BaseBackupDriver {
2279
2834
  })
2280
2835
  );
2281
2836
  if (!deleteBackupOk && !deleteManifestOk) {
2282
- throw new Error(`Failed to delete backup objects for ${backupId}`);
2837
+ throw new BackupError("Failed to delete backup from S3", {
2838
+ operation: "delete",
2839
+ driver: "s3",
2840
+ backupId,
2841
+ bucket: this.config.bucket,
2842
+ backupKey,
2843
+ manifestKey,
2844
+ suggestion: "Check S3 delete permissions"
2845
+ });
2283
2846
  }
2284
2847
  this.log(`Deleted backup ${backupId} from S3`);
2285
2848
  }
@@ -2392,11 +2955,22 @@ class MultiBackupDriver extends BaseBackupDriver {
2392
2955
  }
2393
2956
  async onSetup() {
2394
2957
  if (!Array.isArray(this.config.destinations) || this.config.destinations.length === 0) {
2395
- throw new Error("MultiBackupDriver: destinations array is required and must not be empty");
2958
+ throw new BackupError("MultiBackupDriver requires non-empty destinations array", {
2959
+ operation: "onSetup",
2960
+ driver: "multi",
2961
+ destinationsProvided: this.config.destinations,
2962
+ suggestion: 'Provide destinations array: { destinations: [{ driver: "s3", config: {...} }, { driver: "filesystem", config: {...} }] }'
2963
+ });
2396
2964
  }
2397
2965
  for (const [index, destConfig] of this.config.destinations.entries()) {
2398
2966
  if (!destConfig.driver) {
2399
- throw new Error(`MultiBackupDriver: destination[${index}] must have a driver type`);
2967
+ throw new BackupError(`Destination ${index} missing driver type`, {
2968
+ operation: "onSetup",
2969
+ driver: "multi",
2970
+ destinationIndex: index,
2971
+ destination: destConfig,
2972
+ suggestion: 'Each destination must have a driver property: { driver: "s3", config: {...} } or { driver: "filesystem", config: {...} }'
2973
+ });
2400
2974
  }
2401
2975
  try {
2402
2976
  const driver = createBackupDriver(destConfig.driver, destConfig.config || {});
@@ -2408,7 +2982,15 @@ class MultiBackupDriver extends BaseBackupDriver {
2408
2982
  });
2409
2983
  this.log(`Setup destination ${index}: ${destConfig.driver}`);
2410
2984
  } catch (error) {
2411
- throw new Error(`Failed to setup destination ${index} (${destConfig.driver}): ${error.message}`);
2985
+ throw new BackupError(`Failed to setup destination ${index}`, {
2986
+ operation: "onSetup",
2987
+ driver: "multi",
2988
+ destinationIndex: index,
2989
+ destinationDriver: destConfig.driver,
2990
+ destinationConfig: destConfig.config,
2991
+ original: error,
2992
+ suggestion: "Check destination driver configuration and ensure dependencies are available"
2993
+ });
2412
2994
  }
2413
2995
  }
2414
2996
  if (this.config.requireAll === false) {
@@ -2437,7 +3019,15 @@ class MultiBackupDriver extends BaseBackupDriver {
2437
3019
  this.log(`Priority upload failed to destination ${index}: ${err.message}`);
2438
3020
  }
2439
3021
  }
2440
- throw new Error(`All priority destinations failed: ${errors.map((e) => `${e.destination}: ${e.error}`).join("; ")}`);
3022
+ throw new BackupError("All priority destinations failed", {
3023
+ operation: "upload",
3024
+ driver: "multi",
3025
+ strategy: "priority",
3026
+ backupId,
3027
+ totalDestinations: this.drivers.length,
3028
+ failures: errors,
3029
+ suggestion: "Check destination configurations and ensure at least one destination is accessible"
3030
+ });
2441
3031
  }
2442
3032
  const uploadPromises = this.drivers.map(async ({ driver, config, index }) => {
2443
3033
  const [ok, err, result] = await tryFn(
@@ -2467,10 +3057,28 @@ class MultiBackupDriver extends BaseBackupDriver {
2467
3057
  const successResults = allResults.filter((r) => r.status === "success");
2468
3058
  const failedResults = allResults.filter((r) => r.status === "failed");
2469
3059
  if (strategy === "all" && failedResults.length > 0) {
2470
- throw new Error(`Some destinations failed: ${failedResults.map((r) => `${r.destination}: ${r.error}`).join("; ")}`);
3060
+ throw new BackupError('Some destinations failed with strategy "all"', {
3061
+ operation: "upload",
3062
+ driver: "multi",
3063
+ strategy: "all",
3064
+ backupId,
3065
+ totalDestinations: this.drivers.length,
3066
+ successCount: successResults.length,
3067
+ failedCount: failedResults.length,
3068
+ failures: failedResults,
3069
+ suggestion: 'All destinations must succeed with "all" strategy. Use "any" strategy to tolerate failures, or fix failing destinations.'
3070
+ });
2471
3071
  }
2472
3072
  if (strategy === "any" && successResults.length === 0) {
2473
- throw new Error(`All destinations failed: ${failedResults.map((r) => `${r.destination}: ${r.error}`).join("; ")}`);
3073
+ throw new BackupError('All destinations failed with strategy "any"', {
3074
+ operation: "upload",
3075
+ driver: "multi",
3076
+ strategy: "any",
3077
+ backupId,
3078
+ totalDestinations: this.drivers.length,
3079
+ failures: failedResults,
3080
+ suggestion: 'At least one destination must succeed with "any" strategy. Check all destination configurations.'
3081
+ });
2474
3082
  }
2475
3083
  return allResults;
2476
3084
  }
@@ -2490,7 +3098,14 @@ class MultiBackupDriver extends BaseBackupDriver {
2490
3098
  this.log(`Download failed from destination ${destMetadata.destination}: ${err.message}`);
2491
3099
  }
2492
3100
  }
2493
- throw new Error(`Failed to download backup from any destination`);
3101
+ throw new BackupError("Failed to download backup from any destination", {
3102
+ operation: "download",
3103
+ driver: "multi",
3104
+ backupId,
3105
+ targetPath,
3106
+ attemptedDestinations: destinations.length,
3107
+ suggestion: "Check if backup exists in at least one destination and destinations are accessible"
3108
+ });
2494
3109
  }
2495
3110
  async delete(backupId, metadata) {
2496
3111
  const destinations = Array.isArray(metadata.destinations) ? metadata.destinations : [metadata];
@@ -2512,7 +3127,14 @@ class MultiBackupDriver extends BaseBackupDriver {
2512
3127
  }
2513
3128
  }
2514
3129
  if (successCount === 0 && errors.length > 0) {
2515
- throw new Error(`Failed to delete from any destination: ${errors.join("; ")}`);
3130
+ throw new BackupError("Failed to delete from any destination", {
3131
+ operation: "delete",
3132
+ driver: "multi",
3133
+ backupId,
3134
+ attemptedDestinations: destinations.length,
3135
+ failures: errors,
3136
+ suggestion: "Check if backup exists in destinations and destinations are accessible with delete permissions"
3137
+ });
2516
3138
  }
2517
3139
  if (errors.length > 0) {
2518
3140
  this.log(`Partial delete success, some errors: ${errors.join("; ")}`);
@@ -2612,32 +3234,62 @@ const BACKUP_DRIVERS = {
2612
3234
  function createBackupDriver(driver, config = {}) {
2613
3235
  const DriverClass = BACKUP_DRIVERS[driver];
2614
3236
  if (!DriverClass) {
2615
- throw new Error(`Unknown backup driver: ${driver}. Available drivers: ${Object.keys(BACKUP_DRIVERS).join(", ")}`);
3237
+ throw new BackupError(`Unknown backup driver: ${driver}`, {
3238
+ operation: "createBackupDriver",
3239
+ driver,
3240
+ availableDrivers: Object.keys(BACKUP_DRIVERS),
3241
+ suggestion: `Use one of the available drivers: ${Object.keys(BACKUP_DRIVERS).join(", ")}`
3242
+ });
2616
3243
  }
2617
3244
  return new DriverClass(config);
2618
3245
  }
2619
3246
  function validateBackupConfig(driver, config = {}) {
2620
3247
  if (!driver || typeof driver !== "string") {
2621
- throw new Error("Driver type must be a non-empty string");
3248
+ throw new BackupError("Driver type must be a non-empty string", {
3249
+ operation: "validateBackupConfig",
3250
+ driver,
3251
+ suggestion: "Provide a valid driver type string (filesystem, s3, or multi)"
3252
+ });
2622
3253
  }
2623
3254
  if (!BACKUP_DRIVERS[driver]) {
2624
- throw new Error(`Unknown backup driver: ${driver}. Available drivers: ${Object.keys(BACKUP_DRIVERS).join(", ")}`);
3255
+ throw new BackupError(`Unknown backup driver: ${driver}`, {
3256
+ operation: "validateBackupConfig",
3257
+ driver,
3258
+ availableDrivers: Object.keys(BACKUP_DRIVERS),
3259
+ suggestion: `Use one of the available drivers: ${Object.keys(BACKUP_DRIVERS).join(", ")}`
3260
+ });
2625
3261
  }
2626
3262
  switch (driver) {
2627
3263
  case "filesystem":
2628
3264
  if (!config.path) {
2629
- throw new Error('FilesystemBackupDriver requires "path" configuration');
3265
+ throw new BackupError('FilesystemBackupDriver requires "path" configuration', {
3266
+ operation: "validateBackupConfig",
3267
+ driver: "filesystem",
3268
+ config,
3269
+ suggestion: 'Provide a "path" property in config: { path: "/path/to/backups" }'
3270
+ });
2630
3271
  }
2631
3272
  break;
2632
3273
  case "s3":
2633
3274
  break;
2634
3275
  case "multi":
2635
3276
  if (!Array.isArray(config.destinations) || config.destinations.length === 0) {
2636
- throw new Error('MultiBackupDriver requires non-empty "destinations" array');
3277
+ throw new BackupError('MultiBackupDriver requires non-empty "destinations" array', {
3278
+ operation: "validateBackupConfig",
3279
+ driver: "multi",
3280
+ config,
3281
+ suggestion: 'Provide destinations array: { destinations: [{ driver: "s3", config: {...} }] }'
3282
+ });
2637
3283
  }
2638
3284
  config.destinations.forEach((dest, index) => {
2639
3285
  if (!dest.driver) {
2640
- throw new Error(`Destination ${index} must have a "driver" property`);
3286
+ throw new BackupError(`Destination ${index} must have a "driver" property`, {
3287
+ operation: "validateBackupConfig",
3288
+ driver: "multi",
3289
+ destinationIndex: index,
3290
+ destination: dest,
3291
+ suggestion: 'Each destination must have a driver property: { driver: "s3", config: {...} }'
3292
+ });
2641
3293
  }
2642
3294
  if (dest.driver !== "multi") {
2643
3295
  validateBackupConfig(dest.driver, dest.config || {});
@@ -3293,6 +3945,36 @@ class BackupPlugin extends Plugin {
3293
3945
  }
3294
3946
  }
3295
3947
 
3948
+ class CacheError extends S3dbError {
3949
+ constructor(message, details = {}) {
3950
+ const { driver = "unknown", operation = "unknown", resourceName, key, ...rest } = details;
3951
+ let description = details.description;
3952
+ if (!description) {
3953
+ description = `
3954
+ Cache Operation Error
3955
+
3956
+ Driver: ${driver}
3957
+ Operation: ${operation}
3958
+ ${resourceName ? `Resource: ${resourceName}` : ""}
3959
+ ${key ? `Key: ${key}` : ""}
3960
+
3961
+ Common causes:
3962
+ 1. Invalid cache key format
3963
+ 2. Cache driver not properly initialized
3964
+ 3. Resource not found or not cached
3965
+ 4. Memory limits exceeded
3966
+ 5. Filesystem permissions issues
3967
+
3968
+ Solution:
3969
+ Check cache configuration and ensure the cache driver is properly initialized.
3970
+
3971
+ Docs: https://github.com/forattini-dev/s3db.js/blob/main/docs/plugins/cache.md
3972
+ `.trim();
3973
+ }
3974
+ super(message, { ...rest, driver, operation, resourceName, key, description });
3975
+ }
3976
+ }
3977
+
3296
3978
  class Cache extends EventEmitter {
3297
3979
  constructor(config = {}) {
3298
3980
  super();
@@ -3309,7 +3991,13 @@ class Cache extends EventEmitter {
3309
3991
  }
3310
3992
  validateKey(key) {
3311
3993
  if (key === null || key === void 0 || typeof key !== "string" || !key) {
3312
- throw new Error("Invalid key");
3994
+ throw new CacheError("Invalid cache key", {
3995
+ operation: "validateKey",
3996
+ driver: this.constructor.name,
3997
+ key,
3998
+ keyType: typeof key,
3999
+ suggestion: "Cache key must be a non-empty string"
4000
+ });
3313
4001
  }
3314
4002
  }
3315
4003
  // generic class methods
@@ -3396,7 +4084,11 @@ class ResourceReader extends EventEmitter {
3396
4084
  constructor({ resource, batchSize = 10, concurrency = 5 }) {
3397
4085
  super();
3398
4086
  if (!resource) {
3399
- throw new Error("Resource is required for ResourceReader");
4087
+ throw new StreamError("Resource is required for ResourceReader", {
4088
+ operation: "constructor",
4089
+ resource: resource?.name,
4090
+ suggestion: "Pass a valid Resource instance when creating ResourceReader"
4091
+ });
3400
4092
  }
3401
4093
  this.resource = resource;
3402
4094
  this.client = resource.client;
@@ -3520,7 +4212,10 @@ class ResourceWriter extends EventEmitter {
3520
4212
  function streamToString(stream) {
3521
4213
  return new Promise((resolve, reject) => {
3522
4214
  if (!stream) {
3523
- return reject(new Error("streamToString: stream is undefined"));
4215
+ return reject(new StreamError("Stream is undefined", {
4216
+ operation: "streamToString",
4217
+ suggestion: "Ensure a valid stream is passed to streamToString()"
4218
+ }));
3524
4219
  }
3525
4220
  const chunks = [];
3526
4221
  stream.on("data", (chunk) => chunks.push(chunk));
@@ -3601,6 +4296,24 @@ class MemoryCache extends Cache {
3601
4296
  this.cache = {};
3602
4297
  this.meta = {};
3603
4298
  this.maxSize = config.maxSize !== void 0 ? config.maxSize : 1e3;
4299
+ if (config.maxMemoryBytes && config.maxMemoryBytes > 0 && config.maxMemoryPercent && config.maxMemoryPercent > 0) {
4300
+ throw new Error(
4301
+ "[MemoryCache] Cannot use both maxMemoryBytes and maxMemoryPercent. Choose one: maxMemoryBytes (absolute) or maxMemoryPercent (0...1 fraction)."
4302
+ );
4303
+ }
4304
+ if (config.maxMemoryPercent && config.maxMemoryPercent > 0) {
4305
+ if (config.maxMemoryPercent > 1) {
4306
+ throw new Error(
4307
+ `[MemoryCache] maxMemoryPercent must be between 0 and 1 (e.g., 0.1 for 10%). Received: ${config.maxMemoryPercent}`
4308
+ );
4309
+ }
4310
+ const totalMemory = os$1.totalmem();
4311
+ this.maxMemoryBytes = Math.floor(totalMemory * config.maxMemoryPercent);
4312
+ this.maxMemoryPercent = config.maxMemoryPercent;
4313
+ } else {
4314
+ this.maxMemoryBytes = config.maxMemoryBytes !== void 0 ? config.maxMemoryBytes : 0;
4315
+ this.maxMemoryPercent = 0;
4316
+ }
3604
4317
  this.ttl = config.ttl !== void 0 ? config.ttl : 3e5;
3605
4318
  this.enableCompression = config.enableCompression !== void 0 ? config.enableCompression : false;
3606
4319
  this.compressionThreshold = config.compressionThreshold !== void 0 ? config.compressionThreshold : 1024;
@@ -3610,23 +4323,18 @@ class MemoryCache extends Cache {
3610
4323
  totalCompressedSize: 0,
3611
4324
  compressionRatio: 0
3612
4325
  };
4326
+ this.currentMemoryBytes = 0;
4327
+ this.evictedDueToMemory = 0;
3613
4328
  }
3614
4329
  async _set(key, data) {
3615
- if (this.maxSize > 0 && Object.keys(this.cache).length >= this.maxSize) {
3616
- const oldestKey = Object.entries(this.meta).sort((a, b) => a[1].ts - b[1].ts)[0]?.[0];
3617
- if (oldestKey) {
3618
- delete this.cache[oldestKey];
3619
- delete this.meta[oldestKey];
3620
- }
3621
- }
3622
4330
  let finalData = data;
3623
4331
  let compressed = false;
3624
4332
  let originalSize = 0;
3625
4333
  let compressedSize = 0;
4334
+ const serialized = JSON.stringify(data);
4335
+ originalSize = Buffer.byteLength(serialized, "utf8");
3626
4336
  if (this.enableCompression) {
3627
4337
  try {
3628
- const serialized = JSON.stringify(data);
3629
- originalSize = Buffer.byteLength(serialized, "utf8");
3630
4338
  if (originalSize >= this.compressionThreshold) {
3631
4339
  const compressedBuffer = zlib.gzipSync(Buffer.from(serialized, "utf8"));
3632
4340
  finalData = {
@@ -3645,13 +4353,42 @@ class MemoryCache extends Cache {
3645
4353
  console.warn(`[MemoryCache] Compression failed for key '${key}':`, error.message);
3646
4354
  }
3647
4355
  }
4356
+ const itemSize = compressed ? compressedSize : originalSize;
4357
+ if (Object.prototype.hasOwnProperty.call(this.cache, key)) {
4358
+ const oldSize = this.meta[key]?.compressedSize || 0;
4359
+ this.currentMemoryBytes -= oldSize;
4360
+ }
4361
+ if (this.maxMemoryBytes > 0) {
4362
+ while (this.currentMemoryBytes + itemSize > this.maxMemoryBytes && Object.keys(this.cache).length > 0) {
4363
+ const oldestKey = Object.entries(this.meta).sort((a, b) => a[1].ts - b[1].ts)[0]?.[0];
4364
+ if (oldestKey) {
4365
+ const evictedSize = this.meta[oldestKey]?.compressedSize || 0;
4366
+ delete this.cache[oldestKey];
4367
+ delete this.meta[oldestKey];
4368
+ this.currentMemoryBytes -= evictedSize;
4369
+ this.evictedDueToMemory++;
4370
+ } else {
4371
+ break;
4372
+ }
4373
+ }
4374
+ }
4375
+ if (this.maxSize > 0 && Object.keys(this.cache).length >= this.maxSize) {
4376
+ const oldestKey = Object.entries(this.meta).sort((a, b) => a[1].ts - b[1].ts)[0]?.[0];
4377
+ if (oldestKey) {
4378
+ const evictedSize = this.meta[oldestKey]?.compressedSize || 0;
4379
+ delete this.cache[oldestKey];
4380
+ delete this.meta[oldestKey];
4381
+ this.currentMemoryBytes -= evictedSize;
4382
+ }
4383
+ }
3648
4384
  this.cache[key] = finalData;
3649
4385
  this.meta[key] = {
3650
4386
  ts: Date.now(),
3651
4387
  compressed,
3652
4388
  originalSize,
3653
- compressedSize: compressed ? compressedSize : originalSize
4389
+ compressedSize: itemSize
3654
4390
  };
4391
+ this.currentMemoryBytes += itemSize;
3655
4392
  return data;
3656
4393
  }
3657
4394
  async _get(key) {
@@ -3659,7 +4396,9 @@ class MemoryCache extends Cache {
3659
4396
  if (this.ttl > 0) {
3660
4397
  const now = Date.now();
3661
4398
  const meta = this.meta[key];
3662
- if (meta && now - meta.ts > this.ttl * 1e3) {
4399
+ if (meta && now - meta.ts > this.ttl) {
4400
+ const itemSize = meta.compressedSize || 0;
4401
+ this.currentMemoryBytes -= itemSize;
3663
4402
  delete this.cache[key];
3664
4403
  delete this.meta[key];
3665
4404
  return null;
@@ -3681,6 +4420,10 @@ class MemoryCache extends Cache {
3681
4420
  return rawData;
3682
4421
  }
3683
4422
  async _del(key) {
4423
+ if (Object.prototype.hasOwnProperty.call(this.cache, key)) {
4424
+ const itemSize = this.meta[key]?.compressedSize || 0;
4425
+ this.currentMemoryBytes -= itemSize;
4426
+ }
3684
4427
  delete this.cache[key];
3685
4428
  delete this.meta[key];
3686
4429
  return true;
@@ -3689,10 +4432,13 @@ class MemoryCache extends Cache {
3689
4432
  if (!prefix) {
3690
4433
  this.cache = {};
3691
4434
  this.meta = {};
4435
+ this.currentMemoryBytes = 0;
3692
4436
  return true;
3693
4437
  }
3694
4438
  for (const key of Object.keys(this.cache)) {
3695
4439
  if (key.startsWith(prefix)) {
4440
+ const itemSize = this.meta[key]?.compressedSize || 0;
4441
+ this.currentMemoryBytes -= itemSize;
3696
4442
  delete this.cache[key];
3697
4443
  delete this.meta[key];
3698
4444
  }
@@ -3730,6 +4476,53 @@ class MemoryCache extends Cache {
3730
4476
  }
3731
4477
  };
3732
4478
  }
4479
+ /**
4480
+ * Get memory usage statistics
4481
+ * @returns {Object} Memory stats including current usage, limits, and eviction counts
4482
+ */
4483
+ getMemoryStats() {
4484
+ const totalItems = Object.keys(this.cache).length;
4485
+ const memoryUsagePercent = this.maxMemoryBytes > 0 ? (this.currentMemoryBytes / this.maxMemoryBytes * 100).toFixed(2) : 0;
4486
+ const systemMemory = {
4487
+ total: os$1.totalmem(),
4488
+ free: os$1.freemem(),
4489
+ used: os$1.totalmem() - os$1.freemem()
4490
+ };
4491
+ const cachePercentOfTotal = systemMemory.total > 0 ? (this.currentMemoryBytes / systemMemory.total * 100).toFixed(2) : 0;
4492
+ return {
4493
+ currentMemoryBytes: this.currentMemoryBytes,
4494
+ maxMemoryBytes: this.maxMemoryBytes,
4495
+ maxMemoryPercent: this.maxMemoryPercent,
4496
+ memoryUsagePercent: parseFloat(memoryUsagePercent),
4497
+ cachePercentOfSystemMemory: parseFloat(cachePercentOfTotal),
4498
+ totalItems,
4499
+ maxSize: this.maxSize,
4500
+ evictedDueToMemory: this.evictedDueToMemory,
4501
+ averageItemSize: totalItems > 0 ? Math.round(this.currentMemoryBytes / totalItems) : 0,
4502
+ memoryUsage: {
4503
+ current: this._formatBytes(this.currentMemoryBytes),
4504
+ max: this.maxMemoryBytes > 0 ? this._formatBytes(this.maxMemoryBytes) : "unlimited",
4505
+ available: this.maxMemoryBytes > 0 ? this._formatBytes(this.maxMemoryBytes - this.currentMemoryBytes) : "unlimited"
4506
+ },
4507
+ systemMemory: {
4508
+ total: this._formatBytes(systemMemory.total),
4509
+ free: this._formatBytes(systemMemory.free),
4510
+ used: this._formatBytes(systemMemory.used),
4511
+ cachePercent: `${cachePercentOfTotal}%`
4512
+ }
4513
+ };
4514
+ }
4515
+ /**
4516
+ * Format bytes to human-readable format
4517
+ * @private
4518
+ */
4519
+ _formatBytes(bytes) {
4520
+ if (bytes === 0) return "0 B";
4521
+ const k = 1024;
4522
+ const sizes = ["B", "KB", "MB", "GB"];
4523
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
4524
+ return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
4525
+ }
3733
4526
  }
3734
4527
 
3735
4528
  class FilesystemCache extends Cache {
@@ -4559,8 +5352,10 @@ class CachePlugin extends Plugin {
4559
5352
  config: {
4560
5353
  ttl: options.ttl,
4561
5354
  maxSize: options.maxSize,
5355
+ maxMemoryBytes: options.maxMemoryBytes,
5356
+ maxMemoryPercent: options.maxMemoryPercent,
4562
5357
  ...options.config
4563
- // Driver-specific config (can override ttl/maxSize)
5358
+ // Driver-specific config (can override ttl/maxSize/maxMemoryBytes/maxMemoryPercent)
4564
5359
  },
4565
5360
  // Resource filtering
4566
5361
  include: options.include || null,
@@ -4914,7 +5709,13 @@ class CachePlugin extends Plugin {
4914
5709
  async warmCache(resourceName, options = {}) {
4915
5710
  const resource = this.database.resources[resourceName];
4916
5711
  if (!resource) {
4917
- throw new Error(`Resource '${resourceName}' not found`);
5712
+ throw new CacheError("Resource not found for cache warming", {
5713
+ operation: "warmCache",
5714
+ driver: this.driver?.constructor.name,
5715
+ resourceName,
5716
+ availableResources: Object.keys(this.database.resources),
5717
+ suggestion: "Check resource name spelling or ensure resource has been created"
5718
+ });
4918
5719
  }
4919
5720
  const { includePartitions = true, sampleSize = 100 } = options;
4920
5721
  if (this.driver instanceof PartitionAwareFilesystemCache && resource.warmPartitionCache) {
@@ -7019,6 +7820,80 @@ async function getLastNMonths(resourceName, field, months = 12, options, fieldHa
7019
7820
  }
7020
7821
  return data;
7021
7822
  }
7823
+ async function getRawEvents(resourceName, field, options, fieldHandlers) {
7824
+ const resourceHandlers = fieldHandlers.get(resourceName);
7825
+ if (!resourceHandlers) {
7826
+ throw new Error(`No eventual consistency configured for resource: ${resourceName}`);
7827
+ }
7828
+ const handler = resourceHandlers.get(field);
7829
+ if (!handler) {
7830
+ throw new Error(`No eventual consistency configured for field: ${resourceName}.${field}`);
7831
+ }
7832
+ if (!handler.transactionResource) {
7833
+ throw new Error("Transaction resource not initialized");
7834
+ }
7835
+ const {
7836
+ recordId,
7837
+ startDate,
7838
+ endDate,
7839
+ cohortDate,
7840
+ cohortHour,
7841
+ cohortMonth,
7842
+ applied,
7843
+ operation,
7844
+ limit
7845
+ } = options;
7846
+ const query = {};
7847
+ if (recordId !== void 0) {
7848
+ query.originalId = recordId;
7849
+ }
7850
+ if (applied !== void 0) {
7851
+ query.applied = applied;
7852
+ }
7853
+ const [ok, err, allTransactions] = await tryFn(
7854
+ () => handler.transactionResource.query(query)
7855
+ );
7856
+ if (!ok || !allTransactions) {
7857
+ return [];
7858
+ }
7859
+ let filtered = allTransactions;
7860
+ if (operation !== void 0) {
7861
+ filtered = filtered.filter((t) => t.operation === operation);
7862
+ }
7863
+ if (cohortDate) {
7864
+ filtered = filtered.filter((t) => t.cohortDate === cohortDate);
7865
+ }
7866
+ if (cohortHour) {
7867
+ filtered = filtered.filter((t) => t.cohortHour === cohortHour);
7868
+ }
7869
+ if (cohortMonth) {
7870
+ filtered = filtered.filter((t) => t.cohortMonth === cohortMonth);
7871
+ }
7872
+ if (startDate && endDate) {
7873
+ const isHourly = startDate.length > 10;
7874
+ const cohortField = isHourly ? "cohortHour" : "cohortDate";
7875
+ filtered = filtered.filter(
7876
+ (t) => t[cohortField] && t[cohortField] >= startDate && t[cohortField] <= endDate
7877
+ );
7878
+ } else if (startDate) {
7879
+ const isHourly = startDate.length > 10;
7880
+ const cohortField = isHourly ? "cohortHour" : "cohortDate";
7881
+ filtered = filtered.filter((t) => t[cohortField] && t[cohortField] >= startDate);
7882
+ } else if (endDate) {
7883
+ const isHourly = endDate.length > 10;
7884
+ const cohortField = isHourly ? "cohortHour" : "cohortDate";
7885
+ filtered = filtered.filter((t) => t[cohortField] && t[cohortField] <= endDate);
7886
+ }
7887
+ filtered.sort((a, b) => {
7888
+ const aTime = new Date(a.timestamp || a.createdAt).getTime();
7889
+ const bTime = new Date(b.timestamp || b.createdAt).getTime();
7890
+ return bTime - aTime;
7891
+ });
7892
+ if (limit && limit > 0) {
7893
+ filtered = filtered.slice(0, limit);
7894
+ }
7895
+ return filtered;
7896
+ }
7022
7897
 
7023
7898
  function addHelperMethods(resource, plugin, config) {
7024
7899
  resource.set = async (id, field, value) => {
@@ -7776,6 +8651,214 @@ class EventualConsistencyPlugin extends Plugin {
7776
8651
  async getLastNMonths(resourceName, field, months = 12, options = {}) {
7777
8652
  return await getLastNMonths(resourceName, field, months, options, this.fieldHandlers);
7778
8653
  }
8654
+ /**
8655
+ * Get raw transaction events for custom aggregation
8656
+ *
8657
+ * This method provides direct access to the underlying transaction events,
8658
+ * allowing developers to perform custom aggregations beyond the pre-built analytics.
8659
+ * Useful for complex queries, custom metrics, or when you need the raw event data.
8660
+ *
8661
+ * @param {string} resourceName - Resource name
8662
+ * @param {string} field - Field name
8663
+ * @param {Object} options - Query options
8664
+ * @param {string} options.recordId - Filter by specific record ID
8665
+ * @param {string} options.startDate - Start date filter (YYYY-MM-DD or YYYY-MM-DDTHH)
8666
+ * @param {string} options.endDate - End date filter (YYYY-MM-DD or YYYY-MM-DDTHH)
8667
+ * @param {string} options.cohortDate - Filter by cohort date (YYYY-MM-DD)
8668
+ * @param {string} options.cohortHour - Filter by cohort hour (YYYY-MM-DDTHH)
8669
+ * @param {string} options.cohortMonth - Filter by cohort month (YYYY-MM)
8670
+ * @param {boolean} options.applied - Filter by applied status (true/false/undefined for both)
8671
+ * @param {string} options.operation - Filter by operation type ('add', 'sub', 'set')
8672
+ * @param {number} options.limit - Maximum number of events to return
8673
+ * @returns {Promise<Array>} Raw transaction events
8674
+ *
8675
+ * @example
8676
+ * // Get all events for a specific record
8677
+ * const events = await plugin.getRawEvents('wallets', 'balance', {
8678
+ * recordId: 'wallet1'
8679
+ * });
8680
+ *
8681
+ * @example
8682
+ * // Get events for a specific time range
8683
+ * const events = await plugin.getRawEvents('wallets', 'balance', {
8684
+ * startDate: '2025-10-01',
8685
+ * endDate: '2025-10-31'
8686
+ * });
8687
+ *
8688
+ * @example
8689
+ * // Get only pending (unapplied) transactions
8690
+ * const pending = await plugin.getRawEvents('wallets', 'balance', {
8691
+ * applied: false
8692
+ * });
8693
+ */
8694
+ async getRawEvents(resourceName, field, options = {}) {
8695
+ return await getRawEvents(resourceName, field, options, this.fieldHandlers);
8696
+ }
8697
+ /**
8698
+ * Get diagnostics information about the plugin state
8699
+ *
8700
+ * This method provides comprehensive diagnostic information about the EventualConsistencyPlugin,
8701
+ * including configured resources, field handlers, timers, and overall health status.
8702
+ * Useful for debugging initialization issues, configuration problems, or runtime errors.
8703
+ *
8704
+ * @param {Object} options - Diagnostic options
8705
+ * @param {string} options.resourceName - Optional: limit diagnostics to specific resource
8706
+ * @param {string} options.field - Optional: limit diagnostics to specific field
8707
+ * @param {boolean} options.includeStats - Include transaction statistics (default: false)
8708
+ * @returns {Promise<Object>} Diagnostic information
8709
+ *
8710
+ * @example
8711
+ * // Get overall plugin diagnostics
8712
+ * const diagnostics = await plugin.getDiagnostics();
8713
+ * console.log(diagnostics);
8714
+ *
8715
+ * @example
8716
+ * // Get diagnostics for specific resource/field with stats
8717
+ * const diagnostics = await plugin.getDiagnostics({
8718
+ * resourceName: 'wallets',
8719
+ * field: 'balance',
8720
+ * includeStats: true
8721
+ * });
8722
+ */
8723
+ async getDiagnostics(options = {}) {
8724
+ const { resourceName, field, includeStats = false } = options;
8725
+ const diagnostics = {
8726
+ plugin: {
8727
+ name: "EventualConsistencyPlugin",
8728
+ initialized: this.database !== null && this.database !== void 0,
8729
+ verbose: this.config.verbose || false,
8730
+ timezone: this.config.cohort?.timezone || "UTC",
8731
+ consolidation: {
8732
+ mode: this.config.consolidation?.mode || "timer",
8733
+ interval: this.config.consolidation?.interval || 6e4,
8734
+ batchSize: this.config.consolidation?.batchSize || 100
8735
+ },
8736
+ garbageCollection: {
8737
+ enabled: this.config.garbageCollection?.enabled !== false,
8738
+ retentionDays: this.config.garbageCollection?.retentionDays || 30,
8739
+ interval: this.config.garbageCollection?.interval || 36e5
8740
+ }
8741
+ },
8742
+ resources: [],
8743
+ errors: [],
8744
+ warnings: []
8745
+ };
8746
+ for (const [resName, resourceHandlers] of this.fieldHandlers.entries()) {
8747
+ if (resourceName && resName !== resourceName) {
8748
+ continue;
8749
+ }
8750
+ const resourceDiag = {
8751
+ name: resName,
8752
+ fields: []
8753
+ };
8754
+ for (const [fieldName, handler] of resourceHandlers.entries()) {
8755
+ if (field && fieldName !== field) {
8756
+ continue;
8757
+ }
8758
+ const fieldDiag = {
8759
+ name: fieldName,
8760
+ type: handler.type || "counter",
8761
+ analyticsEnabled: handler.analyticsResource !== null && handler.analyticsResource !== void 0,
8762
+ resources: {
8763
+ transaction: handler.transactionResource?.name || null,
8764
+ target: handler.targetResource?.name || null,
8765
+ analytics: handler.analyticsResource?.name || null
8766
+ },
8767
+ timers: {
8768
+ consolidation: handler.consolidationTimer !== null && handler.consolidationTimer !== void 0,
8769
+ garbageCollection: handler.garbageCollectionTimer !== null && handler.garbageCollectionTimer !== void 0
8770
+ }
8771
+ };
8772
+ if (!handler.transactionResource) {
8773
+ diagnostics.errors.push({
8774
+ resource: resName,
8775
+ field: fieldName,
8776
+ issue: "Missing transaction resource",
8777
+ suggestion: "Ensure plugin is installed and resources are created after plugin installation"
8778
+ });
8779
+ }
8780
+ if (!handler.targetResource) {
8781
+ diagnostics.warnings.push({
8782
+ resource: resName,
8783
+ field: fieldName,
8784
+ issue: "Missing target resource",
8785
+ suggestion: "Target resource may not have been created yet"
8786
+ });
8787
+ }
8788
+ if (handler.analyticsResource && !handler.analyticsResource.name) {
8789
+ diagnostics.errors.push({
8790
+ resource: resName,
8791
+ field: fieldName,
8792
+ issue: "Invalid analytics resource",
8793
+ suggestion: "Analytics resource exists but has no name - possible initialization failure"
8794
+ });
8795
+ }
8796
+ if (includeStats && handler.transactionResource) {
8797
+ try {
8798
+ const [okPending, errPending, pendingTxns] = await handler.transactionResource.query({ applied: false }).catch(() => [false, null, []]);
8799
+ const [okApplied, errApplied, appliedTxns] = await handler.transactionResource.query({ applied: true }).catch(() => [false, null, []]);
8800
+ fieldDiag.stats = {
8801
+ pendingTransactions: okPending ? pendingTxns?.length || 0 : "error",
8802
+ appliedTransactions: okApplied ? appliedTxns?.length || 0 : "error",
8803
+ totalTransactions: okPending && okApplied ? (pendingTxns?.length || 0) + (appliedTxns?.length || 0) : "error"
8804
+ };
8805
+ if (handler.analyticsResource) {
8806
+ const [okAnalytics, errAnalytics, analyticsRecords] = await handler.analyticsResource.list().catch(() => [false, null, []]);
8807
+ fieldDiag.stats.analyticsRecords = okAnalytics ? analyticsRecords?.length || 0 : "error";
8808
+ }
8809
+ } catch (error) {
8810
+ diagnostics.warnings.push({
8811
+ resource: resName,
8812
+ field: fieldName,
8813
+ issue: "Failed to fetch statistics",
8814
+ error: error.message
8815
+ });
8816
+ }
8817
+ }
8818
+ resourceDiag.fields.push(fieldDiag);
8819
+ }
8820
+ if (resourceDiag.fields.length > 0) {
8821
+ diagnostics.resources.push(resourceDiag);
8822
+ }
8823
+ }
8824
+ diagnostics.health = {
8825
+ status: diagnostics.errors.length === 0 ? diagnostics.warnings.length === 0 ? "healthy" : "warning" : "error",
8826
+ totalResources: diagnostics.resources.length,
8827
+ totalFields: diagnostics.resources.reduce((sum, r) => sum + r.fields.length, 0),
8828
+ errorCount: diagnostics.errors.length,
8829
+ warningCount: diagnostics.warnings.length
8830
+ };
8831
+ return diagnostics;
8832
+ }
8833
+ }
8834
+
8835
+ class FulltextError extends S3dbError {
8836
+ constructor(message, details = {}) {
8837
+ const { resourceName, query, operation = "unknown", ...rest } = details;
8838
+ let description = details.description;
8839
+ if (!description) {
8840
+ description = `
8841
+ Fulltext Search Operation Error
8842
+
8843
+ Operation: ${operation}
8844
+ ${resourceName ? `Resource: ${resourceName}` : ""}
8845
+ ${query ? `Query: ${query}` : ""}
8846
+
8847
+ Common causes:
8848
+ 1. Resource not indexed for fulltext search
8849
+ 2. Invalid query syntax
8850
+ 3. Index not built yet
8851
+ 4. Search configuration missing
8852
+ 5. Field not indexed
8853
+
8854
+ Solution:
8855
+ Ensure resource is configured for fulltext search and index is built.
8856
+
8857
+ Docs: https://github.com/forattini-dev/s3db.js/blob/main/docs/plugins/fulltext.md
8858
+ `.trim();
8859
+ }
8860
+ super(message, { ...rest, resourceName, query, operation, description });
8861
+ }
7779
8862
  }
7780
8863
 
7781
8864
  class FullTextPlugin extends Plugin {
@@ -8084,7 +9167,13 @@ class FullTextPlugin extends Plugin {
8084
9167
  }
8085
9168
  const resource = this.database.resources[resourceName];
8086
9169
  if (!resource) {
8087
- throw new Error(`Resource '${resourceName}' not found`);
9170
+ throw new FulltextError(`Resource '${resourceName}' not found`, {
9171
+ operation: "searchRecords",
9172
+ resourceName,
9173
+ query,
9174
+ availableResources: Object.keys(this.database.resources),
9175
+ suggestion: "Check resource name or ensure resource is created before searching"
9176
+ });
8088
9177
  }
8089
9178
  const recordIds = searchResults.map((result2) => result2.recordId);
8090
9179
  const records = await resource.getMany(recordIds);
@@ -8101,7 +9190,12 @@ class FullTextPlugin extends Plugin {
8101
9190
  async rebuildIndex(resourceName) {
8102
9191
  const resource = this.database.resources[resourceName];
8103
9192
  if (!resource) {
8104
- throw new Error(`Resource '${resourceName}' not found`);
9193
+ throw new FulltextError(`Resource '${resourceName}' not found`, {
9194
+ operation: "rebuildIndex",
9195
+ resourceName,
9196
+ availableResources: Object.keys(this.database.resources),
9197
+ suggestion: "Check resource name or ensure resource is created before rebuilding index"
9198
+ });
8105
9199
  }
8106
9200
  for (const [key] of this.indexes.entries()) {
8107
9201
  if (key.startsWith(`${resourceName}:`)) {
@@ -8886,6 +9980,35 @@ function createConsumer(driver, config) {
8886
9980
  return new ConsumerClass(config);
8887
9981
  }
8888
9982
 
9983
+ class QueueError extends S3dbError {
9984
+ constructor(message, details = {}) {
9985
+ const { queueName, operation = "unknown", messageId, ...rest } = details;
9986
+ let description = details.description;
9987
+ if (!description) {
9988
+ description = `
9989
+ Queue Operation Error
9990
+
9991
+ Operation: ${operation}
9992
+ ${queueName ? `Queue: ${queueName}` : ""}
9993
+ ${messageId ? `Message ID: ${messageId}` : ""}
9994
+
9995
+ Common causes:
9996
+ 1. Queue not properly configured
9997
+ 2. Message handler not registered
9998
+ 3. Queue resource not found
9999
+ 4. SQS/RabbitMQ connection failed
10000
+ 5. Message processing timeout
10001
+
10002
+ Solution:
10003
+ Check queue configuration and message handler registration.
10004
+
10005
+ Docs: https://github.com/forattini-dev/s3db.js/blob/main/docs/plugins/queue.md
10006
+ `.trim();
10007
+ }
10008
+ super(message, { ...rest, queueName, operation, messageId, description });
10009
+ }
10010
+ }
10011
+
8889
10012
  class QueueConsumerPlugin extends Plugin {
8890
10013
  constructor(options = {}) {
8891
10014
  super(options);
@@ -8946,13 +10069,32 @@ class QueueConsumerPlugin extends Plugin {
8946
10069
  let action = body.action || msg.action;
8947
10070
  let data = body.data || msg.data;
8948
10071
  if (!resource) {
8949
- throw new Error("QueueConsumerPlugin: resource not found in message");
10072
+ throw new QueueError("Resource not found in message", {
10073
+ operation: "handleMessage",
10074
+ queueName: configuredResource,
10075
+ messageBody: body,
10076
+ suggestion: 'Ensure message includes a "resource" field specifying the target resource name'
10077
+ });
8950
10078
  }
8951
10079
  if (!action) {
8952
- throw new Error("QueueConsumerPlugin: action not found in message");
10080
+ throw new QueueError("Action not found in message", {
10081
+ operation: "handleMessage",
10082
+ queueName: configuredResource,
10083
+ resource,
10084
+ messageBody: body,
10085
+ suggestion: 'Ensure message includes an "action" field (insert, update, or delete)'
10086
+ });
8953
10087
  }
8954
10088
  const resourceObj = this.database.resources[resource];
8955
- if (!resourceObj) throw new Error(`QueueConsumerPlugin: resource '${resource}' not found`);
10089
+ if (!resourceObj) {
10090
+ throw new QueueError(`Resource '${resource}' not found`, {
10091
+ operation: "handleMessage",
10092
+ queueName: configuredResource,
10093
+ resource,
10094
+ availableResources: Object.keys(this.database.resources),
10095
+ suggestion: "Check resource name or ensure resource is created before consuming messages"
10096
+ });
10097
+ }
8956
10098
  let result;
8957
10099
  const [ok, err, res] = await tryFn(async () => {
8958
10100
  if (action === "insert") {
@@ -8963,7 +10105,14 @@ class QueueConsumerPlugin extends Plugin {
8963
10105
  } else if (action === "delete") {
8964
10106
  result = await resourceObj.delete(data.id);
8965
10107
  } else {
8966
- throw new Error(`QueueConsumerPlugin: unsupported action '${action}'`);
10108
+ throw new QueueError(`Unsupported action '${action}'`, {
10109
+ operation: "handleMessage",
10110
+ queueName: configuredResource,
10111
+ resource,
10112
+ action,
10113
+ supportedActions: ["insert", "update", "delete"],
10114
+ suggestion: "Use one of the supported actions: insert, update, or delete"
10115
+ });
8967
10116
  }
8968
10117
  return result;
8969
10118
  });
@@ -8976,6 +10125,35 @@ class QueueConsumerPlugin extends Plugin {
8976
10125
  }
8977
10126
  }
8978
10127
 
10128
+ class ReplicationError extends S3dbError {
10129
+ constructor(message, details = {}) {
10130
+ const { replicatorClass = "unknown", operation = "unknown", resourceName, ...rest } = details;
10131
+ let description = details.description;
10132
+ if (!description) {
10133
+ description = `
10134
+ Replication Operation Error
10135
+
10136
+ Replicator: ${replicatorClass}
10137
+ Operation: ${operation}
10138
+ ${resourceName ? `Resource: ${resourceName}` : ""}
10139
+
10140
+ Common causes:
10141
+ 1. Invalid replicator configuration
10142
+ 2. Target system not accessible
10143
+ 3. Resource not configured for replication
10144
+ 4. Invalid operation type
10145
+ 5. Transformation function errors
10146
+
10147
+ Solution:
10148
+ Check replicator configuration and ensure target system is accessible.
10149
+
10150
+ Docs: https://github.com/forattini-dev/s3db.js/blob/main/docs/plugins/replicator.md
10151
+ `.trim();
10152
+ }
10153
+ super(message, { ...rest, replicatorClass, operation, resourceName, description });
10154
+ }
10155
+ }
10156
+
8979
10157
  class BaseReplicator extends EventEmitter {
8980
10158
  constructor(config = {}) {
8981
10159
  super();
@@ -9001,7 +10179,12 @@ class BaseReplicator extends EventEmitter {
9001
10179
  * @returns {Promise<Object>} replicator result
9002
10180
  */
9003
10181
  async replicate(resourceName, operation, data, id) {
9004
- throw new Error(`replicate() method must be implemented by ${this.name}`);
10182
+ throw new ReplicationError("replicate() method must be implemented by subclass", {
10183
+ operation: "replicate",
10184
+ replicatorClass: this.name,
10185
+ resourceName,
10186
+ suggestion: "Extend BaseReplicator and implement the replicate() method"
10187
+ });
9005
10188
  }
9006
10189
  /**
9007
10190
  * Replicate multiple records in batch
@@ -9010,14 +10193,24 @@ class BaseReplicator extends EventEmitter {
9010
10193
  * @returns {Promise<Object>} Batch replicator result
9011
10194
  */
9012
10195
  async replicateBatch(resourceName, records) {
9013
- throw new Error(`replicateBatch() method must be implemented by ${this.name}`);
10196
+ throw new ReplicationError("replicateBatch() method must be implemented by subclass", {
10197
+ operation: "replicateBatch",
10198
+ replicatorClass: this.name,
10199
+ resourceName,
10200
+ batchSize: records?.length,
10201
+ suggestion: "Extend BaseReplicator and implement the replicateBatch() method"
10202
+ });
9014
10203
  }
9015
10204
  /**
9016
10205
  * Test the connection to the target
9017
10206
  * @returns {Promise<boolean>} True if connection is successful
9018
10207
  */
9019
10208
  async testConnection() {
9020
- throw new Error(`testConnection() method must be implemented by ${this.name}`);
10209
+ throw new ReplicationError("testConnection() method must be implemented by subclass", {
10210
+ operation: "testConnection",
10211
+ replicatorClass: this.name,
10212
+ suggestion: "Extend BaseReplicator and implement the testConnection() method"
10213
+ });
9021
10214
  }
9022
10215
  /**
9023
10216
  * Get replicator status and statistics
@@ -10189,7 +11382,17 @@ class Client extends EventEmitter {
10189
11382
  });
10190
11383
  this.emit("moveAllObjects", { results, errors }, { prefixFrom, prefixTo });
10191
11384
  if (errors.length > 0) {
10192
- throw new Error("Some objects could not be moved");
11385
+ throw new UnknownError("Some objects could not be moved", {
11386
+ bucket: this.config.bucket,
11387
+ operation: "moveAllObjects",
11388
+ prefixFrom,
11389
+ prefixTo,
11390
+ totalKeys: keys.length,
11391
+ failedCount: errors.length,
11392
+ successCount: results.length,
11393
+ errors: errors.map((e) => ({ message: e.message, raw: e.raw })),
11394
+ suggestion: "Check S3 permissions and retry failed objects individually"
11395
+ });
10193
11396
  }
10194
11397
  return results;
10195
11398
  }
@@ -10903,7 +12106,14 @@ async function handleInsert$4({ resource, data, mappedData, originalData }) {
10903
12106
  }
10904
12107
  });
10905
12108
  if (totalSize > effectiveLimit) {
10906
- throw new Error(`S3 metadata size exceeds 2KB limit. Current size: ${totalSize} bytes, effective limit: ${effectiveLimit} bytes, absolute limit: ${S3_METADATA_LIMIT_BYTES} bytes`);
12109
+ throw new MetadataLimitError("Metadata size exceeds 2KB limit on insert", {
12110
+ totalSize,
12111
+ effectiveLimit,
12112
+ absoluteLimit: S3_METADATA_LIMIT_BYTES,
12113
+ excess: totalSize - effectiveLimit,
12114
+ resourceName: resource.name,
12115
+ operation: "insert"
12116
+ });
10907
12117
  }
10908
12118
  return { mappedData, body: "" };
10909
12119
  }
@@ -10918,7 +12128,15 @@ async function handleUpdate$4({ resource, id, data, mappedData, originalData })
10918
12128
  }
10919
12129
  });
10920
12130
  if (totalSize > effectiveLimit) {
10921
- throw new Error(`S3 metadata size exceeds 2KB limit. Current size: ${totalSize} bytes, effective limit: ${effectiveLimit} bytes, absolute limit: ${S3_METADATA_LIMIT_BYTES} bytes`);
12131
+ throw new MetadataLimitError("Metadata size exceeds 2KB limit on update", {
12132
+ totalSize,
12133
+ effectiveLimit,
12134
+ absoluteLimit: S3_METADATA_LIMIT_BYTES,
12135
+ excess: totalSize - effectiveLimit,
12136
+ resourceName: resource.name,
12137
+ operation: "update",
12138
+ id
12139
+ });
10922
12140
  }
10923
12141
  return { mappedData, body: JSON.stringify(mappedData) };
10924
12142
  }
@@ -10933,7 +12151,15 @@ async function handleUpsert$4({ resource, id, data, mappedData }) {
10933
12151
  }
10934
12152
  });
10935
12153
  if (totalSize > effectiveLimit) {
10936
- throw new Error(`S3 metadata size exceeds 2KB limit. Current size: ${totalSize} bytes, effective limit: ${effectiveLimit} bytes, absolute limit: ${S3_METADATA_LIMIT_BYTES} bytes`);
12154
+ throw new MetadataLimitError("Metadata size exceeds 2KB limit on upsert", {
12155
+ totalSize,
12156
+ effectiveLimit,
12157
+ absoluteLimit: S3_METADATA_LIMIT_BYTES,
12158
+ excess: totalSize - effectiveLimit,
12159
+ resourceName: resource.name,
12160
+ operation: "upsert",
12161
+ id
12162
+ });
10937
12163
  }
10938
12164
  return { mappedData, body: "" };
10939
12165
  }
@@ -11275,7 +12501,11 @@ const behaviors = {
11275
12501
  function getBehavior(behaviorName) {
11276
12502
  const behavior = behaviors[behaviorName];
11277
12503
  if (!behavior) {
11278
- throw new Error(`Unknown behavior: ${behaviorName}. Available behaviors: ${Object.keys(behaviors).join(", ")}`);
12504
+ throw new BehaviorError(`Unknown behavior: ${behaviorName}`, {
12505
+ behavior: behaviorName,
12506
+ availableBehaviors: Object.keys(behaviors),
12507
+ operation: "getBehavior"
12508
+ });
11279
12509
  }
11280
12510
  return behavior;
11281
12511
  }
@@ -11397,6 +12627,7 @@ ${errorDetails}`,
11397
12627
  idGenerator: customIdGenerator,
11398
12628
  idSize = 22,
11399
12629
  versioningEnabled = false,
12630
+ strictValidation = true,
11400
12631
  events = {},
11401
12632
  asyncEvents = true,
11402
12633
  asyncPartitions = true,
@@ -11410,6 +12641,7 @@ ${errorDetails}`,
11410
12641
  this.parallelism = parallelism;
11411
12642
  this.passphrase = passphrase ?? "secret";
11412
12643
  this.versioningEnabled = versioningEnabled;
12644
+ this.strictValidation = strictValidation;
11413
12645
  this.setAsyncMode(asyncEvents);
11414
12646
  this.idGenerator = this.configureIdGenerator(customIdGenerator, idSize);
11415
12647
  if (typeof customIdGenerator === "number" && customIdGenerator > 0) {
@@ -11657,9 +12889,12 @@ ${errorDetails}`,
11657
12889
  }
11658
12890
  /**
11659
12891
  * Validate that all partition fields exist in current resource attributes
11660
- * @throws {Error} If partition fields don't exist in current schema
12892
+ * @throws {Error} If partition fields don't exist in current schema (only when strictValidation is true)
11661
12893
  */
11662
12894
  validatePartitions() {
12895
+ if (!this.strictValidation) {
12896
+ return;
12897
+ }
11663
12898
  if (!this.config.partitions) {
11664
12899
  return;
11665
12900
  }
@@ -13794,7 +15029,7 @@ class Database extends EventEmitter {
13794
15029
  this.id = idGenerator(7);
13795
15030
  this.version = "1";
13796
15031
  this.s3dbVersion = (() => {
13797
- const [ok, err, version] = tryFn(() => true ? "11.2.2" : "latest");
15032
+ const [ok, err, version] = tryFn(() => true ? "11.2.4" : "latest");
13798
15033
  return ok ? version : "latest";
13799
15034
  })();
13800
15035
  this.resources = {};
@@ -13809,6 +15044,7 @@ class Database extends EventEmitter {
13809
15044
  this.passphrase = options.passphrase || "secret";
13810
15045
  this.versioningEnabled = options.versioningEnabled || false;
13811
15046
  this.persistHooks = options.persistHooks || false;
15047
+ this.strictValidation = options.strictValidation !== false;
13812
15048
  this._initHooks();
13813
15049
  let connectionString = options.connectionString;
13814
15050
  if (!connectionString && (options.bucket || options.accessKeyId || options.secretAccessKey)) {
@@ -13930,6 +15166,7 @@ class Database extends EventEmitter {
13930
15166
  asyncEvents: versionData.asyncEvents !== void 0 ? versionData.asyncEvents : true,
13931
15167
  hooks: this.persistHooks ? this._deserializeHooks(versionData.hooks || {}) : versionData.hooks || {},
13932
15168
  versioningEnabled: this.versioningEnabled,
15169
+ strictValidation: this.strictValidation,
13933
15170
  map: versionData.map,
13934
15171
  idGenerator: restoredIdGenerator,
13935
15172
  idSize: restoredIdSize
@@ -14137,7 +15374,12 @@ class Database extends EventEmitter {
14137
15374
  const pluginName = name.toLowerCase().replace("plugin", "");
14138
15375
  const plugin = this.plugins[pluginName] || this.pluginRegistry[pluginName];
14139
15376
  if (!plugin) {
14140
- throw new Error(`Plugin '${name}' not found`);
15377
+ throw new DatabaseError(`Plugin '${name}' not found`, {
15378
+ operation: "uninstallPlugin",
15379
+ pluginName: name,
15380
+ availablePlugins: Object.keys(this.pluginRegistry),
15381
+ suggestion: "Check plugin name or list available plugins using Object.keys(db.pluginRegistry)"
15382
+ });
14141
15383
  }
14142
15384
  if (plugin.stop) {
14143
15385
  await plugin.stop();
@@ -14591,6 +15833,7 @@ class Database extends EventEmitter {
14591
15833
  autoDecrypt: config.autoDecrypt !== void 0 ? config.autoDecrypt : true,
14592
15834
  hooks: hooks || {},
14593
15835
  versioningEnabled: this.versioningEnabled,
15836
+ strictValidation: this.strictValidation,
14594
15837
  map: config.map,
14595
15838
  idGenerator: config.idGenerator,
14596
15839
  idSize: config.idSize,
@@ -14769,10 +16012,20 @@ class Database extends EventEmitter {
14769
16012
  addHook(event, fn) {
14770
16013
  if (!this._hooks) this._initHooks();
14771
16014
  if (!this._hooks.has(event)) {
14772
- throw new Error(`Unknown hook event: ${event}. Available events: ${this._hookEvents.join(", ")}`);
16015
+ throw new DatabaseError(`Unknown hook event: ${event}`, {
16016
+ operation: "addHook",
16017
+ invalidEvent: event,
16018
+ availableEvents: this._hookEvents,
16019
+ suggestion: `Use one of the available hook events: ${this._hookEvents.join(", ")}`
16020
+ });
14773
16021
  }
14774
16022
  if (typeof fn !== "function") {
14775
- throw new Error("Hook function must be a function");
16023
+ throw new DatabaseError("Hook function must be a function", {
16024
+ operation: "addHook",
16025
+ event,
16026
+ receivedType: typeof fn,
16027
+ suggestion: "Provide a function that will be called when the hook event occurs"
16028
+ });
14776
16029
  }
14777
16030
  this._hooks.get(event).push(fn);
14778
16031
  }
@@ -14910,7 +16163,11 @@ class S3dbReplicator extends BaseReplicator {
14910
16163
  this.targetDatabase = new S3db(targetConfig);
14911
16164
  await this.targetDatabase.connect();
14912
16165
  } else {
14913
- throw new Error("S3dbReplicator: No client or connectionString provided");
16166
+ throw new ReplicationError("S3dbReplicator requires client or connectionString", {
16167
+ operation: "initialize",
16168
+ replicatorClass: "S3dbReplicator",
16169
+ suggestion: 'Provide either a client instance or connectionString in config: { client: db } or { connectionString: "s3://..." }'
16170
+ });
14914
16171
  }
14915
16172
  this.emit("connected", {
14916
16173
  replicator: this.name,
@@ -14941,7 +16198,13 @@ class S3dbReplicator extends BaseReplicator {
14941
16198
  const normResource = normalizeResourceName$1(resource);
14942
16199
  const entry = this.resourcesMap[normResource];
14943
16200
  if (!entry) {
14944
- throw new Error(`[S3dbReplicator] Resource not configured: ${resource}`);
16201
+ throw new ReplicationError("Resource not configured for replication", {
16202
+ operation: "replicate",
16203
+ replicatorClass: "S3dbReplicator",
16204
+ resourceName: resource,
16205
+ configuredResources: Object.keys(this.resourcesMap),
16206
+ suggestion: 'Add resource to replicator resources map: { resources: { [resourceName]: "destination" } }'
16207
+ });
14945
16208
  }
14946
16209
  if (Array.isArray(entry)) {
14947
16210
  const results = [];
@@ -15009,7 +16272,14 @@ class S3dbReplicator extends BaseReplicator {
15009
16272
  } else if (operation === "delete") {
15010
16273
  result = await destResourceObj.delete(recordId);
15011
16274
  } else {
15012
- throw new Error(`Invalid operation: ${operation}. Supported operations are: insert, update, delete`);
16275
+ throw new ReplicationError(`Invalid replication operation: ${operation}`, {
16276
+ operation: "replicate",
16277
+ replicatorClass: "S3dbReplicator",
16278
+ invalidOperation: operation,
16279
+ supportedOperations: ["insert", "update", "delete"],
16280
+ resourceName: sourceResource,
16281
+ suggestion: "Use one of the supported operations: insert, update, delete"
16282
+ });
15013
16283
  }
15014
16284
  return result;
15015
16285
  }
@@ -15077,7 +16347,13 @@ class S3dbReplicator extends BaseReplicator {
15077
16347
  const norm = normalizeResourceName$1(resource);
15078
16348
  const found = available.find((r) => normalizeResourceName$1(r) === norm);
15079
16349
  if (!found) {
15080
- throw new Error(`[S3dbReplicator] Destination resource not found: ${resource}. Available: ${available.join(", ")}`);
16350
+ throw new ReplicationError("Destination resource not found in target database", {
16351
+ operation: "_getDestResourceObj",
16352
+ replicatorClass: "S3dbReplicator",
16353
+ destinationResource: resource,
16354
+ availableResources: available,
16355
+ suggestion: "Create the resource in target database or check resource name spelling"
16356
+ });
15081
16357
  }
15082
16358
  return db.resources[found];
15083
16359
  }
@@ -15126,7 +16402,13 @@ class S3dbReplicator extends BaseReplicator {
15126
16402
  }
15127
16403
  async testConnection() {
15128
16404
  const [ok, err] = await tryFn(async () => {
15129
- if (!this.targetDatabase) throw new Error("No target database configured");
16405
+ if (!this.targetDatabase) {
16406
+ throw new ReplicationError("No target database configured for connection test", {
16407
+ operation: "testConnection",
16408
+ replicatorClass: "S3dbReplicator",
16409
+ suggestion: "Initialize replicator with client or connectionString before testing connection"
16410
+ });
16411
+ }
15130
16412
  if (typeof this.targetDatabase.connect === "function") {
15131
16413
  await this.targetDatabase.connect();
15132
16414
  }
@@ -15513,7 +16795,12 @@ const REPLICATOR_DRIVERS = {
15513
16795
  function createReplicator(driver, config = {}, resources = [], client = null) {
15514
16796
  const ReplicatorClass = REPLICATOR_DRIVERS[driver];
15515
16797
  if (!ReplicatorClass) {
15516
- throw new Error(`Unknown replicator driver: ${driver}. Available drivers: ${Object.keys(REPLICATOR_DRIVERS).join(", ")}`);
16798
+ throw new ReplicationError(`Unknown replicator driver: ${driver}`, {
16799
+ operation: "createReplicator",
16800
+ driver,
16801
+ availableDrivers: Object.keys(REPLICATOR_DRIVERS),
16802
+ suggestion: `Use one of the available drivers: ${Object.keys(REPLICATOR_DRIVERS).join(", ")}`
16803
+ });
15517
16804
  }
15518
16805
  return new ReplicatorClass(config, resources, client);
15519
16806
  }
@@ -15525,12 +16812,40 @@ class ReplicatorPlugin extends Plugin {
15525
16812
  constructor(options = {}) {
15526
16813
  super();
15527
16814
  if (!options.replicators || !Array.isArray(options.replicators)) {
15528
- throw new Error("ReplicatorPlugin: replicators array is required");
16815
+ throw new ReplicationError("ReplicatorPlugin requires replicators array", {
16816
+ operation: "constructor",
16817
+ pluginName: "ReplicatorPlugin",
16818
+ providedOptions: Object.keys(options),
16819
+ suggestion: 'Provide replicators array: new ReplicatorPlugin({ replicators: [{ driver: "s3db", resources: [...] }] })'
16820
+ });
15529
16821
  }
15530
16822
  for (const rep of options.replicators) {
15531
- if (!rep.driver) throw new Error("ReplicatorPlugin: each replicator must have a driver");
15532
- if (!rep.resources || typeof rep.resources !== "object") throw new Error("ReplicatorPlugin: each replicator must have resources config");
15533
- if (Object.keys(rep.resources).length === 0) throw new Error("ReplicatorPlugin: each replicator must have at least one resource configured");
16823
+ if (!rep.driver) {
16824
+ throw new ReplicationError("Each replicator must have a driver", {
16825
+ operation: "constructor",
16826
+ pluginName: "ReplicatorPlugin",
16827
+ replicatorConfig: rep,
16828
+ suggestion: 'Each replicator entry must specify a driver: { driver: "s3db", resources: {...} }'
16829
+ });
16830
+ }
16831
+ if (!rep.resources || typeof rep.resources !== "object") {
16832
+ throw new ReplicationError("Each replicator must have resources config", {
16833
+ operation: "constructor",
16834
+ pluginName: "ReplicatorPlugin",
16835
+ driver: rep.driver,
16836
+ replicatorConfig: rep,
16837
+ suggestion: 'Provide resources as object or array: { driver: "s3db", resources: ["users"] } or { resources: { users: "people" } }'
16838
+ });
16839
+ }
16840
+ if (Object.keys(rep.resources).length === 0) {
16841
+ throw new ReplicationError("Each replicator must have at least one resource configured", {
16842
+ operation: "constructor",
16843
+ pluginName: "ReplicatorPlugin",
16844
+ driver: rep.driver,
16845
+ replicatorConfig: rep,
16846
+ suggestion: 'Add at least one resource to replicate: { driver: "s3db", resources: ["users"] }'
16847
+ });
16848
+ }
15534
16849
  }
15535
16850
  this.config = {
15536
16851
  replicators: options.replicators || [],
@@ -15956,7 +17271,13 @@ class ReplicatorPlugin extends Plugin {
15956
17271
  async syncAllData(replicatorId) {
15957
17272
  const replicator = this.replicators.find((r) => r.id === replicatorId);
15958
17273
  if (!replicator) {
15959
- throw new Error(`Replicator not found: ${replicatorId}`);
17274
+ throw new ReplicationError("Replicator not found", {
17275
+ operation: "syncAllData",
17276
+ pluginName: "ReplicatorPlugin",
17277
+ replicatorId,
17278
+ availableReplicators: this.replicators.map((r) => r.id),
17279
+ suggestion: "Check replicator ID or use getReplicatorStats() to list available replicators"
17280
+ });
15960
17281
  }
15961
17282
  this.stats.lastSync = (/* @__PURE__ */ new Date()).toISOString();
15962
17283
  for (const resourceName in this.database.resources) {
@@ -16486,6 +17807,35 @@ class S3QueuePlugin extends Plugin {
16486
17807
  }
16487
17808
  }
16488
17809
 
17810
+ class SchedulerError extends S3dbError {
17811
+ constructor(message, details = {}) {
17812
+ const { taskId, operation = "unknown", cronExpression, ...rest } = details;
17813
+ let description = details.description;
17814
+ if (!description) {
17815
+ description = `
17816
+ Scheduler Operation Error
17817
+
17818
+ Operation: ${operation}
17819
+ ${taskId ? `Task ID: ${taskId}` : ""}
17820
+ ${cronExpression ? `Cron: ${cronExpression}` : ""}
17821
+
17822
+ Common causes:
17823
+ 1. Invalid cron expression format
17824
+ 2. Task not found or already exists
17825
+ 3. Scheduler not properly initialized
17826
+ 4. Job execution failure
17827
+ 5. Resource conflicts
17828
+
17829
+ Solution:
17830
+ Check task configuration and ensure scheduler is properly initialized.
17831
+
17832
+ Docs: https://github.com/forattini-dev/s3db.js/blob/main/docs/plugins/scheduler.md
17833
+ `.trim();
17834
+ }
17835
+ super(message, { ...rest, taskId, operation, cronExpression, description });
17836
+ }
17837
+ }
17838
+
16489
17839
  class SchedulerPlugin extends Plugin {
16490
17840
  constructor(options = {}) {
16491
17841
  super();
@@ -16519,17 +17869,36 @@ class SchedulerPlugin extends Plugin {
16519
17869
  }
16520
17870
  _validateConfiguration() {
16521
17871
  if (Object.keys(this.config.jobs).length === 0) {
16522
- throw new Error("SchedulerPlugin: At least one job must be defined");
17872
+ throw new SchedulerError("At least one job must be defined", {
17873
+ operation: "validateConfiguration",
17874
+ jobCount: 0,
17875
+ suggestion: 'Provide at least one job in the jobs configuration: { jobs: { myJob: { schedule: "* * * * *", action: async () => {...} } } }'
17876
+ });
16523
17877
  }
16524
17878
  for (const [jobName, job] of Object.entries(this.config.jobs)) {
16525
17879
  if (!job.schedule) {
16526
- throw new Error(`SchedulerPlugin: Job '${jobName}' must have a schedule`);
17880
+ throw new SchedulerError(`Job '${jobName}' must have a schedule`, {
17881
+ operation: "validateConfiguration",
17882
+ taskId: jobName,
17883
+ providedConfig: Object.keys(job),
17884
+ suggestion: 'Add a schedule property with a valid cron expression: { schedule: "0 * * * *", action: async () => {...} }'
17885
+ });
16527
17886
  }
16528
17887
  if (!job.action || typeof job.action !== "function") {
16529
- throw new Error(`SchedulerPlugin: Job '${jobName}' must have an action function`);
17888
+ throw new SchedulerError(`Job '${jobName}' must have an action function`, {
17889
+ operation: "validateConfiguration",
17890
+ taskId: jobName,
17891
+ actionType: typeof job.action,
17892
+ suggestion: 'Provide an action function: { schedule: "...", action: async (db, ctx) => {...} }'
17893
+ });
16530
17894
  }
16531
17895
  if (!this._isValidCronExpression(job.schedule)) {
16532
- throw new Error(`SchedulerPlugin: Job '${jobName}' has invalid cron expression: ${job.schedule}`);
17896
+ throw new SchedulerError(`Job '${jobName}' has invalid cron expression`, {
17897
+ operation: "validateConfiguration",
17898
+ taskId: jobName,
17899
+ cronExpression: job.schedule,
17900
+ suggestion: "Use valid cron format (5 fields: minute hour day month weekday) or shortcuts (@hourly, @daily, @weekly, @monthly, @yearly)"
17901
+ });
16533
17902
  }
16534
17903
  }
16535
17904
  }
@@ -16827,10 +18196,20 @@ class SchedulerPlugin extends Plugin {
16827
18196
  async runJob(jobName, context = {}) {
16828
18197
  const job = this.jobs.get(jobName);
16829
18198
  if (!job) {
16830
- throw new Error(`Job '${jobName}' not found`);
18199
+ throw new SchedulerError(`Job '${jobName}' not found`, {
18200
+ operation: "runJob",
18201
+ taskId: jobName,
18202
+ availableJobs: Array.from(this.jobs.keys()),
18203
+ suggestion: "Check job name or use getAllJobsStatus() to list available jobs"
18204
+ });
16831
18205
  }
16832
18206
  if (this.activeJobs.has(jobName)) {
16833
- throw new Error(`Job '${jobName}' is already running`);
18207
+ throw new SchedulerError(`Job '${jobName}' is already running`, {
18208
+ operation: "runJob",
18209
+ taskId: jobName,
18210
+ executionId: this.activeJobs.get(jobName),
18211
+ suggestion: "Wait for current execution to complete or check job status with getJobStatus()"
18212
+ });
16834
18213
  }
16835
18214
  await this._executeJob(jobName);
16836
18215
  }
@@ -16840,7 +18219,12 @@ class SchedulerPlugin extends Plugin {
16840
18219
  enableJob(jobName) {
16841
18220
  const job = this.jobs.get(jobName);
16842
18221
  if (!job) {
16843
- throw new Error(`Job '${jobName}' not found`);
18222
+ throw new SchedulerError(`Job '${jobName}' not found`, {
18223
+ operation: "enableJob",
18224
+ taskId: jobName,
18225
+ availableJobs: Array.from(this.jobs.keys()),
18226
+ suggestion: "Check job name or use getAllJobsStatus() to list available jobs"
18227
+ });
16844
18228
  }
16845
18229
  job.enabled = true;
16846
18230
  this._scheduleNextExecution(jobName);
@@ -16852,7 +18236,12 @@ class SchedulerPlugin extends Plugin {
16852
18236
  disableJob(jobName) {
16853
18237
  const job = this.jobs.get(jobName);
16854
18238
  if (!job) {
16855
- throw new Error(`Job '${jobName}' not found`);
18239
+ throw new SchedulerError(`Job '${jobName}' not found`, {
18240
+ operation: "disableJob",
18241
+ taskId: jobName,
18242
+ availableJobs: Array.from(this.jobs.keys()),
18243
+ suggestion: "Check job name or use getAllJobsStatus() to list available jobs"
18244
+ });
16856
18245
  }
16857
18246
  job.enabled = false;
16858
18247
  const timer = this.timers.get(jobName);
@@ -16951,13 +18340,28 @@ class SchedulerPlugin extends Plugin {
16951
18340
  */
16952
18341
  addJob(jobName, jobConfig) {
16953
18342
  if (this.jobs.has(jobName)) {
16954
- throw new Error(`Job '${jobName}' already exists`);
18343
+ throw new SchedulerError(`Job '${jobName}' already exists`, {
18344
+ operation: "addJob",
18345
+ taskId: jobName,
18346
+ existingJobs: Array.from(this.jobs.keys()),
18347
+ suggestion: "Use a different job name or remove the existing job first with removeJob()"
18348
+ });
16955
18349
  }
16956
18350
  if (!jobConfig.schedule || !jobConfig.action) {
16957
- throw new Error("Job must have schedule and action");
18351
+ throw new SchedulerError("Job must have schedule and action", {
18352
+ operation: "addJob",
18353
+ taskId: jobName,
18354
+ providedConfig: Object.keys(jobConfig),
18355
+ suggestion: 'Provide both schedule and action: { schedule: "0 * * * *", action: async (db, ctx) => {...} }'
18356
+ });
16958
18357
  }
16959
18358
  if (!this._isValidCronExpression(jobConfig.schedule)) {
16960
- throw new Error(`Invalid cron expression: ${jobConfig.schedule}`);
18359
+ throw new SchedulerError("Invalid cron expression", {
18360
+ operation: "addJob",
18361
+ taskId: jobName,
18362
+ cronExpression: jobConfig.schedule,
18363
+ suggestion: "Use valid cron format (5 fields) or shortcuts (@hourly, @daily, @weekly, @monthly, @yearly)"
18364
+ });
16961
18365
  }
16962
18366
  const job = {
16963
18367
  ...jobConfig,
@@ -16991,7 +18395,12 @@ class SchedulerPlugin extends Plugin {
16991
18395
  removeJob(jobName) {
16992
18396
  const job = this.jobs.get(jobName);
16993
18397
  if (!job) {
16994
- throw new Error(`Job '${jobName}' not found`);
18398
+ throw new SchedulerError(`Job '${jobName}' not found`, {
18399
+ operation: "removeJob",
18400
+ taskId: jobName,
18401
+ availableJobs: Array.from(this.jobs.keys()),
18402
+ suggestion: "Check job name or use getAllJobsStatus() to list available jobs"
18403
+ });
16995
18404
  }
16996
18405
  const timer = this.timers.get(jobName);
16997
18406
  if (timer) {
@@ -17045,6 +18454,36 @@ class SchedulerPlugin extends Plugin {
17045
18454
  }
17046
18455
  }
17047
18456
 
18457
+ class StateMachineError extends S3dbError {
18458
+ constructor(message, details = {}) {
18459
+ const { currentState, targetState, resourceName, operation = "unknown", ...rest } = details;
18460
+ let description = details.description;
18461
+ if (!description) {
18462
+ description = `
18463
+ State Machine Operation Error
18464
+
18465
+ Operation: ${operation}
18466
+ ${currentState ? `Current State: ${currentState}` : ""}
18467
+ ${targetState ? `Target State: ${targetState}` : ""}
18468
+ ${resourceName ? `Resource: ${resourceName}` : ""}
18469
+
18470
+ Common causes:
18471
+ 1. Invalid state transition
18472
+ 2. State machine not configured
18473
+ 3. Transition conditions not met
18474
+ 4. State not defined in configuration
18475
+ 5. Missing transition handler
18476
+
18477
+ Solution:
18478
+ Check state machine configuration and valid transitions.
18479
+
18480
+ Docs: https://github.com/forattini-dev/s3db.js/blob/main/docs/plugins/state-machine.md
18481
+ `.trim();
18482
+ }
18483
+ super(message, { ...rest, currentState, targetState, resourceName, operation, description });
18484
+ }
18485
+ }
18486
+
17048
18487
  class StateMachinePlugin extends Plugin {
17049
18488
  constructor(options = {}) {
17050
18489
  super();
@@ -17065,17 +18504,36 @@ class StateMachinePlugin extends Plugin {
17065
18504
  }
17066
18505
  _validateConfiguration() {
17067
18506
  if (!this.config.stateMachines || Object.keys(this.config.stateMachines).length === 0) {
17068
- throw new Error("StateMachinePlugin: At least one state machine must be defined");
18507
+ throw new StateMachineError("At least one state machine must be defined", {
18508
+ operation: "validateConfiguration",
18509
+ machineCount: 0,
18510
+ suggestion: "Provide at least one state machine in the stateMachines configuration"
18511
+ });
17069
18512
  }
17070
18513
  for (const [machineName, machine] of Object.entries(this.config.stateMachines)) {
17071
18514
  if (!machine.states || Object.keys(machine.states).length === 0) {
17072
- throw new Error(`StateMachinePlugin: Machine '${machineName}' must have states defined`);
18515
+ throw new StateMachineError(`Machine '${machineName}' must have states defined`, {
18516
+ operation: "validateConfiguration",
18517
+ machineId: machineName,
18518
+ suggestion: "Define at least one state in the states configuration"
18519
+ });
17073
18520
  }
17074
18521
  if (!machine.initialState) {
17075
- throw new Error(`StateMachinePlugin: Machine '${machineName}' must have an initialState`);
18522
+ throw new StateMachineError(`Machine '${machineName}' must have an initialState`, {
18523
+ operation: "validateConfiguration",
18524
+ machineId: machineName,
18525
+ availableStates: Object.keys(machine.states),
18526
+ suggestion: "Specify an initialState property matching one of the defined states"
18527
+ });
17076
18528
  }
17077
18529
  if (!machine.states[machine.initialState]) {
17078
- throw new Error(`StateMachinePlugin: Initial state '${machine.initialState}' not found in machine '${machineName}'`);
18530
+ throw new StateMachineError(`Initial state '${machine.initialState}' not found in machine '${machineName}'`, {
18531
+ operation: "validateConfiguration",
18532
+ machineId: machineName,
18533
+ initialState: machine.initialState,
18534
+ availableStates: Object.keys(machine.states),
18535
+ suggestion: "Set initialState to one of the defined states"
18536
+ });
17079
18537
  }
17080
18538
  }
17081
18539
  }
@@ -17132,12 +18590,25 @@ class StateMachinePlugin extends Plugin {
17132
18590
  async send(machineId, entityId, event, context = {}) {
17133
18591
  const machine = this.machines.get(machineId);
17134
18592
  if (!machine) {
17135
- throw new Error(`State machine '${machineId}' not found`);
18593
+ throw new StateMachineError(`State machine '${machineId}' not found`, {
18594
+ operation: "send",
18595
+ machineId,
18596
+ availableMachines: Array.from(this.machines.keys()),
18597
+ suggestion: "Check machine ID or use getMachines() to list available machines"
18598
+ });
17136
18599
  }
17137
18600
  const currentState = await this.getState(machineId, entityId);
17138
18601
  const stateConfig = machine.config.states[currentState];
17139
18602
  if (!stateConfig || !stateConfig.on || !stateConfig.on[event]) {
17140
- throw new Error(`Event '${event}' not valid for state '${currentState}' in machine '${machineId}'`);
18603
+ throw new StateMachineError(`Event '${event}' not valid for state '${currentState}' in machine '${machineId}'`, {
18604
+ operation: "send",
18605
+ machineId,
18606
+ entityId,
18607
+ event,
18608
+ currentState,
18609
+ validEvents: stateConfig && stateConfig.on ? Object.keys(stateConfig.on) : [],
18610
+ suggestion: "Use getValidEvents() to check which events are valid for the current state"
18611
+ });
17141
18612
  }
17142
18613
  const targetState = stateConfig.on[event];
17143
18614
  if (stateConfig.guards && stateConfig.guards[event]) {
@@ -17148,7 +18619,16 @@ class StateMachinePlugin extends Plugin {
17148
18619
  () => guard(context, event, { database: this.database, machineId, entityId })
17149
18620
  );
17150
18621
  if (!guardOk || !guardResult) {
17151
- throw new Error(`Transition blocked by guard '${guardName}': ${guardErr?.message || "Guard returned false"}`);
18622
+ throw new StateMachineError(`Transition blocked by guard '${guardName}'`, {
18623
+ operation: "send",
18624
+ machineId,
18625
+ entityId,
18626
+ event,
18627
+ currentState,
18628
+ guardName,
18629
+ guardError: guardErr?.message || "Guard returned false",
18630
+ suggestion: "Check guard conditions or modify the context to satisfy guard requirements"
18631
+ });
17152
18632
  }
17153
18633
  }
17154
18634
  }
@@ -17258,7 +18738,12 @@ class StateMachinePlugin extends Plugin {
17258
18738
  async getState(machineId, entityId) {
17259
18739
  const machine = this.machines.get(machineId);
17260
18740
  if (!machine) {
17261
- throw new Error(`State machine '${machineId}' not found`);
18741
+ throw new StateMachineError(`State machine '${machineId}' not found`, {
18742
+ operation: "getState",
18743
+ machineId,
18744
+ availableMachines: Array.from(this.machines.keys()),
18745
+ suggestion: "Check machine ID or use getMachines() to list available machines"
18746
+ });
17262
18747
  }
17263
18748
  if (machine.currentStates.has(entityId)) {
17264
18749
  return machine.currentStates.get(entityId);
@@ -17284,7 +18769,12 @@ class StateMachinePlugin extends Plugin {
17284
18769
  async getValidEvents(machineId, stateOrEntityId) {
17285
18770
  const machine = this.machines.get(machineId);
17286
18771
  if (!machine) {
17287
- throw new Error(`State machine '${machineId}' not found`);
18772
+ throw new StateMachineError(`State machine '${machineId}' not found`, {
18773
+ operation: "getValidEvents",
18774
+ machineId,
18775
+ availableMachines: Array.from(this.machines.keys()),
18776
+ suggestion: "Check machine ID or use getMachines() to list available machines"
18777
+ });
17288
18778
  }
17289
18779
  let state;
17290
18780
  if (machine.config.states[stateOrEntityId]) {
@@ -17333,7 +18823,12 @@ class StateMachinePlugin extends Plugin {
17333
18823
  async initializeEntity(machineId, entityId, context = {}) {
17334
18824
  const machine = this.machines.get(machineId);
17335
18825
  if (!machine) {
17336
- throw new Error(`State machine '${machineId}' not found`);
18826
+ throw new StateMachineError(`State machine '${machineId}' not found`, {
18827
+ operation: "initializeEntity",
18828
+ machineId,
18829
+ availableMachines: Array.from(this.machines.keys()),
18830
+ suggestion: "Check machine ID or use getMachines() to list available machines"
18831
+ });
17337
18832
  }
17338
18833
  const initialState = machine.config.initialState;
17339
18834
  machine.currentStates.set(entityId, initialState);
@@ -17352,7 +18847,14 @@ class StateMachinePlugin extends Plugin {
17352
18847
  })
17353
18848
  );
17354
18849
  if (!ok && err && !err.message?.includes("already exists")) {
17355
- throw new Error(`Failed to initialize entity state: ${err.message}`);
18850
+ throw new StateMachineError("Failed to initialize entity state", {
18851
+ operation: "initializeEntity",
18852
+ machineId,
18853
+ entityId,
18854
+ initialState,
18855
+ original: err,
18856
+ suggestion: "Check state resource configuration and database permissions"
18857
+ });
17356
18858
  }
17357
18859
  }
17358
18860
  const initialStateConfig = machine.config.states[initialState];
@@ -17381,7 +18883,12 @@ class StateMachinePlugin extends Plugin {
17381
18883
  visualize(machineId) {
17382
18884
  const machine = this.machines.get(machineId);
17383
18885
  if (!machine) {
17384
- throw new Error(`State machine '${machineId}' not found`);
18886
+ throw new StateMachineError(`State machine '${machineId}' not found`, {
18887
+ operation: "visualize",
18888
+ machineId,
18889
+ availableMachines: Array.from(this.machines.keys()),
18890
+ suggestion: "Check machine ID or use getMachines() to list available machines"
18891
+ });
17385
18892
  }
17386
18893
  let dot = `digraph ${machineId} {
17387
18894
  `;
@@ -17425,5 +18932,5 @@ class StateMachinePlugin extends Plugin {
17425
18932
  }
17426
18933
  }
17427
18934
 
17428
- export { AVAILABLE_BEHAVIORS, AuditPlugin, AuthenticationError, BackupPlugin, BaseError, CachePlugin, Client, ConnectionString, ConnectionStringError, CostsPlugin, CryptoError, DEFAULT_BEHAVIOR, Database, DatabaseError, EncryptionError, ErrorMap, EventualConsistencyPlugin, FullTextPlugin, InvalidResourceItem, MetricsPlugin, MissingMetadata, NoSuchBucket, NoSuchKey, NotFound, PartitionError, PermissionError, Plugin, PluginObject, QueueConsumerPlugin, ReplicatorPlugin, Resource, ResourceError, ResourceIdsPageReader, ResourceIdsReader, ResourceNotFound, ResourceReader, ResourceWriter, S3QueuePlugin, Database as S3db, S3dbError, SchedulerPlugin, Schema, SchemaError, StateMachinePlugin, UnknownError, ValidationError, Validator, behaviors, calculateAttributeNamesSize, calculateAttributeSizes, calculateEffectiveLimit, calculateSystemOverhead, calculateTotalSize, calculateUTF8Bytes, clearUTF8Cache, clearUTF8Memo, clearUTF8Memory, decode, decodeDecimal, decrypt, S3db as default, encode, encodeDecimal, encrypt, getBehavior, getSizeBreakdown, idGenerator, mapAwsError, md5, passwordGenerator, sha256, streamToString, transformValue, tryFn, tryFnSync };
18935
+ export { AVAILABLE_BEHAVIORS, AnalyticsNotEnabledError, AuditPlugin, AuthenticationError, BackupPlugin, BaseError, BehaviorError, CachePlugin, Client, ConnectionString, ConnectionStringError, CostsPlugin, CryptoError, DEFAULT_BEHAVIOR, Database, DatabaseError, EncryptionError, ErrorMap, EventualConsistencyPlugin, FullTextPlugin, InvalidResourceItem, MetadataLimitError, MetricsPlugin, MissingMetadata, NoSuchBucket, NoSuchKey, NotFound, PartitionDriverError, PartitionError, PermissionError, Plugin, PluginError, PluginObject, PluginStorageError, QueueConsumerPlugin, ReplicatorPlugin, Resource, ResourceError, ResourceIdsPageReader, ResourceIdsReader, ResourceNotFound, ResourceReader, ResourceWriter, S3QueuePlugin, Database as S3db, S3dbError, SchedulerPlugin, Schema, SchemaError, StateMachinePlugin, StreamError, UnknownError, ValidationError, Validator, behaviors, calculateAttributeNamesSize, calculateAttributeSizes, calculateEffectiveLimit, calculateSystemOverhead, calculateTotalSize, calculateUTF8Bytes, clearUTF8Cache, clearUTF8Memo, clearUTF8Memory, decode, decodeDecimal, decrypt, S3db as default, encode, encodeDecimal, encrypt, getBehavior, getSizeBreakdown, idGenerator, mapAwsError, md5, passwordGenerator, sha256, streamToString, transformValue, tryFn, tryFnSync };
17429
18936
  //# sourceMappingURL=s3db.es.js.map