s3db.js 10.0.18 → 11.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/s3db.es.js CHANGED
@@ -656,18 +656,496 @@ var id = /*#__PURE__*/Object.freeze({
656
656
  passwordGenerator: passwordGenerator
657
657
  });
658
658
 
659
+ function analyzeString(str) {
660
+ if (!str || typeof str !== "string") {
661
+ return { type: "none", safe: true };
662
+ }
663
+ let hasLatin1 = false;
664
+ let hasMultibyte = false;
665
+ let asciiCount = 0;
666
+ let latin1Count = 0;
667
+ let multibyteCount = 0;
668
+ for (let i = 0; i < str.length; i++) {
669
+ const code = str.charCodeAt(i);
670
+ if (code >= 32 && code <= 126) {
671
+ asciiCount++;
672
+ } else if (code < 32 || code === 127) {
673
+ hasMultibyte = true;
674
+ multibyteCount++;
675
+ } else if (code >= 128 && code <= 255) {
676
+ hasLatin1 = true;
677
+ latin1Count++;
678
+ } else {
679
+ hasMultibyte = true;
680
+ multibyteCount++;
681
+ }
682
+ }
683
+ if (!hasLatin1 && !hasMultibyte) {
684
+ return {
685
+ type: "ascii",
686
+ safe: true,
687
+ stats: { ascii: asciiCount, latin1: 0, multibyte: 0 }
688
+ };
689
+ }
690
+ if (hasMultibyte) {
691
+ const multibyteRatio = multibyteCount / str.length;
692
+ if (multibyteRatio > 0.3) {
693
+ return {
694
+ type: "base64",
695
+ safe: false,
696
+ reason: "high multibyte content",
697
+ stats: { ascii: asciiCount, latin1: latin1Count, multibyte: multibyteCount }
698
+ };
699
+ }
700
+ return {
701
+ type: "url",
702
+ safe: false,
703
+ reason: "contains multibyte characters",
704
+ stats: { ascii: asciiCount, latin1: latin1Count, multibyte: multibyteCount }
705
+ };
706
+ }
707
+ const latin1Ratio = latin1Count / str.length;
708
+ if (latin1Ratio > 0.5) {
709
+ return {
710
+ type: "base64",
711
+ safe: false,
712
+ reason: "high Latin-1 content",
713
+ stats: { ascii: asciiCount, latin1: latin1Count, multibyte: 0 }
714
+ };
715
+ }
716
+ return {
717
+ type: "url",
718
+ safe: false,
719
+ reason: "contains Latin-1 extended characters",
720
+ stats: { ascii: asciiCount, latin1: latin1Count, multibyte: 0 }
721
+ };
722
+ }
723
+ function metadataEncode(value) {
724
+ if (value === null) {
725
+ return { encoded: "null", encoding: "special" };
726
+ }
727
+ if (value === void 0) {
728
+ return { encoded: "undefined", encoding: "special" };
729
+ }
730
+ const stringValue = String(value);
731
+ const analysis = analyzeString(stringValue);
732
+ switch (analysis.type) {
733
+ case "none":
734
+ case "ascii":
735
+ return {
736
+ encoded: stringValue,
737
+ encoding: "none",
738
+ analysis
739
+ };
740
+ case "url":
741
+ return {
742
+ encoded: "u:" + encodeURIComponent(stringValue),
743
+ encoding: "url",
744
+ analysis
745
+ };
746
+ case "base64":
747
+ return {
748
+ encoded: "b:" + Buffer.from(stringValue, "utf8").toString("base64"),
749
+ encoding: "base64",
750
+ analysis
751
+ };
752
+ default:
753
+ return {
754
+ encoded: "b:" + Buffer.from(stringValue, "utf8").toString("base64"),
755
+ encoding: "base64",
756
+ analysis
757
+ };
758
+ }
759
+ }
760
+ function metadataDecode(value) {
761
+ if (value === "null") {
762
+ return null;
763
+ }
764
+ if (value === "undefined") {
765
+ return void 0;
766
+ }
767
+ if (value === null || value === void 0 || typeof value !== "string") {
768
+ return value;
769
+ }
770
+ if (value.startsWith("u:")) {
771
+ if (value.length === 2) return value;
772
+ try {
773
+ return decodeURIComponent(value.substring(2));
774
+ } catch (err) {
775
+ return value;
776
+ }
777
+ }
778
+ if (value.startsWith("b:")) {
779
+ if (value.length === 2) return value;
780
+ try {
781
+ const decoded = Buffer.from(value.substring(2), "base64").toString("utf8");
782
+ return decoded;
783
+ } catch (err) {
784
+ return value;
785
+ }
786
+ }
787
+ if (value.length > 0 && /^[A-Za-z0-9+/]+=*$/.test(value)) {
788
+ try {
789
+ const decoded = Buffer.from(value, "base64").toString("utf8");
790
+ if (/[^\x00-\x7F]/.test(decoded) && Buffer.from(decoded, "utf8").toString("base64") === value) {
791
+ return decoded;
792
+ }
793
+ } catch {
794
+ }
795
+ }
796
+ return value;
797
+ }
798
+
799
+ const S3_METADATA_LIMIT = 2047;
800
+ class PluginStorage {
801
+ /**
802
+ * @param {Object} client - S3db Client instance
803
+ * @param {string} pluginSlug - Plugin identifier (kebab-case)
804
+ */
805
+ constructor(client, pluginSlug) {
806
+ if (!client) {
807
+ throw new Error("PluginStorage requires a client instance");
808
+ }
809
+ if (!pluginSlug) {
810
+ throw new Error("PluginStorage requires a pluginSlug");
811
+ }
812
+ this.client = client;
813
+ this.pluginSlug = pluginSlug;
814
+ }
815
+ /**
816
+ * Generate hierarchical plugin-scoped key
817
+ *
818
+ * @param {string} resourceName - Resource name (optional, for resource-scoped data)
819
+ * @param {...string} parts - Additional path parts
820
+ * @returns {string} S3 key
821
+ *
822
+ * @example
823
+ * // Resource-scoped: resource=wallets/plugin=eventual-consistency/balance/transactions/id=txn1
824
+ * getPluginKey('wallets', 'balance', 'transactions', 'id=txn1')
825
+ *
826
+ * // Global plugin data: plugin=eventual-consistency/config
827
+ * getPluginKey(null, 'config')
828
+ */
829
+ getPluginKey(resourceName, ...parts) {
830
+ if (resourceName) {
831
+ return `resource=${resourceName}/plugin=${this.pluginSlug}/${parts.join("/")}`;
832
+ }
833
+ return `plugin=${this.pluginSlug}/${parts.join("/")}`;
834
+ }
835
+ /**
836
+ * Save data with metadata encoding and behavior support
837
+ *
838
+ * @param {string} key - S3 key
839
+ * @param {Object} data - Data to save
840
+ * @param {Object} options - Options
841
+ * @param {string} options.behavior - 'body-overflow' | 'body-only' | 'enforce-limits'
842
+ * @param {string} options.contentType - Content type (default: application/json)
843
+ * @returns {Promise<void>}
844
+ */
845
+ async put(key, data, options = {}) {
846
+ const { behavior = "body-overflow", contentType = "application/json" } = options;
847
+ const { metadata, body } = this._applyBehavior(data, behavior);
848
+ const putParams = {
849
+ key,
850
+ metadata,
851
+ contentType
852
+ };
853
+ if (body !== null) {
854
+ putParams.body = JSON.stringify(body);
855
+ }
856
+ const [ok, err] = await tryFn(() => this.client.putObject(putParams));
857
+ if (!ok) {
858
+ throw new Error(`PluginStorage.put failed for key ${key}: ${err.message}`);
859
+ }
860
+ }
861
+ /**
862
+ * Get data with automatic metadata decoding
863
+ *
864
+ * @param {string} key - S3 key
865
+ * @returns {Promise<Object|null>} Data or null if not found
866
+ */
867
+ async get(key) {
868
+ const [ok, err, response] = await tryFn(() => this.client.getObject(key));
869
+ if (!ok) {
870
+ if (err.name === "NoSuchKey" || err.Code === "NoSuchKey") {
871
+ return null;
872
+ }
873
+ throw new Error(`PluginStorage.get failed for key ${key}: ${err.message}`);
874
+ }
875
+ const metadata = response.Metadata || {};
876
+ const parsedMetadata = this._parseMetadataValues(metadata);
877
+ if (response.Body) {
878
+ try {
879
+ const bodyContent = await response.Body.transformToString();
880
+ if (bodyContent && bodyContent.trim()) {
881
+ const body = JSON.parse(bodyContent);
882
+ return { ...parsedMetadata, ...body };
883
+ }
884
+ } catch (parseErr) {
885
+ throw new Error(`PluginStorage.get failed to parse body for key ${key}: ${parseErr.message}`);
886
+ }
887
+ }
888
+ return parsedMetadata;
889
+ }
890
+ /**
891
+ * Parse metadata values back to their original types
892
+ * @private
893
+ */
894
+ _parseMetadataValues(metadata) {
895
+ const parsed = {};
896
+ for (const [key, value] of Object.entries(metadata)) {
897
+ if (typeof value === "string") {
898
+ if (value.startsWith("{") && value.endsWith("}") || value.startsWith("[") && value.endsWith("]")) {
899
+ try {
900
+ parsed[key] = JSON.parse(value);
901
+ continue;
902
+ } catch {
903
+ }
904
+ }
905
+ if (!isNaN(value) && value.trim() !== "") {
906
+ parsed[key] = Number(value);
907
+ continue;
908
+ }
909
+ if (value === "true") {
910
+ parsed[key] = true;
911
+ continue;
912
+ }
913
+ if (value === "false") {
914
+ parsed[key] = false;
915
+ continue;
916
+ }
917
+ }
918
+ parsed[key] = value;
919
+ }
920
+ return parsed;
921
+ }
922
+ /**
923
+ * List all keys with plugin prefix
924
+ *
925
+ * @param {string} prefix - Additional prefix (optional)
926
+ * @param {Object} options - List options
927
+ * @param {number} options.limit - Max number of results
928
+ * @returns {Promise<Array<string>>} List of keys
929
+ */
930
+ async list(prefix = "", options = {}) {
931
+ const { limit } = options;
932
+ const fullPrefix = prefix ? `plugin=${this.pluginSlug}/${prefix}` : `plugin=${this.pluginSlug}/`;
933
+ const [ok, err, result] = await tryFn(
934
+ () => this.client.listObjects({ prefix: fullPrefix, maxKeys: limit })
935
+ );
936
+ if (!ok) {
937
+ throw new Error(`PluginStorage.list failed: ${err.message}`);
938
+ }
939
+ const keys = result.Contents?.map((item) => item.Key) || [];
940
+ return this._removeKeyPrefix(keys);
941
+ }
942
+ /**
943
+ * List keys for a specific resource
944
+ *
945
+ * @param {string} resourceName - Resource name
946
+ * @param {string} subPrefix - Additional prefix within resource (optional)
947
+ * @param {Object} options - List options
948
+ * @returns {Promise<Array<string>>} List of keys
949
+ */
950
+ async listForResource(resourceName, subPrefix = "", options = {}) {
951
+ const { limit } = options;
952
+ const fullPrefix = subPrefix ? `resource=${resourceName}/plugin=${this.pluginSlug}/${subPrefix}` : `resource=${resourceName}/plugin=${this.pluginSlug}/`;
953
+ const [ok, err, result] = await tryFn(
954
+ () => this.client.listObjects({ prefix: fullPrefix, maxKeys: limit })
955
+ );
956
+ if (!ok) {
957
+ throw new Error(`PluginStorage.listForResource failed: ${err.message}`);
958
+ }
959
+ const keys = result.Contents?.map((item) => item.Key) || [];
960
+ return this._removeKeyPrefix(keys);
961
+ }
962
+ /**
963
+ * Remove client keyPrefix from keys
964
+ * @private
965
+ */
966
+ _removeKeyPrefix(keys) {
967
+ const keyPrefix = this.client.config.keyPrefix;
968
+ if (!keyPrefix) return keys;
969
+ return keys.map((key) => key.replace(keyPrefix, "")).map((key) => key.startsWith("/") ? key.replace("/", "") : key);
970
+ }
971
+ /**
972
+ * Delete a single object
973
+ *
974
+ * @param {string} key - S3 key
975
+ * @returns {Promise<void>}
976
+ */
977
+ async delete(key) {
978
+ const [ok, err] = await tryFn(() => this.client.deleteObject(key));
979
+ if (!ok) {
980
+ throw new Error(`PluginStorage.delete failed for key ${key}: ${err.message}`);
981
+ }
982
+ }
983
+ /**
984
+ * Delete all plugin data (for uninstall)
985
+ *
986
+ * @param {string} resourceName - Resource name (optional, if null deletes all plugin data)
987
+ * @returns {Promise<number>} Number of objects deleted
988
+ */
989
+ async deleteAll(resourceName = null) {
990
+ let deleted = 0;
991
+ if (resourceName) {
992
+ const keys = await this.listForResource(resourceName);
993
+ for (const key of keys) {
994
+ await this.delete(key);
995
+ deleted++;
996
+ }
997
+ } else {
998
+ const allKeys = await this.client.getAllKeys({});
999
+ const pluginKeys = allKeys.filter(
1000
+ (key) => key.includes(`plugin=${this.pluginSlug}/`)
1001
+ );
1002
+ for (const key of pluginKeys) {
1003
+ await this.delete(key);
1004
+ deleted++;
1005
+ }
1006
+ }
1007
+ return deleted;
1008
+ }
1009
+ /**
1010
+ * Batch put operations
1011
+ *
1012
+ * @param {Array<{key: string, data: Object, options?: Object}>} items - Items to save
1013
+ * @returns {Promise<Array<{key: string, ok: boolean, error?: Error}>>} Results
1014
+ */
1015
+ async batchPut(items) {
1016
+ const results = [];
1017
+ for (const item of items) {
1018
+ const [ok, err] = await tryFn(
1019
+ () => this.put(item.key, item.data, item.options)
1020
+ );
1021
+ results.push({
1022
+ key: item.key,
1023
+ ok,
1024
+ error: err
1025
+ });
1026
+ }
1027
+ return results;
1028
+ }
1029
+ /**
1030
+ * Batch get operations
1031
+ *
1032
+ * @param {Array<string>} keys - Keys to fetch
1033
+ * @returns {Promise<Array<{key: string, ok: boolean, data?: Object, error?: Error}>>} Results
1034
+ */
1035
+ async batchGet(keys) {
1036
+ const results = [];
1037
+ for (const key of keys) {
1038
+ const [ok, err, data] = await tryFn(() => this.get(key));
1039
+ results.push({
1040
+ key,
1041
+ ok,
1042
+ data,
1043
+ error: err
1044
+ });
1045
+ }
1046
+ return results;
1047
+ }
1048
+ /**
1049
+ * Apply behavior to split data between metadata and body
1050
+ *
1051
+ * @private
1052
+ * @param {Object} data - Data to split
1053
+ * @param {string} behavior - Behavior strategy
1054
+ * @returns {{metadata: Object, body: Object|null}}
1055
+ */
1056
+ _applyBehavior(data, behavior) {
1057
+ const effectiveLimit = calculateEffectiveLimit({ s3Limit: S3_METADATA_LIMIT });
1058
+ let metadata = {};
1059
+ let body = null;
1060
+ switch (behavior) {
1061
+ case "body-overflow": {
1062
+ const entries = Object.entries(data);
1063
+ const sorted = entries.map(([key, value]) => {
1064
+ const jsonValue = typeof value === "object" ? JSON.stringify(value) : value;
1065
+ const { encoded } = metadataEncode(jsonValue);
1066
+ const keySize = calculateUTF8Bytes(key);
1067
+ const valueSize = calculateUTF8Bytes(encoded);
1068
+ return { key, value, jsonValue, encoded, size: keySize + valueSize };
1069
+ }).sort((a, b) => a.size - b.size);
1070
+ let currentSize = 0;
1071
+ for (const item of sorted) {
1072
+ if (currentSize + item.size <= effectiveLimit) {
1073
+ metadata[item.key] = item.jsonValue;
1074
+ currentSize += item.size;
1075
+ } else {
1076
+ if (body === null) body = {};
1077
+ body[item.key] = item.value;
1078
+ }
1079
+ }
1080
+ break;
1081
+ }
1082
+ case "body-only": {
1083
+ body = data;
1084
+ break;
1085
+ }
1086
+ case "enforce-limits": {
1087
+ let currentSize = 0;
1088
+ for (const [key, value] of Object.entries(data)) {
1089
+ const jsonValue = typeof value === "object" ? JSON.stringify(value) : value;
1090
+ const { encoded } = metadataEncode(jsonValue);
1091
+ const keySize = calculateUTF8Bytes(key);
1092
+ const valueSize = calculateUTF8Bytes(encoded);
1093
+ currentSize += keySize + valueSize;
1094
+ if (currentSize > effectiveLimit) {
1095
+ throw new Error(
1096
+ `Data exceeds metadata limit (${currentSize} > ${effectiveLimit} bytes). Use 'body-overflow' or 'body-only' behavior.`
1097
+ );
1098
+ }
1099
+ metadata[key] = jsonValue;
1100
+ }
1101
+ break;
1102
+ }
1103
+ default:
1104
+ throw new Error(`Unknown behavior: ${behavior}. Use 'body-overflow', 'body-only', or 'enforce-limits'.`);
1105
+ }
1106
+ return { metadata, body };
1107
+ }
1108
+ }
1109
+
659
1110
  class Plugin extends EventEmitter {
660
1111
  constructor(options = {}) {
661
1112
  super();
662
1113
  this.name = this.constructor.name;
663
1114
  this.options = options;
664
1115
  this.hooks = /* @__PURE__ */ new Map();
1116
+ this.slug = options.slug || this._generateSlug();
1117
+ this._storage = null;
665
1118
  }
666
- async setup(database) {
1119
+ /**
1120
+ * Generate kebab-case slug from class name
1121
+ * @private
1122
+ * @returns {string}
1123
+ */
1124
+ _generateSlug() {
1125
+ return this.name.replace(/Plugin$/, "").replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
1126
+ }
1127
+ /**
1128
+ * Get PluginStorage instance (lazy-loaded)
1129
+ * @returns {PluginStorage}
1130
+ */
1131
+ getStorage() {
1132
+ if (!this._storage) {
1133
+ if (!this.database || !this.database.client) {
1134
+ throw new Error("Plugin must be installed before accessing storage");
1135
+ }
1136
+ this._storage = new PluginStorage(this.database.client, this.slug);
1137
+ }
1138
+ return this._storage;
1139
+ }
1140
+ /**
1141
+ * Install plugin
1142
+ * @param {Database} database - Database instance
1143
+ */
1144
+ async install(database) {
667
1145
  this.database = database;
668
- this.beforeSetup();
669
- await this.onSetup();
670
- this.afterSetup();
1146
+ this.beforeInstall();
1147
+ await this.onInstall();
1148
+ this.afterInstall();
671
1149
  }
672
1150
  async start() {
673
1151
  this.beforeStart();
@@ -679,13 +1157,30 @@ class Plugin extends EventEmitter {
679
1157
  await this.onStop();
680
1158
  this.afterStop();
681
1159
  }
1160
+ /**
1161
+ * Uninstall plugin and cleanup all data
1162
+ * @param {Object} options - Uninstall options
1163
+ * @param {boolean} options.purgeData - Delete all plugin data from S3 (default: false)
1164
+ */
1165
+ async uninstall(options = {}) {
1166
+ const { purgeData = false } = options;
1167
+ this.beforeUninstall();
1168
+ await this.onUninstall(options);
1169
+ if (purgeData && this._storage) {
1170
+ const deleted = await this._storage.deleteAll();
1171
+ this.emit("plugin.dataPurged", { deleted });
1172
+ }
1173
+ this.afterUninstall();
1174
+ }
682
1175
  // Override these methods in subclasses
683
- async onSetup() {
1176
+ async onInstall() {
684
1177
  }
685
1178
  async onStart() {
686
1179
  }
687
1180
  async onStop() {
688
1181
  }
1182
+ async onUninstall(options) {
1183
+ }
689
1184
  // Hook management methods
690
1185
  addHook(resource, event, handler) {
691
1186
  if (!this.hooks.has(resource)) {
@@ -797,11 +1292,11 @@ class Plugin extends EventEmitter {
797
1292
  return value ?? null;
798
1293
  }
799
1294
  // Event emission methods
800
- beforeSetup() {
801
- this.emit("plugin.beforeSetup", /* @__PURE__ */ new Date());
1295
+ beforeInstall() {
1296
+ this.emit("plugin.beforeInstall", /* @__PURE__ */ new Date());
802
1297
  }
803
- afterSetup() {
804
- this.emit("plugin.afterSetup", /* @__PURE__ */ new Date());
1298
+ afterInstall() {
1299
+ this.emit("plugin.afterInstall", /* @__PURE__ */ new Date());
805
1300
  }
806
1301
  beforeStart() {
807
1302
  this.emit("plugin.beforeStart", /* @__PURE__ */ new Date());
@@ -815,6 +1310,12 @@ class Plugin extends EventEmitter {
815
1310
  afterStop() {
816
1311
  this.emit("plugin.afterStop", /* @__PURE__ */ new Date());
817
1312
  }
1313
+ beforeUninstall() {
1314
+ this.emit("plugin.beforeUninstall", /* @__PURE__ */ new Date());
1315
+ }
1316
+ afterUninstall() {
1317
+ this.emit("plugin.afterUninstall", /* @__PURE__ */ new Date());
1318
+ }
818
1319
  }
819
1320
 
820
1321
  const PluginObject = {
@@ -837,7 +1338,7 @@ class AuditPlugin extends Plugin {
837
1338
  ...options
838
1339
  };
839
1340
  }
840
- async onSetup() {
1341
+ async onInstall() {
841
1342
  const [ok, err, auditResource] = await tryFn(() => this.database.createResource({
842
1343
  name: "plg_audits",
843
1344
  attributes: {
@@ -1928,7 +2429,7 @@ class BackupPlugin extends Plugin {
1928
2429
  throw new Error("BackupPlugin: Invalid compression type. Use: none, gzip, brotli, deflate");
1929
2430
  }
1930
2431
  }
1931
- async onSetup() {
2432
+ async onInstall() {
1932
2433
  this.driver = createBackupDriver(this.config.driver, this.config.driverConfig);
1933
2434
  await this.driver.setup(this.database);
1934
2435
  await mkdir(this.config.tempDir, { recursive: true });
@@ -3813,10 +4314,7 @@ class CachePlugin extends Plugin {
3813
4314
  verbose: options.verbose || false
3814
4315
  };
3815
4316
  }
3816
- async setup(database) {
3817
- await super.setup(database);
3818
- }
3819
- async onSetup() {
4317
+ async onInstall() {
3820
4318
  if (this.config.driver && typeof this.config.driver === "object") {
3821
4319
  this.driver = this.config.driver;
3822
4320
  } else if (this.config.driver === "memory") {
@@ -4936,7 +5434,20 @@ async function consolidateRecord(originalId, transactionResource, targetResource
4936
5434
  console.warn(`[EventualConsistency] ${errors.length} transactions failed to mark as applied`);
4937
5435
  }
4938
5436
  if (config.enableAnalytics && transactionsToUpdate.length > 0 && updateAnalyticsFn) {
4939
- await updateAnalyticsFn(transactionsToUpdate);
5437
+ const [analyticsOk, analyticsErr] = await tryFn(
5438
+ () => updateAnalyticsFn(transactionsToUpdate)
5439
+ );
5440
+ if (!analyticsOk) {
5441
+ console.error(
5442
+ `[EventualConsistency] ${config.resource}.${config.field} - CRITICAL: Analytics update failed for ${originalId}, but consolidation succeeded:`,
5443
+ {
5444
+ error: analyticsErr?.message || analyticsErr,
5445
+ stack: analyticsErr?.stack,
5446
+ originalId,
5447
+ transactionCount: transactionsToUpdate.length
5448
+ }
5449
+ );
5450
+ }
4940
5451
  }
4941
5452
  if (targetResource && targetResource.cache && typeof targetResource.cache.delete === "function") {
4942
5453
  try {
@@ -5232,9 +5743,18 @@ async function updateAnalytics(transactions, analyticsResource, config) {
5232
5743
  );
5233
5744
  }
5234
5745
  } catch (error) {
5235
- console.warn(
5236
- `[EventualConsistency] ${config.resource}.${config.field} - Analytics update error:`,
5237
- error.message
5746
+ console.error(
5747
+ `[EventualConsistency] CRITICAL: ${config.resource}.${config.field} - Analytics update failed:`,
5748
+ {
5749
+ error: error.message,
5750
+ stack: error.stack,
5751
+ field: config.field,
5752
+ resource: config.resource,
5753
+ transactionCount: transactions.length
5754
+ }
5755
+ );
5756
+ throw new Error(
5757
+ `Analytics update failed for ${config.resource}.${config.field}: ${error.message}`
5238
5758
  );
5239
5759
  }
5240
5760
  }
@@ -5286,6 +5806,7 @@ async function upsertAnalytics(period, cohort, transactions, analyticsResource,
5286
5806
  await tryFn(
5287
5807
  () => analyticsResource.insert({
5288
5808
  id,
5809
+ field: config.field,
5289
5810
  period,
5290
5811
  cohort,
5291
5812
  transactionCount,
@@ -5317,8 +5838,8 @@ function calculateOperationBreakdown(transactions) {
5317
5838
  async function rollupAnalytics(cohortHour, analyticsResource, config) {
5318
5839
  const cohortDate = cohortHour.substring(0, 10);
5319
5840
  const cohortMonth = cohortHour.substring(0, 7);
5320
- await rollupPeriod("day", cohortDate, cohortDate, analyticsResource);
5321
- await rollupPeriod("month", cohortMonth, cohortMonth, analyticsResource);
5841
+ await rollupPeriod("day", cohortDate, cohortDate, analyticsResource, config);
5842
+ await rollupPeriod("month", cohortMonth, cohortMonth, analyticsResource, config);
5322
5843
  }
5323
5844
  async function rollupPeriod(period, cohort, sourcePrefix, analyticsResource, config) {
5324
5845
  const sourcePeriod = period === "day" ? "hour" : "day";
@@ -5368,6 +5889,7 @@ async function rollupPeriod(period, cohort, sourcePrefix, analyticsResource, con
5368
5889
  await tryFn(
5369
5890
  () => analyticsResource.insert({
5370
5891
  id,
5892
+ field: config.field,
5371
5893
  period,
5372
5894
  cohort,
5373
5895
  transactionCount,
@@ -5727,7 +6249,7 @@ function addHelperMethods(resource, plugin, config) {
5727
6249
  };
5728
6250
  }
5729
6251
 
5730
- async function onSetup(database, fieldHandlers, completeFieldSetupFn, watchForResourceFn) {
6252
+ async function onInstall(database, fieldHandlers, completeFieldSetupFn, watchForResourceFn) {
5731
6253
  for (const [resourceName, resourceHandlers] of fieldHandlers) {
5732
6254
  const targetResource = database.resources[resourceName];
5733
6255
  if (!targetResource) {
@@ -5827,6 +6349,7 @@ async function createAnalyticsResource(handler, database, resourceName, fieldNam
5827
6349
  name: analyticsResourceName,
5828
6350
  attributes: {
5829
6351
  id: "string|required",
6352
+ field: "string|required",
5830
6353
  period: "string|required",
5831
6354
  cohort: "string|required",
5832
6355
  transactionCount: "number|required",
@@ -5924,10 +6447,10 @@ class EventualConsistencyPlugin extends Plugin {
5924
6447
  logInitialization(this.config, this.fieldHandlers, timezoneAutoDetected);
5925
6448
  }
5926
6449
  /**
5927
- * Setup hook - create resources and register helpers
6450
+ * Install hook - create resources and register helpers
5928
6451
  */
5929
- async onSetup() {
5930
- await onSetup(
6452
+ async onInstall() {
6453
+ await onInstall(
5931
6454
  this.database,
5932
6455
  this.fieldHandlers,
5933
6456
  (handler) => completeFieldSetup(handler, this.database, this.config, this),
@@ -6292,9 +6815,8 @@ class FullTextPlugin extends Plugin {
6292
6815
  };
6293
6816
  this.indexes = /* @__PURE__ */ new Map();
6294
6817
  }
6295
- async setup(database) {
6296
- this.database = database;
6297
- const [ok, err, indexResource] = await tryFn(() => database.createResource({
6818
+ async onInstall() {
6819
+ const [ok, err, indexResource] = await tryFn(() => this.database.createResource({
6298
6820
  name: "plg_fulltext_indexes",
6299
6821
  attributes: {
6300
6822
  id: "string|required",
@@ -6307,7 +6829,7 @@ class FullTextPlugin extends Plugin {
6307
6829
  lastUpdated: "string|required"
6308
6830
  }
6309
6831
  }));
6310
- this.indexResource = ok ? indexResource : database.resources.fulltext_indexes;
6832
+ this.indexResource = ok ? indexResource : this.database.resources.fulltext_indexes;
6311
6833
  await this.loadIndexes();
6312
6834
  this.installDatabaseHooks();
6313
6835
  this.installIndexingHooks();
@@ -6673,11 +7195,10 @@ class MetricsPlugin extends Plugin {
6673
7195
  };
6674
7196
  this.flushTimer = null;
6675
7197
  }
6676
- async setup(database) {
6677
- this.database = database;
7198
+ async onInstall() {
6678
7199
  if (typeof process !== "undefined" && process.env.NODE_ENV === "test") return;
6679
7200
  const [ok, err] = await tryFn(async () => {
6680
- const [ok1, err1, metricsResource] = await tryFn(() => database.createResource({
7201
+ const [ok1, err1, metricsResource] = await tryFn(() => this.database.createResource({
6681
7202
  name: "plg_metrics",
6682
7203
  attributes: {
6683
7204
  id: "string|required",
@@ -6693,8 +7214,8 @@ class MetricsPlugin extends Plugin {
6693
7214
  metadata: "json"
6694
7215
  }
6695
7216
  }));
6696
- this.metricsResource = ok1 ? metricsResource : database.resources.plg_metrics;
6697
- const [ok2, err2, errorsResource] = await tryFn(() => database.createResource({
7217
+ this.metricsResource = ok1 ? metricsResource : this.database.resources.plg_metrics;
7218
+ const [ok2, err2, errorsResource] = await tryFn(() => this.database.createResource({
6698
7219
  name: "plg_error_logs",
6699
7220
  attributes: {
6700
7221
  id: "string|required",
@@ -6705,8 +7226,8 @@ class MetricsPlugin extends Plugin {
6705
7226
  metadata: "json"
6706
7227
  }
6707
7228
  }));
6708
- this.errorsResource = ok2 ? errorsResource : database.resources.plg_error_logs;
6709
- const [ok3, err3, performanceResource] = await tryFn(() => database.createResource({
7229
+ this.errorsResource = ok2 ? errorsResource : this.database.resources.plg_error_logs;
7230
+ const [ok3, err3, performanceResource] = await tryFn(() => this.database.createResource({
6710
7231
  name: "plg_performance_logs",
6711
7232
  attributes: {
6712
7233
  id: "string|required",
@@ -6717,12 +7238,12 @@ class MetricsPlugin extends Plugin {
6717
7238
  metadata: "json"
6718
7239
  }
6719
7240
  }));
6720
- this.performanceResource = ok3 ? performanceResource : database.resources.plg_performance_logs;
7241
+ this.performanceResource = ok3 ? performanceResource : this.database.resources.plg_performance_logs;
6721
7242
  });
6722
7243
  if (!ok) {
6723
- this.metricsResource = database.resources.plg_metrics;
6724
- this.errorsResource = database.resources.plg_error_logs;
6725
- this.performanceResource = database.resources.plg_performance_logs;
7244
+ this.metricsResource = this.database.resources.plg_metrics;
7245
+ this.errorsResource = this.database.resources.plg_error_logs;
7246
+ this.performanceResource = this.database.resources.plg_performance_logs;
6726
7247
  }
6727
7248
  this.installDatabaseHooks();
6728
7249
  this.installMetricsHooks();
@@ -7300,14 +7821,14 @@ function createConsumer(driver, config) {
7300
7821
  return new ConsumerClass(config);
7301
7822
  }
7302
7823
 
7303
- class QueueConsumerPlugin {
7824
+ class QueueConsumerPlugin extends Plugin {
7304
7825
  constructor(options = {}) {
7826
+ super(options);
7305
7827
  this.options = options;
7306
7828
  this.driversConfig = Array.isArray(options.consumers) ? options.consumers : [];
7307
7829
  this.consumers = [];
7308
7830
  }
7309
- async setup(database) {
7310
- this.database = database;
7831
+ async onInstall() {
7311
7832
  for (const driverDef of this.driversConfig) {
7312
7833
  const { driver, config: driverConfig = {}, consumers: consumerDefs = [] } = driverDef;
7313
7834
  if (consumerDefs.length === 0 && driverDef.resources) {
@@ -8092,146 +8613,6 @@ class PostgresReplicator extends BaseReplicator {
8092
8613
  }
8093
8614
  }
8094
8615
 
8095
- function analyzeString(str) {
8096
- if (!str || typeof str !== "string") {
8097
- return { type: "none", safe: true };
8098
- }
8099
- let hasLatin1 = false;
8100
- let hasMultibyte = false;
8101
- let asciiCount = 0;
8102
- let latin1Count = 0;
8103
- let multibyteCount = 0;
8104
- for (let i = 0; i < str.length; i++) {
8105
- const code = str.charCodeAt(i);
8106
- if (code >= 32 && code <= 126) {
8107
- asciiCount++;
8108
- } else if (code < 32 || code === 127) {
8109
- hasMultibyte = true;
8110
- multibyteCount++;
8111
- } else if (code >= 128 && code <= 255) {
8112
- hasLatin1 = true;
8113
- latin1Count++;
8114
- } else {
8115
- hasMultibyte = true;
8116
- multibyteCount++;
8117
- }
8118
- }
8119
- if (!hasLatin1 && !hasMultibyte) {
8120
- return {
8121
- type: "ascii",
8122
- safe: true,
8123
- stats: { ascii: asciiCount, latin1: 0, multibyte: 0 }
8124
- };
8125
- }
8126
- if (hasMultibyte) {
8127
- const multibyteRatio = multibyteCount / str.length;
8128
- if (multibyteRatio > 0.3) {
8129
- return {
8130
- type: "base64",
8131
- safe: false,
8132
- reason: "high multibyte content",
8133
- stats: { ascii: asciiCount, latin1: latin1Count, multibyte: multibyteCount }
8134
- };
8135
- }
8136
- return {
8137
- type: "url",
8138
- safe: false,
8139
- reason: "contains multibyte characters",
8140
- stats: { ascii: asciiCount, latin1: latin1Count, multibyte: multibyteCount }
8141
- };
8142
- }
8143
- const latin1Ratio = latin1Count / str.length;
8144
- if (latin1Ratio > 0.5) {
8145
- return {
8146
- type: "base64",
8147
- safe: false,
8148
- reason: "high Latin-1 content",
8149
- stats: { ascii: asciiCount, latin1: latin1Count, multibyte: 0 }
8150
- };
8151
- }
8152
- return {
8153
- type: "url",
8154
- safe: false,
8155
- reason: "contains Latin-1 extended characters",
8156
- stats: { ascii: asciiCount, latin1: latin1Count, multibyte: 0 }
8157
- };
8158
- }
8159
- function metadataEncode(value) {
8160
- if (value === null) {
8161
- return { encoded: "null", encoding: "special" };
8162
- }
8163
- if (value === void 0) {
8164
- return { encoded: "undefined", encoding: "special" };
8165
- }
8166
- const stringValue = String(value);
8167
- const analysis = analyzeString(stringValue);
8168
- switch (analysis.type) {
8169
- case "none":
8170
- case "ascii":
8171
- return {
8172
- encoded: stringValue,
8173
- encoding: "none",
8174
- analysis
8175
- };
8176
- case "url":
8177
- return {
8178
- encoded: "u:" + encodeURIComponent(stringValue),
8179
- encoding: "url",
8180
- analysis
8181
- };
8182
- case "base64":
8183
- return {
8184
- encoded: "b:" + Buffer.from(stringValue, "utf8").toString("base64"),
8185
- encoding: "base64",
8186
- analysis
8187
- };
8188
- default:
8189
- return {
8190
- encoded: "b:" + Buffer.from(stringValue, "utf8").toString("base64"),
8191
- encoding: "base64",
8192
- analysis
8193
- };
8194
- }
8195
- }
8196
- function metadataDecode(value) {
8197
- if (value === "null") {
8198
- return null;
8199
- }
8200
- if (value === "undefined") {
8201
- return void 0;
8202
- }
8203
- if (value === null || value === void 0 || typeof value !== "string") {
8204
- return value;
8205
- }
8206
- if (value.startsWith("u:")) {
8207
- if (value.length === 2) return value;
8208
- try {
8209
- return decodeURIComponent(value.substring(2));
8210
- } catch (err) {
8211
- return value;
8212
- }
8213
- }
8214
- if (value.startsWith("b:")) {
8215
- if (value.length === 2) return value;
8216
- try {
8217
- const decoded = Buffer.from(value.substring(2), "base64").toString("utf8");
8218
- return decoded;
8219
- } catch (err) {
8220
- return value;
8221
- }
8222
- }
8223
- if (value.length > 0 && /^[A-Za-z0-9+/]+=*$/.test(value)) {
8224
- try {
8225
- const decoded = Buffer.from(value, "base64").toString("utf8");
8226
- if (/[^\x00-\x7F]/.test(decoded) && Buffer.from(decoded, "utf8").toString("base64") === value) {
8227
- return decoded;
8228
- }
8229
- } catch {
8230
- }
8231
- }
8232
- return value;
8233
- }
8234
-
8235
8616
  const S3_DEFAULT_REGION = "us-east-1";
8236
8617
  const S3_DEFAULT_ENDPOINT = "https://s3.us-east-1.amazonaws.com";
8237
8618
  class ConnectionString {
@@ -12348,7 +12729,7 @@ class Database extends EventEmitter {
12348
12729
  this.id = idGenerator(7);
12349
12730
  this.version = "1";
12350
12731
  this.s3dbVersion = (() => {
12351
- const [ok, err, version] = tryFn(() => true ? "10.0.18" : "latest");
12732
+ const [ok, err, version] = tryFn(() => true ? "11.0.0" : "latest");
12352
12733
  return ok ? version : "latest";
12353
12734
  })();
12354
12735
  this.resources = {};
@@ -12648,18 +13029,14 @@ class Database extends EventEmitter {
12648
13029
  const db = this;
12649
13030
  if (!isEmpty(this.pluginList)) {
12650
13031
  const plugins = this.pluginList.map((p) => isFunction(p) ? new p(this) : p);
12651
- const setupProms = plugins.map(async (plugin) => {
12652
- if (plugin.beforeSetup) await plugin.beforeSetup();
12653
- await plugin.setup(db);
12654
- if (plugin.afterSetup) await plugin.afterSetup();
13032
+ const installProms = plugins.map(async (plugin) => {
13033
+ await plugin.install(db);
12655
13034
  const pluginName = this._getPluginName(plugin);
12656
13035
  this.pluginRegistry[pluginName] = plugin;
12657
13036
  });
12658
- await Promise.all(setupProms);
13037
+ await Promise.all(installProms);
12659
13038
  const startProms = plugins.map(async (plugin) => {
12660
- if (plugin.beforeStart) await plugin.beforeStart();
12661
13039
  await plugin.start();
12662
- if (plugin.afterStart) await plugin.afterStart();
12663
13040
  });
12664
13041
  await Promise.all(startProms);
12665
13042
  }
@@ -12680,11 +13057,37 @@ class Database extends EventEmitter {
12680
13057
  const pluginName = this._getPluginName(plugin, name);
12681
13058
  this.plugins[pluginName] = plugin;
12682
13059
  if (this.isConnected()) {
12683
- await plugin.setup(this);
13060
+ await plugin.install(this);
12684
13061
  await plugin.start();
12685
13062
  }
12686
13063
  return plugin;
12687
13064
  }
13065
+ /**
13066
+ * Uninstall a plugin and optionally purge its data
13067
+ * @param {string} name - Plugin name
13068
+ * @param {Object} options - Uninstall options
13069
+ * @param {boolean} options.purgeData - Delete all plugin data from S3 (default: false)
13070
+ */
13071
+ async uninstallPlugin(name, options = {}) {
13072
+ const pluginName = name.toLowerCase().replace("plugin", "");
13073
+ const plugin = this.plugins[pluginName] || this.pluginRegistry[pluginName];
13074
+ if (!plugin) {
13075
+ throw new Error(`Plugin '${name}' not found`);
13076
+ }
13077
+ if (plugin.stop) {
13078
+ await plugin.stop();
13079
+ }
13080
+ if (plugin.uninstall) {
13081
+ await plugin.uninstall(options);
13082
+ }
13083
+ delete this.plugins[pluginName];
13084
+ delete this.pluginRegistry[pluginName];
13085
+ const index = this.pluginList.indexOf(plugin);
13086
+ if (index > -1) {
13087
+ this.pluginList.splice(index, 1);
13088
+ }
13089
+ this.emit("plugin.uninstalled", { name: pluginName, plugin });
13090
+ }
12688
13091
  async uploadMetadataFile() {
12689
13092
  const metadata = {
12690
13093
  version: this.version,
@@ -14151,10 +14554,9 @@ class ReplicatorPlugin extends Plugin {
14151
14554
  resource.on("delete", deleteHandler);
14152
14555
  this.eventListenersInstalled.add(resource.name);
14153
14556
  }
14154
- async setup(database) {
14155
- this.database = database;
14557
+ async onInstall() {
14156
14558
  if (this.config.persistReplicatorLog) {
14157
- const [ok, err, logResource] = await tryFn(() => database.createResource({
14559
+ const [ok, err, logResource] = await tryFn(() => this.database.createResource({
14158
14560
  name: this.config.replicatorLogResource || "plg_replicator_logs",
14159
14561
  attributes: {
14160
14562
  id: "string|required",
@@ -14169,14 +14571,14 @@ class ReplicatorPlugin extends Plugin {
14169
14571
  if (ok) {
14170
14572
  this.replicatorLogResource = logResource;
14171
14573
  } else {
14172
- this.replicatorLogResource = database.resources[this.config.replicatorLogResource || "plg_replicator_logs"];
14574
+ this.replicatorLogResource = this.database.resources[this.config.replicatorLogResource || "plg_replicator_logs"];
14173
14575
  }
14174
14576
  }
14175
- await this.initializeReplicators(database);
14577
+ await this.initializeReplicators(this.database);
14176
14578
  this.installDatabaseHooks();
14177
- for (const resource of Object.values(database.resources)) {
14579
+ for (const resource of Object.values(this.database.resources)) {
14178
14580
  if (resource.name !== (this.config.replicatorLogResource || "plg_replicator_logs")) {
14179
- this.installEventListeners(resource, database, this);
14581
+ this.installEventListeners(resource, this.database, this);
14180
14582
  }
14181
14583
  }
14182
14584
  }
@@ -14220,8 +14622,8 @@ class ReplicatorPlugin extends Plugin {
14220
14622
  }
14221
14623
  }
14222
14624
  async uploadMetadataFile(database) {
14223
- if (typeof database.uploadMetadataFile === "function") {
14224
- await database.uploadMetadataFile();
14625
+ if (typeof this.database.uploadMetadataFile === "function") {
14626
+ await this.database.uploadMetadataFile();
14225
14627
  }
14226
14628
  }
14227
14629
  async retryWithBackoff(operation, maxRetries = 3) {
@@ -14595,7 +14997,7 @@ class S3QueuePlugin extends Plugin {
14595
14997
  this.cacheCleanupInterval = null;
14596
14998
  this.lockCleanupInterval = null;
14597
14999
  }
14598
- async onSetup() {
15000
+ async onInstall() {
14599
15001
  this.targetResource = this.database.resources[this.config.resource];
14600
15002
  if (!this.targetResource) {
14601
15003
  throw new Error(`S3QueuePlugin: resource '${this.config.resource}' not found`);
@@ -15156,8 +15558,7 @@ class SchedulerPlugin extends Plugin {
15156
15558
  if (parts.length !== 5) return false;
15157
15559
  return true;
15158
15560
  }
15159
- async setup(database) {
15160
- this.database = database;
15561
+ async onInstall() {
15161
15562
  await this._createLockResource();
15162
15563
  if (this.config.persistJobs) {
15163
15564
  await this._createJobHistoryResource();
@@ -15715,8 +16116,7 @@ class StateMachinePlugin extends Plugin {
15715
16116
  }
15716
16117
  }
15717
16118
  }
15718
- async setup(database) {
15719
- this.database = database;
16119
+ async onInstall() {
15720
16120
  if (this.config.persistTransitions) {
15721
16121
  await this._createStateResources();
15722
16122
  }