s3db.js 10.0.19 → 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.cjs.js CHANGED
@@ -660,18 +660,496 @@ var id = /*#__PURE__*/Object.freeze({
660
660
  passwordGenerator: passwordGenerator
661
661
  });
662
662
 
663
+ function analyzeString(str) {
664
+ if (!str || typeof str !== "string") {
665
+ return { type: "none", safe: true };
666
+ }
667
+ let hasLatin1 = false;
668
+ let hasMultibyte = false;
669
+ let asciiCount = 0;
670
+ let latin1Count = 0;
671
+ let multibyteCount = 0;
672
+ for (let i = 0; i < str.length; i++) {
673
+ const code = str.charCodeAt(i);
674
+ if (code >= 32 && code <= 126) {
675
+ asciiCount++;
676
+ } else if (code < 32 || code === 127) {
677
+ hasMultibyte = true;
678
+ multibyteCount++;
679
+ } else if (code >= 128 && code <= 255) {
680
+ hasLatin1 = true;
681
+ latin1Count++;
682
+ } else {
683
+ hasMultibyte = true;
684
+ multibyteCount++;
685
+ }
686
+ }
687
+ if (!hasLatin1 && !hasMultibyte) {
688
+ return {
689
+ type: "ascii",
690
+ safe: true,
691
+ stats: { ascii: asciiCount, latin1: 0, multibyte: 0 }
692
+ };
693
+ }
694
+ if (hasMultibyte) {
695
+ const multibyteRatio = multibyteCount / str.length;
696
+ if (multibyteRatio > 0.3) {
697
+ return {
698
+ type: "base64",
699
+ safe: false,
700
+ reason: "high multibyte content",
701
+ stats: { ascii: asciiCount, latin1: latin1Count, multibyte: multibyteCount }
702
+ };
703
+ }
704
+ return {
705
+ type: "url",
706
+ safe: false,
707
+ reason: "contains multibyte characters",
708
+ stats: { ascii: asciiCount, latin1: latin1Count, multibyte: multibyteCount }
709
+ };
710
+ }
711
+ const latin1Ratio = latin1Count / str.length;
712
+ if (latin1Ratio > 0.5) {
713
+ return {
714
+ type: "base64",
715
+ safe: false,
716
+ reason: "high Latin-1 content",
717
+ stats: { ascii: asciiCount, latin1: latin1Count, multibyte: 0 }
718
+ };
719
+ }
720
+ return {
721
+ type: "url",
722
+ safe: false,
723
+ reason: "contains Latin-1 extended characters",
724
+ stats: { ascii: asciiCount, latin1: latin1Count, multibyte: 0 }
725
+ };
726
+ }
727
+ function metadataEncode(value) {
728
+ if (value === null) {
729
+ return { encoded: "null", encoding: "special" };
730
+ }
731
+ if (value === void 0) {
732
+ return { encoded: "undefined", encoding: "special" };
733
+ }
734
+ const stringValue = String(value);
735
+ const analysis = analyzeString(stringValue);
736
+ switch (analysis.type) {
737
+ case "none":
738
+ case "ascii":
739
+ return {
740
+ encoded: stringValue,
741
+ encoding: "none",
742
+ analysis
743
+ };
744
+ case "url":
745
+ return {
746
+ encoded: "u:" + encodeURIComponent(stringValue),
747
+ encoding: "url",
748
+ analysis
749
+ };
750
+ case "base64":
751
+ return {
752
+ encoded: "b:" + Buffer.from(stringValue, "utf8").toString("base64"),
753
+ encoding: "base64",
754
+ analysis
755
+ };
756
+ default:
757
+ return {
758
+ encoded: "b:" + Buffer.from(stringValue, "utf8").toString("base64"),
759
+ encoding: "base64",
760
+ analysis
761
+ };
762
+ }
763
+ }
764
+ function metadataDecode(value) {
765
+ if (value === "null") {
766
+ return null;
767
+ }
768
+ if (value === "undefined") {
769
+ return void 0;
770
+ }
771
+ if (value === null || value === void 0 || typeof value !== "string") {
772
+ return value;
773
+ }
774
+ if (value.startsWith("u:")) {
775
+ if (value.length === 2) return value;
776
+ try {
777
+ return decodeURIComponent(value.substring(2));
778
+ } catch (err) {
779
+ return value;
780
+ }
781
+ }
782
+ if (value.startsWith("b:")) {
783
+ if (value.length === 2) return value;
784
+ try {
785
+ const decoded = Buffer.from(value.substring(2), "base64").toString("utf8");
786
+ return decoded;
787
+ } catch (err) {
788
+ return value;
789
+ }
790
+ }
791
+ if (value.length > 0 && /^[A-Za-z0-9+/]+=*$/.test(value)) {
792
+ try {
793
+ const decoded = Buffer.from(value, "base64").toString("utf8");
794
+ if (/[^\x00-\x7F]/.test(decoded) && Buffer.from(decoded, "utf8").toString("base64") === value) {
795
+ return decoded;
796
+ }
797
+ } catch {
798
+ }
799
+ }
800
+ return value;
801
+ }
802
+
803
+ const S3_METADATA_LIMIT = 2047;
804
+ class PluginStorage {
805
+ /**
806
+ * @param {Object} client - S3db Client instance
807
+ * @param {string} pluginSlug - Plugin identifier (kebab-case)
808
+ */
809
+ constructor(client, pluginSlug) {
810
+ if (!client) {
811
+ throw new Error("PluginStorage requires a client instance");
812
+ }
813
+ if (!pluginSlug) {
814
+ throw new Error("PluginStorage requires a pluginSlug");
815
+ }
816
+ this.client = client;
817
+ this.pluginSlug = pluginSlug;
818
+ }
819
+ /**
820
+ * Generate hierarchical plugin-scoped key
821
+ *
822
+ * @param {string} resourceName - Resource name (optional, for resource-scoped data)
823
+ * @param {...string} parts - Additional path parts
824
+ * @returns {string} S3 key
825
+ *
826
+ * @example
827
+ * // Resource-scoped: resource=wallets/plugin=eventual-consistency/balance/transactions/id=txn1
828
+ * getPluginKey('wallets', 'balance', 'transactions', 'id=txn1')
829
+ *
830
+ * // Global plugin data: plugin=eventual-consistency/config
831
+ * getPluginKey(null, 'config')
832
+ */
833
+ getPluginKey(resourceName, ...parts) {
834
+ if (resourceName) {
835
+ return `resource=${resourceName}/plugin=${this.pluginSlug}/${parts.join("/")}`;
836
+ }
837
+ return `plugin=${this.pluginSlug}/${parts.join("/")}`;
838
+ }
839
+ /**
840
+ * Save data with metadata encoding and behavior support
841
+ *
842
+ * @param {string} key - S3 key
843
+ * @param {Object} data - Data to save
844
+ * @param {Object} options - Options
845
+ * @param {string} options.behavior - 'body-overflow' | 'body-only' | 'enforce-limits'
846
+ * @param {string} options.contentType - Content type (default: application/json)
847
+ * @returns {Promise<void>}
848
+ */
849
+ async put(key, data, options = {}) {
850
+ const { behavior = "body-overflow", contentType = "application/json" } = options;
851
+ const { metadata, body } = this._applyBehavior(data, behavior);
852
+ const putParams = {
853
+ key,
854
+ metadata,
855
+ contentType
856
+ };
857
+ if (body !== null) {
858
+ putParams.body = JSON.stringify(body);
859
+ }
860
+ const [ok, err] = await tryFn(() => this.client.putObject(putParams));
861
+ if (!ok) {
862
+ throw new Error(`PluginStorage.put failed for key ${key}: ${err.message}`);
863
+ }
864
+ }
865
+ /**
866
+ * Get data with automatic metadata decoding
867
+ *
868
+ * @param {string} key - S3 key
869
+ * @returns {Promise<Object|null>} Data or null if not found
870
+ */
871
+ async get(key) {
872
+ const [ok, err, response] = await tryFn(() => this.client.getObject(key));
873
+ if (!ok) {
874
+ if (err.name === "NoSuchKey" || err.Code === "NoSuchKey") {
875
+ return null;
876
+ }
877
+ throw new Error(`PluginStorage.get failed for key ${key}: ${err.message}`);
878
+ }
879
+ const metadata = response.Metadata || {};
880
+ const parsedMetadata = this._parseMetadataValues(metadata);
881
+ if (response.Body) {
882
+ try {
883
+ const bodyContent = await response.Body.transformToString();
884
+ if (bodyContent && bodyContent.trim()) {
885
+ const body = JSON.parse(bodyContent);
886
+ return { ...parsedMetadata, ...body };
887
+ }
888
+ } catch (parseErr) {
889
+ throw new Error(`PluginStorage.get failed to parse body for key ${key}: ${parseErr.message}`);
890
+ }
891
+ }
892
+ return parsedMetadata;
893
+ }
894
+ /**
895
+ * Parse metadata values back to their original types
896
+ * @private
897
+ */
898
+ _parseMetadataValues(metadata) {
899
+ const parsed = {};
900
+ for (const [key, value] of Object.entries(metadata)) {
901
+ if (typeof value === "string") {
902
+ if (value.startsWith("{") && value.endsWith("}") || value.startsWith("[") && value.endsWith("]")) {
903
+ try {
904
+ parsed[key] = JSON.parse(value);
905
+ continue;
906
+ } catch {
907
+ }
908
+ }
909
+ if (!isNaN(value) && value.trim() !== "") {
910
+ parsed[key] = Number(value);
911
+ continue;
912
+ }
913
+ if (value === "true") {
914
+ parsed[key] = true;
915
+ continue;
916
+ }
917
+ if (value === "false") {
918
+ parsed[key] = false;
919
+ continue;
920
+ }
921
+ }
922
+ parsed[key] = value;
923
+ }
924
+ return parsed;
925
+ }
926
+ /**
927
+ * List all keys with plugin prefix
928
+ *
929
+ * @param {string} prefix - Additional prefix (optional)
930
+ * @param {Object} options - List options
931
+ * @param {number} options.limit - Max number of results
932
+ * @returns {Promise<Array<string>>} List of keys
933
+ */
934
+ async list(prefix = "", options = {}) {
935
+ const { limit } = options;
936
+ const fullPrefix = prefix ? `plugin=${this.pluginSlug}/${prefix}` : `plugin=${this.pluginSlug}/`;
937
+ const [ok, err, result] = await tryFn(
938
+ () => this.client.listObjects({ prefix: fullPrefix, maxKeys: limit })
939
+ );
940
+ if (!ok) {
941
+ throw new Error(`PluginStorage.list failed: ${err.message}`);
942
+ }
943
+ const keys = result.Contents?.map((item) => item.Key) || [];
944
+ return this._removeKeyPrefix(keys);
945
+ }
946
+ /**
947
+ * List keys for a specific resource
948
+ *
949
+ * @param {string} resourceName - Resource name
950
+ * @param {string} subPrefix - Additional prefix within resource (optional)
951
+ * @param {Object} options - List options
952
+ * @returns {Promise<Array<string>>} List of keys
953
+ */
954
+ async listForResource(resourceName, subPrefix = "", options = {}) {
955
+ const { limit } = options;
956
+ const fullPrefix = subPrefix ? `resource=${resourceName}/plugin=${this.pluginSlug}/${subPrefix}` : `resource=${resourceName}/plugin=${this.pluginSlug}/`;
957
+ const [ok, err, result] = await tryFn(
958
+ () => this.client.listObjects({ prefix: fullPrefix, maxKeys: limit })
959
+ );
960
+ if (!ok) {
961
+ throw new Error(`PluginStorage.listForResource failed: ${err.message}`);
962
+ }
963
+ const keys = result.Contents?.map((item) => item.Key) || [];
964
+ return this._removeKeyPrefix(keys);
965
+ }
966
+ /**
967
+ * Remove client keyPrefix from keys
968
+ * @private
969
+ */
970
+ _removeKeyPrefix(keys) {
971
+ const keyPrefix = this.client.config.keyPrefix;
972
+ if (!keyPrefix) return keys;
973
+ return keys.map((key) => key.replace(keyPrefix, "")).map((key) => key.startsWith("/") ? key.replace("/", "") : key);
974
+ }
975
+ /**
976
+ * Delete a single object
977
+ *
978
+ * @param {string} key - S3 key
979
+ * @returns {Promise<void>}
980
+ */
981
+ async delete(key) {
982
+ const [ok, err] = await tryFn(() => this.client.deleteObject(key));
983
+ if (!ok) {
984
+ throw new Error(`PluginStorage.delete failed for key ${key}: ${err.message}`);
985
+ }
986
+ }
987
+ /**
988
+ * Delete all plugin data (for uninstall)
989
+ *
990
+ * @param {string} resourceName - Resource name (optional, if null deletes all plugin data)
991
+ * @returns {Promise<number>} Number of objects deleted
992
+ */
993
+ async deleteAll(resourceName = null) {
994
+ let deleted = 0;
995
+ if (resourceName) {
996
+ const keys = await this.listForResource(resourceName);
997
+ for (const key of keys) {
998
+ await this.delete(key);
999
+ deleted++;
1000
+ }
1001
+ } else {
1002
+ const allKeys = await this.client.getAllKeys({});
1003
+ const pluginKeys = allKeys.filter(
1004
+ (key) => key.includes(`plugin=${this.pluginSlug}/`)
1005
+ );
1006
+ for (const key of pluginKeys) {
1007
+ await this.delete(key);
1008
+ deleted++;
1009
+ }
1010
+ }
1011
+ return deleted;
1012
+ }
1013
+ /**
1014
+ * Batch put operations
1015
+ *
1016
+ * @param {Array<{key: string, data: Object, options?: Object}>} items - Items to save
1017
+ * @returns {Promise<Array<{key: string, ok: boolean, error?: Error}>>} Results
1018
+ */
1019
+ async batchPut(items) {
1020
+ const results = [];
1021
+ for (const item of items) {
1022
+ const [ok, err] = await tryFn(
1023
+ () => this.put(item.key, item.data, item.options)
1024
+ );
1025
+ results.push({
1026
+ key: item.key,
1027
+ ok,
1028
+ error: err
1029
+ });
1030
+ }
1031
+ return results;
1032
+ }
1033
+ /**
1034
+ * Batch get operations
1035
+ *
1036
+ * @param {Array<string>} keys - Keys to fetch
1037
+ * @returns {Promise<Array<{key: string, ok: boolean, data?: Object, error?: Error}>>} Results
1038
+ */
1039
+ async batchGet(keys) {
1040
+ const results = [];
1041
+ for (const key of keys) {
1042
+ const [ok, err, data] = await tryFn(() => this.get(key));
1043
+ results.push({
1044
+ key,
1045
+ ok,
1046
+ data,
1047
+ error: err
1048
+ });
1049
+ }
1050
+ return results;
1051
+ }
1052
+ /**
1053
+ * Apply behavior to split data between metadata and body
1054
+ *
1055
+ * @private
1056
+ * @param {Object} data - Data to split
1057
+ * @param {string} behavior - Behavior strategy
1058
+ * @returns {{metadata: Object, body: Object|null}}
1059
+ */
1060
+ _applyBehavior(data, behavior) {
1061
+ const effectiveLimit = calculateEffectiveLimit({ s3Limit: S3_METADATA_LIMIT });
1062
+ let metadata = {};
1063
+ let body = null;
1064
+ switch (behavior) {
1065
+ case "body-overflow": {
1066
+ const entries = Object.entries(data);
1067
+ const sorted = entries.map(([key, value]) => {
1068
+ const jsonValue = typeof value === "object" ? JSON.stringify(value) : value;
1069
+ const { encoded } = metadataEncode(jsonValue);
1070
+ const keySize = calculateUTF8Bytes(key);
1071
+ const valueSize = calculateUTF8Bytes(encoded);
1072
+ return { key, value, jsonValue, encoded, size: keySize + valueSize };
1073
+ }).sort((a, b) => a.size - b.size);
1074
+ let currentSize = 0;
1075
+ for (const item of sorted) {
1076
+ if (currentSize + item.size <= effectiveLimit) {
1077
+ metadata[item.key] = item.jsonValue;
1078
+ currentSize += item.size;
1079
+ } else {
1080
+ if (body === null) body = {};
1081
+ body[item.key] = item.value;
1082
+ }
1083
+ }
1084
+ break;
1085
+ }
1086
+ case "body-only": {
1087
+ body = data;
1088
+ break;
1089
+ }
1090
+ case "enforce-limits": {
1091
+ let currentSize = 0;
1092
+ for (const [key, value] of Object.entries(data)) {
1093
+ const jsonValue = typeof value === "object" ? JSON.stringify(value) : value;
1094
+ const { encoded } = metadataEncode(jsonValue);
1095
+ const keySize = calculateUTF8Bytes(key);
1096
+ const valueSize = calculateUTF8Bytes(encoded);
1097
+ currentSize += keySize + valueSize;
1098
+ if (currentSize > effectiveLimit) {
1099
+ throw new Error(
1100
+ `Data exceeds metadata limit (${currentSize} > ${effectiveLimit} bytes). Use 'body-overflow' or 'body-only' behavior.`
1101
+ );
1102
+ }
1103
+ metadata[key] = jsonValue;
1104
+ }
1105
+ break;
1106
+ }
1107
+ default:
1108
+ throw new Error(`Unknown behavior: ${behavior}. Use 'body-overflow', 'body-only', or 'enforce-limits'.`);
1109
+ }
1110
+ return { metadata, body };
1111
+ }
1112
+ }
1113
+
663
1114
  class Plugin extends EventEmitter {
664
1115
  constructor(options = {}) {
665
1116
  super();
666
1117
  this.name = this.constructor.name;
667
1118
  this.options = options;
668
1119
  this.hooks = /* @__PURE__ */ new Map();
1120
+ this.slug = options.slug || this._generateSlug();
1121
+ this._storage = null;
669
1122
  }
670
- async setup(database) {
1123
+ /**
1124
+ * Generate kebab-case slug from class name
1125
+ * @private
1126
+ * @returns {string}
1127
+ */
1128
+ _generateSlug() {
1129
+ return this.name.replace(/Plugin$/, "").replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
1130
+ }
1131
+ /**
1132
+ * Get PluginStorage instance (lazy-loaded)
1133
+ * @returns {PluginStorage}
1134
+ */
1135
+ getStorage() {
1136
+ if (!this._storage) {
1137
+ if (!this.database || !this.database.client) {
1138
+ throw new Error("Plugin must be installed before accessing storage");
1139
+ }
1140
+ this._storage = new PluginStorage(this.database.client, this.slug);
1141
+ }
1142
+ return this._storage;
1143
+ }
1144
+ /**
1145
+ * Install plugin
1146
+ * @param {Database} database - Database instance
1147
+ */
1148
+ async install(database) {
671
1149
  this.database = database;
672
- this.beforeSetup();
673
- await this.onSetup();
674
- this.afterSetup();
1150
+ this.beforeInstall();
1151
+ await this.onInstall();
1152
+ this.afterInstall();
675
1153
  }
676
1154
  async start() {
677
1155
  this.beforeStart();
@@ -683,13 +1161,30 @@ class Plugin extends EventEmitter {
683
1161
  await this.onStop();
684
1162
  this.afterStop();
685
1163
  }
1164
+ /**
1165
+ * Uninstall plugin and cleanup all data
1166
+ * @param {Object} options - Uninstall options
1167
+ * @param {boolean} options.purgeData - Delete all plugin data from S3 (default: false)
1168
+ */
1169
+ async uninstall(options = {}) {
1170
+ const { purgeData = false } = options;
1171
+ this.beforeUninstall();
1172
+ await this.onUninstall(options);
1173
+ if (purgeData && this._storage) {
1174
+ const deleted = await this._storage.deleteAll();
1175
+ this.emit("plugin.dataPurged", { deleted });
1176
+ }
1177
+ this.afterUninstall();
1178
+ }
686
1179
  // Override these methods in subclasses
687
- async onSetup() {
1180
+ async onInstall() {
688
1181
  }
689
1182
  async onStart() {
690
1183
  }
691
1184
  async onStop() {
692
1185
  }
1186
+ async onUninstall(options) {
1187
+ }
693
1188
  // Hook management methods
694
1189
  addHook(resource, event, handler) {
695
1190
  if (!this.hooks.has(resource)) {
@@ -801,11 +1296,11 @@ class Plugin extends EventEmitter {
801
1296
  return value ?? null;
802
1297
  }
803
1298
  // Event emission methods
804
- beforeSetup() {
805
- this.emit("plugin.beforeSetup", /* @__PURE__ */ new Date());
1299
+ beforeInstall() {
1300
+ this.emit("plugin.beforeInstall", /* @__PURE__ */ new Date());
806
1301
  }
807
- afterSetup() {
808
- this.emit("plugin.afterSetup", /* @__PURE__ */ new Date());
1302
+ afterInstall() {
1303
+ this.emit("plugin.afterInstall", /* @__PURE__ */ new Date());
809
1304
  }
810
1305
  beforeStart() {
811
1306
  this.emit("plugin.beforeStart", /* @__PURE__ */ new Date());
@@ -819,6 +1314,12 @@ class Plugin extends EventEmitter {
819
1314
  afterStop() {
820
1315
  this.emit("plugin.afterStop", /* @__PURE__ */ new Date());
821
1316
  }
1317
+ beforeUninstall() {
1318
+ this.emit("plugin.beforeUninstall", /* @__PURE__ */ new Date());
1319
+ }
1320
+ afterUninstall() {
1321
+ this.emit("plugin.afterUninstall", /* @__PURE__ */ new Date());
1322
+ }
822
1323
  }
823
1324
 
824
1325
  const PluginObject = {
@@ -841,7 +1342,7 @@ class AuditPlugin extends Plugin {
841
1342
  ...options
842
1343
  };
843
1344
  }
844
- async onSetup() {
1345
+ async onInstall() {
845
1346
  const [ok, err, auditResource] = await tryFn(() => this.database.createResource({
846
1347
  name: "plg_audits",
847
1348
  attributes: {
@@ -1932,7 +2433,7 @@ class BackupPlugin extends Plugin {
1932
2433
  throw new Error("BackupPlugin: Invalid compression type. Use: none, gzip, brotli, deflate");
1933
2434
  }
1934
2435
  }
1935
- async onSetup() {
2436
+ async onInstall() {
1936
2437
  this.driver = createBackupDriver(this.config.driver, this.config.driverConfig);
1937
2438
  await this.driver.setup(this.database);
1938
2439
  await promises.mkdir(this.config.tempDir, { recursive: true });
@@ -3817,10 +4318,7 @@ class CachePlugin extends Plugin {
3817
4318
  verbose: options.verbose || false
3818
4319
  };
3819
4320
  }
3820
- async setup(database) {
3821
- await super.setup(database);
3822
- }
3823
- async onSetup() {
4321
+ async onInstall() {
3824
4322
  if (this.config.driver && typeof this.config.driver === "object") {
3825
4323
  this.driver = this.config.driver;
3826
4324
  } else if (this.config.driver === "memory") {
@@ -4940,7 +5438,20 @@ async function consolidateRecord(originalId, transactionResource, targetResource
4940
5438
  console.warn(`[EventualConsistency] ${errors.length} transactions failed to mark as applied`);
4941
5439
  }
4942
5440
  if (config.enableAnalytics && transactionsToUpdate.length > 0 && updateAnalyticsFn) {
4943
- await updateAnalyticsFn(transactionsToUpdate);
5441
+ const [analyticsOk, analyticsErr] = await tryFn(
5442
+ () => updateAnalyticsFn(transactionsToUpdate)
5443
+ );
5444
+ if (!analyticsOk) {
5445
+ console.error(
5446
+ `[EventualConsistency] ${config.resource}.${config.field} - CRITICAL: Analytics update failed for ${originalId}, but consolidation succeeded:`,
5447
+ {
5448
+ error: analyticsErr?.message || analyticsErr,
5449
+ stack: analyticsErr?.stack,
5450
+ originalId,
5451
+ transactionCount: transactionsToUpdate.length
5452
+ }
5453
+ );
5454
+ }
4944
5455
  }
4945
5456
  if (targetResource && targetResource.cache && typeof targetResource.cache.delete === "function") {
4946
5457
  try {
@@ -5236,9 +5747,18 @@ async function updateAnalytics(transactions, analyticsResource, config) {
5236
5747
  );
5237
5748
  }
5238
5749
  } catch (error) {
5239
- console.warn(
5240
- `[EventualConsistency] ${config.resource}.${config.field} - Analytics update error:`,
5241
- error.message
5750
+ console.error(
5751
+ `[EventualConsistency] CRITICAL: ${config.resource}.${config.field} - Analytics update failed:`,
5752
+ {
5753
+ error: error.message,
5754
+ stack: error.stack,
5755
+ field: config.field,
5756
+ resource: config.resource,
5757
+ transactionCount: transactions.length
5758
+ }
5759
+ );
5760
+ throw new Error(
5761
+ `Analytics update failed for ${config.resource}.${config.field}: ${error.message}`
5242
5762
  );
5243
5763
  }
5244
5764
  }
@@ -5290,6 +5810,7 @@ async function upsertAnalytics(period, cohort, transactions, analyticsResource,
5290
5810
  await tryFn(
5291
5811
  () => analyticsResource.insert({
5292
5812
  id,
5813
+ field: config.field,
5293
5814
  period,
5294
5815
  cohort,
5295
5816
  transactionCount,
@@ -5321,8 +5842,8 @@ function calculateOperationBreakdown(transactions) {
5321
5842
  async function rollupAnalytics(cohortHour, analyticsResource, config) {
5322
5843
  const cohortDate = cohortHour.substring(0, 10);
5323
5844
  const cohortMonth = cohortHour.substring(0, 7);
5324
- await rollupPeriod("day", cohortDate, cohortDate, analyticsResource);
5325
- await rollupPeriod("month", cohortMonth, cohortMonth, analyticsResource);
5845
+ await rollupPeriod("day", cohortDate, cohortDate, analyticsResource, config);
5846
+ await rollupPeriod("month", cohortMonth, cohortMonth, analyticsResource, config);
5326
5847
  }
5327
5848
  async function rollupPeriod(period, cohort, sourcePrefix, analyticsResource, config) {
5328
5849
  const sourcePeriod = period === "day" ? "hour" : "day";
@@ -5372,6 +5893,7 @@ async function rollupPeriod(period, cohort, sourcePrefix, analyticsResource, con
5372
5893
  await tryFn(
5373
5894
  () => analyticsResource.insert({
5374
5895
  id,
5896
+ field: config.field,
5375
5897
  period,
5376
5898
  cohort,
5377
5899
  transactionCount,
@@ -5731,7 +6253,7 @@ function addHelperMethods(resource, plugin, config) {
5731
6253
  };
5732
6254
  }
5733
6255
 
5734
- async function onSetup(database, fieldHandlers, completeFieldSetupFn, watchForResourceFn) {
6256
+ async function onInstall(database, fieldHandlers, completeFieldSetupFn, watchForResourceFn) {
5735
6257
  for (const [resourceName, resourceHandlers] of fieldHandlers) {
5736
6258
  const targetResource = database.resources[resourceName];
5737
6259
  if (!targetResource) {
@@ -5831,6 +6353,7 @@ async function createAnalyticsResource(handler, database, resourceName, fieldNam
5831
6353
  name: analyticsResourceName,
5832
6354
  attributes: {
5833
6355
  id: "string|required",
6356
+ field: "string|required",
5834
6357
  period: "string|required",
5835
6358
  cohort: "string|required",
5836
6359
  transactionCount: "number|required",
@@ -5928,10 +6451,10 @@ class EventualConsistencyPlugin extends Plugin {
5928
6451
  logInitialization(this.config, this.fieldHandlers, timezoneAutoDetected);
5929
6452
  }
5930
6453
  /**
5931
- * Setup hook - create resources and register helpers
6454
+ * Install hook - create resources and register helpers
5932
6455
  */
5933
- async onSetup() {
5934
- await onSetup(
6456
+ async onInstall() {
6457
+ await onInstall(
5935
6458
  this.database,
5936
6459
  this.fieldHandlers,
5937
6460
  (handler) => completeFieldSetup(handler, this.database, this.config, this),
@@ -6296,9 +6819,8 @@ class FullTextPlugin extends Plugin {
6296
6819
  };
6297
6820
  this.indexes = /* @__PURE__ */ new Map();
6298
6821
  }
6299
- async setup(database) {
6300
- this.database = database;
6301
- const [ok, err, indexResource] = await tryFn(() => database.createResource({
6822
+ async onInstall() {
6823
+ const [ok, err, indexResource] = await tryFn(() => this.database.createResource({
6302
6824
  name: "plg_fulltext_indexes",
6303
6825
  attributes: {
6304
6826
  id: "string|required",
@@ -6311,7 +6833,7 @@ class FullTextPlugin extends Plugin {
6311
6833
  lastUpdated: "string|required"
6312
6834
  }
6313
6835
  }));
6314
- this.indexResource = ok ? indexResource : database.resources.fulltext_indexes;
6836
+ this.indexResource = ok ? indexResource : this.database.resources.fulltext_indexes;
6315
6837
  await this.loadIndexes();
6316
6838
  this.installDatabaseHooks();
6317
6839
  this.installIndexingHooks();
@@ -6677,11 +7199,10 @@ class MetricsPlugin extends Plugin {
6677
7199
  };
6678
7200
  this.flushTimer = null;
6679
7201
  }
6680
- async setup(database) {
6681
- this.database = database;
7202
+ async onInstall() {
6682
7203
  if (typeof process !== "undefined" && process.env.NODE_ENV === "test") return;
6683
7204
  const [ok, err] = await tryFn(async () => {
6684
- const [ok1, err1, metricsResource] = await tryFn(() => database.createResource({
7205
+ const [ok1, err1, metricsResource] = await tryFn(() => this.database.createResource({
6685
7206
  name: "plg_metrics",
6686
7207
  attributes: {
6687
7208
  id: "string|required",
@@ -6697,8 +7218,8 @@ class MetricsPlugin extends Plugin {
6697
7218
  metadata: "json"
6698
7219
  }
6699
7220
  }));
6700
- this.metricsResource = ok1 ? metricsResource : database.resources.plg_metrics;
6701
- const [ok2, err2, errorsResource] = await tryFn(() => database.createResource({
7221
+ this.metricsResource = ok1 ? metricsResource : this.database.resources.plg_metrics;
7222
+ const [ok2, err2, errorsResource] = await tryFn(() => this.database.createResource({
6702
7223
  name: "plg_error_logs",
6703
7224
  attributes: {
6704
7225
  id: "string|required",
@@ -6709,8 +7230,8 @@ class MetricsPlugin extends Plugin {
6709
7230
  metadata: "json"
6710
7231
  }
6711
7232
  }));
6712
- this.errorsResource = ok2 ? errorsResource : database.resources.plg_error_logs;
6713
- const [ok3, err3, performanceResource] = await tryFn(() => database.createResource({
7233
+ this.errorsResource = ok2 ? errorsResource : this.database.resources.plg_error_logs;
7234
+ const [ok3, err3, performanceResource] = await tryFn(() => this.database.createResource({
6714
7235
  name: "plg_performance_logs",
6715
7236
  attributes: {
6716
7237
  id: "string|required",
@@ -6721,12 +7242,12 @@ class MetricsPlugin extends Plugin {
6721
7242
  metadata: "json"
6722
7243
  }
6723
7244
  }));
6724
- this.performanceResource = ok3 ? performanceResource : database.resources.plg_performance_logs;
7245
+ this.performanceResource = ok3 ? performanceResource : this.database.resources.plg_performance_logs;
6725
7246
  });
6726
7247
  if (!ok) {
6727
- this.metricsResource = database.resources.plg_metrics;
6728
- this.errorsResource = database.resources.plg_error_logs;
6729
- this.performanceResource = database.resources.plg_performance_logs;
7248
+ this.metricsResource = this.database.resources.plg_metrics;
7249
+ this.errorsResource = this.database.resources.plg_error_logs;
7250
+ this.performanceResource = this.database.resources.plg_performance_logs;
6730
7251
  }
6731
7252
  this.installDatabaseHooks();
6732
7253
  this.installMetricsHooks();
@@ -7304,14 +7825,14 @@ function createConsumer(driver, config) {
7304
7825
  return new ConsumerClass(config);
7305
7826
  }
7306
7827
 
7307
- class QueueConsumerPlugin {
7828
+ class QueueConsumerPlugin extends Plugin {
7308
7829
  constructor(options = {}) {
7830
+ super(options);
7309
7831
  this.options = options;
7310
7832
  this.driversConfig = Array.isArray(options.consumers) ? options.consumers : [];
7311
7833
  this.consumers = [];
7312
7834
  }
7313
- async setup(database) {
7314
- this.database = database;
7835
+ async onInstall() {
7315
7836
  for (const driverDef of this.driversConfig) {
7316
7837
  const { driver, config: driverConfig = {}, consumers: consumerDefs = [] } = driverDef;
7317
7838
  if (consumerDefs.length === 0 && driverDef.resources) {
@@ -8096,146 +8617,6 @@ class PostgresReplicator extends BaseReplicator {
8096
8617
  }
8097
8618
  }
8098
8619
 
8099
- function analyzeString(str) {
8100
- if (!str || typeof str !== "string") {
8101
- return { type: "none", safe: true };
8102
- }
8103
- let hasLatin1 = false;
8104
- let hasMultibyte = false;
8105
- let asciiCount = 0;
8106
- let latin1Count = 0;
8107
- let multibyteCount = 0;
8108
- for (let i = 0; i < str.length; i++) {
8109
- const code = str.charCodeAt(i);
8110
- if (code >= 32 && code <= 126) {
8111
- asciiCount++;
8112
- } else if (code < 32 || code === 127) {
8113
- hasMultibyte = true;
8114
- multibyteCount++;
8115
- } else if (code >= 128 && code <= 255) {
8116
- hasLatin1 = true;
8117
- latin1Count++;
8118
- } else {
8119
- hasMultibyte = true;
8120
- multibyteCount++;
8121
- }
8122
- }
8123
- if (!hasLatin1 && !hasMultibyte) {
8124
- return {
8125
- type: "ascii",
8126
- safe: true,
8127
- stats: { ascii: asciiCount, latin1: 0, multibyte: 0 }
8128
- };
8129
- }
8130
- if (hasMultibyte) {
8131
- const multibyteRatio = multibyteCount / str.length;
8132
- if (multibyteRatio > 0.3) {
8133
- return {
8134
- type: "base64",
8135
- safe: false,
8136
- reason: "high multibyte content",
8137
- stats: { ascii: asciiCount, latin1: latin1Count, multibyte: multibyteCount }
8138
- };
8139
- }
8140
- return {
8141
- type: "url",
8142
- safe: false,
8143
- reason: "contains multibyte characters",
8144
- stats: { ascii: asciiCount, latin1: latin1Count, multibyte: multibyteCount }
8145
- };
8146
- }
8147
- const latin1Ratio = latin1Count / str.length;
8148
- if (latin1Ratio > 0.5) {
8149
- return {
8150
- type: "base64",
8151
- safe: false,
8152
- reason: "high Latin-1 content",
8153
- stats: { ascii: asciiCount, latin1: latin1Count, multibyte: 0 }
8154
- };
8155
- }
8156
- return {
8157
- type: "url",
8158
- safe: false,
8159
- reason: "contains Latin-1 extended characters",
8160
- stats: { ascii: asciiCount, latin1: latin1Count, multibyte: 0 }
8161
- };
8162
- }
8163
- function metadataEncode(value) {
8164
- if (value === null) {
8165
- return { encoded: "null", encoding: "special" };
8166
- }
8167
- if (value === void 0) {
8168
- return { encoded: "undefined", encoding: "special" };
8169
- }
8170
- const stringValue = String(value);
8171
- const analysis = analyzeString(stringValue);
8172
- switch (analysis.type) {
8173
- case "none":
8174
- case "ascii":
8175
- return {
8176
- encoded: stringValue,
8177
- encoding: "none",
8178
- analysis
8179
- };
8180
- case "url":
8181
- return {
8182
- encoded: "u:" + encodeURIComponent(stringValue),
8183
- encoding: "url",
8184
- analysis
8185
- };
8186
- case "base64":
8187
- return {
8188
- encoded: "b:" + Buffer.from(stringValue, "utf8").toString("base64"),
8189
- encoding: "base64",
8190
- analysis
8191
- };
8192
- default:
8193
- return {
8194
- encoded: "b:" + Buffer.from(stringValue, "utf8").toString("base64"),
8195
- encoding: "base64",
8196
- analysis
8197
- };
8198
- }
8199
- }
8200
- function metadataDecode(value) {
8201
- if (value === "null") {
8202
- return null;
8203
- }
8204
- if (value === "undefined") {
8205
- return void 0;
8206
- }
8207
- if (value === null || value === void 0 || typeof value !== "string") {
8208
- return value;
8209
- }
8210
- if (value.startsWith("u:")) {
8211
- if (value.length === 2) return value;
8212
- try {
8213
- return decodeURIComponent(value.substring(2));
8214
- } catch (err) {
8215
- return value;
8216
- }
8217
- }
8218
- if (value.startsWith("b:")) {
8219
- if (value.length === 2) return value;
8220
- try {
8221
- const decoded = Buffer.from(value.substring(2), "base64").toString("utf8");
8222
- return decoded;
8223
- } catch (err) {
8224
- return value;
8225
- }
8226
- }
8227
- if (value.length > 0 && /^[A-Za-z0-9+/]+=*$/.test(value)) {
8228
- try {
8229
- const decoded = Buffer.from(value, "base64").toString("utf8");
8230
- if (/[^\x00-\x7F]/.test(decoded) && Buffer.from(decoded, "utf8").toString("base64") === value) {
8231
- return decoded;
8232
- }
8233
- } catch {
8234
- }
8235
- }
8236
- return value;
8237
- }
8238
-
8239
8620
  const S3_DEFAULT_REGION = "us-east-1";
8240
8621
  const S3_DEFAULT_ENDPOINT = "https://s3.us-east-1.amazonaws.com";
8241
8622
  class ConnectionString {
@@ -12352,7 +12733,7 @@ class Database extends EventEmitter {
12352
12733
  this.id = idGenerator(7);
12353
12734
  this.version = "1";
12354
12735
  this.s3dbVersion = (() => {
12355
- const [ok, err, version] = tryFn(() => true ? "10.0.19" : "latest");
12736
+ const [ok, err, version] = tryFn(() => true ? "11.0.0" : "latest");
12356
12737
  return ok ? version : "latest";
12357
12738
  })();
12358
12739
  this.resources = {};
@@ -12652,18 +13033,14 @@ class Database extends EventEmitter {
12652
13033
  const db = this;
12653
13034
  if (!lodashEs.isEmpty(this.pluginList)) {
12654
13035
  const plugins = this.pluginList.map((p) => lodashEs.isFunction(p) ? new p(this) : p);
12655
- const setupProms = plugins.map(async (plugin) => {
12656
- if (plugin.beforeSetup) await plugin.beforeSetup();
12657
- await plugin.setup(db);
12658
- if (plugin.afterSetup) await plugin.afterSetup();
13036
+ const installProms = plugins.map(async (plugin) => {
13037
+ await plugin.install(db);
12659
13038
  const pluginName = this._getPluginName(plugin);
12660
13039
  this.pluginRegistry[pluginName] = plugin;
12661
13040
  });
12662
- await Promise.all(setupProms);
13041
+ await Promise.all(installProms);
12663
13042
  const startProms = plugins.map(async (plugin) => {
12664
- if (plugin.beforeStart) await plugin.beforeStart();
12665
13043
  await plugin.start();
12666
- if (plugin.afterStart) await plugin.afterStart();
12667
13044
  });
12668
13045
  await Promise.all(startProms);
12669
13046
  }
@@ -12684,11 +13061,37 @@ class Database extends EventEmitter {
12684
13061
  const pluginName = this._getPluginName(plugin, name);
12685
13062
  this.plugins[pluginName] = plugin;
12686
13063
  if (this.isConnected()) {
12687
- await plugin.setup(this);
13064
+ await plugin.install(this);
12688
13065
  await plugin.start();
12689
13066
  }
12690
13067
  return plugin;
12691
13068
  }
13069
+ /**
13070
+ * Uninstall a plugin and optionally purge its data
13071
+ * @param {string} name - Plugin name
13072
+ * @param {Object} options - Uninstall options
13073
+ * @param {boolean} options.purgeData - Delete all plugin data from S3 (default: false)
13074
+ */
13075
+ async uninstallPlugin(name, options = {}) {
13076
+ const pluginName = name.toLowerCase().replace("plugin", "");
13077
+ const plugin = this.plugins[pluginName] || this.pluginRegistry[pluginName];
13078
+ if (!plugin) {
13079
+ throw new Error(`Plugin '${name}' not found`);
13080
+ }
13081
+ if (plugin.stop) {
13082
+ await plugin.stop();
13083
+ }
13084
+ if (plugin.uninstall) {
13085
+ await plugin.uninstall(options);
13086
+ }
13087
+ delete this.plugins[pluginName];
13088
+ delete this.pluginRegistry[pluginName];
13089
+ const index = this.pluginList.indexOf(plugin);
13090
+ if (index > -1) {
13091
+ this.pluginList.splice(index, 1);
13092
+ }
13093
+ this.emit("plugin.uninstalled", { name: pluginName, plugin });
13094
+ }
12692
13095
  async uploadMetadataFile() {
12693
13096
  const metadata = {
12694
13097
  version: this.version,
@@ -14155,10 +14558,9 @@ class ReplicatorPlugin extends Plugin {
14155
14558
  resource.on("delete", deleteHandler);
14156
14559
  this.eventListenersInstalled.add(resource.name);
14157
14560
  }
14158
- async setup(database) {
14159
- this.database = database;
14561
+ async onInstall() {
14160
14562
  if (this.config.persistReplicatorLog) {
14161
- const [ok, err, logResource] = await tryFn(() => database.createResource({
14563
+ const [ok, err, logResource] = await tryFn(() => this.database.createResource({
14162
14564
  name: this.config.replicatorLogResource || "plg_replicator_logs",
14163
14565
  attributes: {
14164
14566
  id: "string|required",
@@ -14173,14 +14575,14 @@ class ReplicatorPlugin extends Plugin {
14173
14575
  if (ok) {
14174
14576
  this.replicatorLogResource = logResource;
14175
14577
  } else {
14176
- this.replicatorLogResource = database.resources[this.config.replicatorLogResource || "plg_replicator_logs"];
14578
+ this.replicatorLogResource = this.database.resources[this.config.replicatorLogResource || "plg_replicator_logs"];
14177
14579
  }
14178
14580
  }
14179
- await this.initializeReplicators(database);
14581
+ await this.initializeReplicators(this.database);
14180
14582
  this.installDatabaseHooks();
14181
- for (const resource of Object.values(database.resources)) {
14583
+ for (const resource of Object.values(this.database.resources)) {
14182
14584
  if (resource.name !== (this.config.replicatorLogResource || "plg_replicator_logs")) {
14183
- this.installEventListeners(resource, database, this);
14585
+ this.installEventListeners(resource, this.database, this);
14184
14586
  }
14185
14587
  }
14186
14588
  }
@@ -14224,8 +14626,8 @@ class ReplicatorPlugin extends Plugin {
14224
14626
  }
14225
14627
  }
14226
14628
  async uploadMetadataFile(database) {
14227
- if (typeof database.uploadMetadataFile === "function") {
14228
- await database.uploadMetadataFile();
14629
+ if (typeof this.database.uploadMetadataFile === "function") {
14630
+ await this.database.uploadMetadataFile();
14229
14631
  }
14230
14632
  }
14231
14633
  async retryWithBackoff(operation, maxRetries = 3) {
@@ -14599,7 +15001,7 @@ class S3QueuePlugin extends Plugin {
14599
15001
  this.cacheCleanupInterval = null;
14600
15002
  this.lockCleanupInterval = null;
14601
15003
  }
14602
- async onSetup() {
15004
+ async onInstall() {
14603
15005
  this.targetResource = this.database.resources[this.config.resource];
14604
15006
  if (!this.targetResource) {
14605
15007
  throw new Error(`S3QueuePlugin: resource '${this.config.resource}' not found`);
@@ -15160,8 +15562,7 @@ class SchedulerPlugin extends Plugin {
15160
15562
  if (parts.length !== 5) return false;
15161
15563
  return true;
15162
15564
  }
15163
- async setup(database) {
15164
- this.database = database;
15565
+ async onInstall() {
15165
15566
  await this._createLockResource();
15166
15567
  if (this.config.persistJobs) {
15167
15568
  await this._createJobHistoryResource();
@@ -15719,8 +16120,7 @@ class StateMachinePlugin extends Plugin {
15719
16120
  }
15720
16121
  }
15721
16122
  }
15722
- async setup(database) {
15723
- this.database = database;
16123
+ async onInstall() {
15724
16124
  if (this.config.persistTransitions) {
15725
16125
  await this._createStateResources();
15726
16126
  }