s3db.js 12.2.1 → 12.2.3

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
@@ -6976,18 +6976,13 @@ class CachePlugin extends Plugin {
6976
6976
  }
6977
6977
  }
6978
6978
 
6979
- const CostsPlugin = {
6980
- async setup(db, options = {}) {
6981
- if (!db || !db.client) {
6982
- return;
6983
- }
6984
- this.client = db.client;
6985
- this.options = {
6986
- considerFreeTier: false,
6987
- // Flag to consider AWS free tier in calculations
6988
- region: "us-east-1",
6989
- // AWS region for pricing (future use)
6990
- ...options
6979
+ class CostsPlugin extends Plugin {
6980
+ constructor(config = {}) {
6981
+ super(config);
6982
+ this.config = {
6983
+ considerFreeTier: config.considerFreeTier !== void 0 ? config.considerFreeTier : false,
6984
+ region: config.region || "us-east-1",
6985
+ ...config
6991
6986
  };
6992
6987
  this.map = {
6993
6988
  PutObjectCommand: "put",
@@ -7081,14 +7076,20 @@ const CostsPlugin = {
7081
7076
  // Data transfer out cost
7082
7077
  }
7083
7078
  };
7079
+ }
7080
+ async onInstall() {
7081
+ if (!this.database || !this.database.client) {
7082
+ return;
7083
+ }
7084
+ this.client = this.database.client;
7084
7085
  this.client.costs = JSON.parse(JSON.stringify(this.costs));
7085
- },
7086
- async start() {
7086
+ }
7087
+ async onStart() {
7087
7088
  if (this.client) {
7088
7089
  this.client.on("command.response", (name, response, input) => this.addRequest(name, this.map[name], response, input));
7089
7090
  this.client.on("command.error", (name, response, input) => this.addRequest(name, this.map[name], response, input));
7090
7091
  }
7091
- },
7092
+ }
7092
7093
  addRequest(name, method, response = {}, input = {}) {
7093
7094
  if (!method) return;
7094
7095
  this.costs.requests.totalEvents++;
@@ -7128,7 +7129,7 @@ const CostsPlugin = {
7128
7129
  this.client.costs.requests.subtotal += requestCost;
7129
7130
  }
7130
7131
  this.updateTotal();
7131
- },
7132
+ }
7132
7133
  trackStorage(bytes) {
7133
7134
  this.costs.storage.totalBytes += bytes;
7134
7135
  this.costs.storage.totalGB = this.costs.storage.totalBytes / (1024 * 1024 * 1024);
@@ -7139,7 +7140,7 @@ const CostsPlugin = {
7139
7140
  this.client.costs.storage.subtotal = this.calculateStorageCost(this.client.costs.storage);
7140
7141
  }
7141
7142
  this.updateTotal();
7142
- },
7143
+ }
7143
7144
  trackDataTransferIn(bytes) {
7144
7145
  this.costs.dataTransfer.inBytes += bytes;
7145
7146
  this.costs.dataTransfer.inGB = this.costs.dataTransfer.inBytes / (1024 * 1024 * 1024);
@@ -7148,7 +7149,7 @@ const CostsPlugin = {
7148
7149
  this.client.costs.dataTransfer.inGB = this.client.costs.dataTransfer.inBytes / (1024 * 1024 * 1024);
7149
7150
  }
7150
7151
  this.updateTotal();
7151
- },
7152
+ }
7152
7153
  trackDataTransferOut(bytes) {
7153
7154
  this.costs.dataTransfer.outBytes += bytes;
7154
7155
  this.costs.dataTransfer.outGB = this.costs.dataTransfer.outBytes / (1024 * 1024 * 1024);
@@ -7159,7 +7160,7 @@ const CostsPlugin = {
7159
7160
  this.client.costs.dataTransfer.subtotal = this.calculateDataTransferCost(this.client.costs.dataTransfer);
7160
7161
  }
7161
7162
  this.updateTotal();
7162
- },
7163
+ }
7163
7164
  calculateStorageCost(storage) {
7164
7165
  const totalGB = storage.totalGB;
7165
7166
  let cost = 0;
@@ -7178,11 +7179,11 @@ const CostsPlugin = {
7178
7179
  }
7179
7180
  }
7180
7181
  return cost;
7181
- },
7182
+ }
7182
7183
  calculateDataTransferCost(dataTransfer) {
7183
7184
  let totalGB = dataTransfer.outGB;
7184
7185
  let cost = 0;
7185
- if (this.options && this.options.considerFreeTier) {
7186
+ if (this.config && this.config.considerFreeTier) {
7186
7187
  const freeTierRemaining = dataTransfer.freeTierGB - dataTransfer.freeTierUsed;
7187
7188
  if (freeTierRemaining > 0 && totalGB > 0) {
7188
7189
  const gbToDeduct = Math.min(totalGB, freeTierRemaining);
@@ -7205,14 +7206,14 @@ const CostsPlugin = {
7205
7206
  }
7206
7207
  }
7207
7208
  return cost;
7208
- },
7209
+ }
7209
7210
  updateTotal() {
7210
7211
  this.costs.total = this.costs.requests.subtotal + this.costs.storage.subtotal + this.costs.dataTransfer.subtotal;
7211
7212
  if (this.client && this.client.costs) {
7212
7213
  this.client.costs.total = this.client.costs.requests.subtotal + this.client.costs.storage.subtotal + this.client.costs.dataTransfer.subtotal;
7213
7214
  }
7214
7215
  }
7215
- };
7216
+ }
7216
7217
 
7217
7218
  function createConfig(options, detectedTimezone) {
7218
7219
  const consolidation = options.consolidation || {};
@@ -10622,6 +10623,605 @@ class FullTextPlugin extends Plugin {
10622
10623
  }
10623
10624
  }
10624
10625
 
10626
+ class GeoPlugin extends Plugin {
10627
+ constructor(config = {}) {
10628
+ super(config);
10629
+ this.resources = config.resources || {};
10630
+ this.verbose = config.verbose !== void 0 ? config.verbose : false;
10631
+ this.base32 = "0123456789bcdefghjkmnpqrstuvwxyz";
10632
+ }
10633
+ /**
10634
+ * Install the plugin
10635
+ */
10636
+ async install(database) {
10637
+ await super.install(database);
10638
+ for (const [resourceName, config] of Object.entries(this.resources)) {
10639
+ await this._setupResource(resourceName, config);
10640
+ }
10641
+ this.database.addHook("afterCreateResource", async (context) => {
10642
+ const { resource, config: resourceConfig } = context;
10643
+ const geoConfig = this.resources[resource.name];
10644
+ if (geoConfig) {
10645
+ await this._setupResource(resource.name, geoConfig);
10646
+ }
10647
+ });
10648
+ if (this.verbose) {
10649
+ console.log(`[GeoPlugin] Installed with ${Object.keys(this.resources).length} resources`);
10650
+ }
10651
+ this.emit("installed", {
10652
+ plugin: "GeoPlugin",
10653
+ resources: Object.keys(this.resources)
10654
+ });
10655
+ }
10656
+ /**
10657
+ * Setup a resource with geo capabilities
10658
+ */
10659
+ async _setupResource(resourceName, config) {
10660
+ if (!this.database.resources[resourceName]) {
10661
+ if (this.verbose) {
10662
+ console.warn(`[GeoPlugin] Resource "${resourceName}" not found, will setup when created`);
10663
+ }
10664
+ return;
10665
+ }
10666
+ const resource = this.database.resources[resourceName];
10667
+ if (!resource || typeof resource.addHook !== "function") {
10668
+ if (this.verbose) {
10669
+ console.warn(`[GeoPlugin] Resource "${resourceName}" not found or invalid`);
10670
+ }
10671
+ return;
10672
+ }
10673
+ if (!config.latField || !config.lonField) {
10674
+ throw new Error(
10675
+ `[GeoPlugin] Resource "${resourceName}" must have "latField" and "lonField" configured`
10676
+ );
10677
+ }
10678
+ if (!config.precision || config.precision < 1 || config.precision > 12) {
10679
+ config.precision = 5;
10680
+ }
10681
+ resource._geoConfig = config;
10682
+ const latField = resource.attributes[config.latField];
10683
+ const lonField = resource.attributes[config.lonField];
10684
+ const isLatOptional = typeof latField === "object" && latField.optional === true;
10685
+ const isLonOptional = typeof lonField === "object" && lonField.optional === true;
10686
+ const areCoordinatesOptional = isLatOptional || isLonOptional;
10687
+ const geohashType = areCoordinatesOptional ? "string|optional" : "string";
10688
+ let needsUpdate = false;
10689
+ const newAttributes = { ...resource.attributes };
10690
+ if (config.addGeohash && !newAttributes.geohash) {
10691
+ newAttributes.geohash = geohashType;
10692
+ needsUpdate = true;
10693
+ }
10694
+ if (!newAttributes._geohash) {
10695
+ newAttributes._geohash = geohashType;
10696
+ needsUpdate = true;
10697
+ }
10698
+ if (config.zoomLevels && Array.isArray(config.zoomLevels)) {
10699
+ for (const zoom of config.zoomLevels) {
10700
+ const fieldName = `_geohash_zoom${zoom}`;
10701
+ if (!newAttributes[fieldName]) {
10702
+ newAttributes[fieldName] = geohashType;
10703
+ needsUpdate = true;
10704
+ }
10705
+ }
10706
+ }
10707
+ if (needsUpdate) {
10708
+ resource.updateAttributes(newAttributes);
10709
+ if (this.database.uploadMetadataFile) {
10710
+ await this.database.uploadMetadataFile();
10711
+ }
10712
+ }
10713
+ if (config.usePartitions) {
10714
+ await this._setupPartitions(resource, config);
10715
+ }
10716
+ this._addHooks(resource, config);
10717
+ this._addHelperMethods(resource, config);
10718
+ if (this.verbose) {
10719
+ console.log(
10720
+ `[GeoPlugin] Setup resource "${resourceName}" with precision ${config.precision} (~${this._getPrecisionDistance(config.precision)}km cells)` + (config.usePartitions ? " [Partitions enabled]" : "")
10721
+ );
10722
+ }
10723
+ }
10724
+ /**
10725
+ * Setup geohash partitions for efficient spatial queries
10726
+ * Creates multiple zoom-level partitions if zoomLevels configured
10727
+ */
10728
+ async _setupPartitions(resource, config) {
10729
+ const updatedConfig = { ...resource.config };
10730
+ updatedConfig.partitions = updatedConfig.partitions || {};
10731
+ let partitionsCreated = 0;
10732
+ if (config.zoomLevels && Array.isArray(config.zoomLevels)) {
10733
+ for (const zoom of config.zoomLevels) {
10734
+ const partitionName = `byGeohashZoom${zoom}`;
10735
+ const fieldName = `_geohash_zoom${zoom}`;
10736
+ if (!updatedConfig.partitions[partitionName]) {
10737
+ updatedConfig.partitions[partitionName] = {
10738
+ fields: {
10739
+ [fieldName]: "string"
10740
+ }
10741
+ };
10742
+ partitionsCreated++;
10743
+ if (this.verbose) {
10744
+ console.log(
10745
+ `[GeoPlugin] Created ${partitionName} partition for "${resource.name}" (precision ${zoom}, ~${this._getPrecisionDistance(zoom)}km cells)`
10746
+ );
10747
+ }
10748
+ }
10749
+ }
10750
+ } else {
10751
+ const hasGeohashPartition = resource.config.partitions && resource.config.partitions.byGeohash;
10752
+ if (!hasGeohashPartition) {
10753
+ updatedConfig.partitions.byGeohash = {
10754
+ fields: {
10755
+ _geohash: "string"
10756
+ }
10757
+ };
10758
+ partitionsCreated++;
10759
+ if (this.verbose) {
10760
+ console.log(`[GeoPlugin] Created byGeohash partition for "${resource.name}"`);
10761
+ }
10762
+ }
10763
+ }
10764
+ if (partitionsCreated > 0) {
10765
+ resource.config = updatedConfig;
10766
+ resource.setupPartitionHooks();
10767
+ if (this.database.uploadMetadataFile) {
10768
+ await this.database.uploadMetadataFile();
10769
+ }
10770
+ }
10771
+ }
10772
+ /**
10773
+ * Add hooks to automatically calculate geohash at all zoom levels
10774
+ */
10775
+ _addHooks(resource, config) {
10776
+ const calculateGeohash = async (data) => {
10777
+ const lat = data[config.latField];
10778
+ const lon = data[config.lonField];
10779
+ if (lat !== void 0 && lon !== void 0) {
10780
+ const geohash = this.encodeGeohash(lat, lon, config.precision);
10781
+ if (config.addGeohash) {
10782
+ data.geohash = geohash;
10783
+ }
10784
+ data._geohash = geohash;
10785
+ if (config.zoomLevels && Array.isArray(config.zoomLevels)) {
10786
+ for (const zoom of config.zoomLevels) {
10787
+ const zoomGeohash = this.encodeGeohash(lat, lon, zoom);
10788
+ data[`_geohash_zoom${zoom}`] = zoomGeohash;
10789
+ }
10790
+ }
10791
+ }
10792
+ return data;
10793
+ };
10794
+ resource.addHook("beforeInsert", calculateGeohash);
10795
+ resource.addHook("beforeUpdate", calculateGeohash);
10796
+ }
10797
+ /**
10798
+ * Add helper methods to resource
10799
+ */
10800
+ _addHelperMethods(resource, config) {
10801
+ const plugin = this;
10802
+ resource.findNearby = async function({ lat, lon, radius = 10, limit = 100 }) {
10803
+ if (lat === void 0 || lon === void 0) {
10804
+ throw new Error("lat and lon are required for findNearby");
10805
+ }
10806
+ const longitude = lon;
10807
+ let allRecords = [];
10808
+ if (config.usePartitions) {
10809
+ let partitionName, fieldName, precision;
10810
+ if (config.zoomLevels && config.zoomLevels.length > 0) {
10811
+ const optimalZoom = plugin._selectOptimalZoom(config.zoomLevels, radius);
10812
+ partitionName = `byGeohashZoom${optimalZoom}`;
10813
+ fieldName = `_geohash_zoom${optimalZoom}`;
10814
+ precision = optimalZoom;
10815
+ if (plugin.verbose) {
10816
+ console.log(
10817
+ `[GeoPlugin] Auto-selected zoom${optimalZoom} (${plugin._getPrecisionDistance(optimalZoom)}km cells) for ${radius}km radius query`
10818
+ );
10819
+ }
10820
+ } else {
10821
+ partitionName = "byGeohash";
10822
+ fieldName = "_geohash";
10823
+ precision = config.precision;
10824
+ }
10825
+ if (this.config.partitions?.[partitionName]) {
10826
+ const centerGeohash = plugin.encodeGeohash(lat, longitude, precision);
10827
+ const neighbors = plugin.getNeighbors(centerGeohash);
10828
+ const geohashesToSearch = [centerGeohash, ...neighbors];
10829
+ const partitionResults = await Promise.all(
10830
+ geohashesToSearch.map(async (geohash) => {
10831
+ const [ok, err, records] = await tryFn(async () => {
10832
+ return await this.listPartition({
10833
+ partition: partitionName,
10834
+ partitionValues: { [fieldName]: geohash },
10835
+ limit: limit * 2
10836
+ });
10837
+ });
10838
+ return ok ? records : [];
10839
+ })
10840
+ );
10841
+ allRecords = partitionResults.flat();
10842
+ if (plugin.verbose) {
10843
+ console.log(
10844
+ `[GeoPlugin] findNearby searched ${geohashesToSearch.length} ${partitionName} partitions, found ${allRecords.length} candidates`
10845
+ );
10846
+ }
10847
+ } else {
10848
+ allRecords = await this.list({ limit: limit * 10 });
10849
+ }
10850
+ } else {
10851
+ allRecords = await this.list({ limit: limit * 10 });
10852
+ }
10853
+ const withDistances = allRecords.map((record) => {
10854
+ const recordLat = record[config.latField];
10855
+ const recordLon = record[config.lonField];
10856
+ if (recordLat === void 0 || recordLon === void 0) {
10857
+ return null;
10858
+ }
10859
+ const distance = plugin.calculateDistance(lat, longitude, recordLat, recordLon);
10860
+ return {
10861
+ ...record,
10862
+ _distance: distance
10863
+ };
10864
+ }).filter((record) => record !== null && record._distance <= radius).sort((a, b) => a._distance - b._distance).slice(0, limit);
10865
+ return withDistances;
10866
+ };
10867
+ resource.findInBounds = async function({ north, south, east, west, limit = 100 }) {
10868
+ if (north === void 0 || south === void 0 || east === void 0 || west === void 0) {
10869
+ throw new Error("north, south, east, west are required for findInBounds");
10870
+ }
10871
+ let allRecords = [];
10872
+ if (config.usePartitions) {
10873
+ let partitionName, precision;
10874
+ if (config.zoomLevels && config.zoomLevels.length > 0) {
10875
+ const centerLat = (north + south) / 2;
10876
+ const centerLon = (east + west) / 2;
10877
+ const latRadius = plugin.calculateDistance(centerLat, centerLon, north, centerLon);
10878
+ const lonRadius = plugin.calculateDistance(centerLat, centerLon, centerLat, east);
10879
+ const approximateRadius = Math.max(latRadius, lonRadius);
10880
+ const optimalZoom = plugin._selectOptimalZoom(config.zoomLevels, approximateRadius);
10881
+ partitionName = `byGeohashZoom${optimalZoom}`;
10882
+ precision = optimalZoom;
10883
+ if (plugin.verbose) {
10884
+ console.log(
10885
+ `[GeoPlugin] Auto-selected zoom${optimalZoom} (${plugin._getPrecisionDistance(optimalZoom)}km cells) for ${approximateRadius.toFixed(1)}km bounding box`
10886
+ );
10887
+ }
10888
+ } else {
10889
+ partitionName = "byGeohash";
10890
+ precision = config.precision;
10891
+ }
10892
+ if (this.config.partitions?.[partitionName]) {
10893
+ const geohashesToSearch = plugin._getGeohashesInBounds({
10894
+ north,
10895
+ south,
10896
+ east,
10897
+ west,
10898
+ precision
10899
+ });
10900
+ const partitionResults = await Promise.all(
10901
+ geohashesToSearch.map(async (geohash) => {
10902
+ const [ok, err, records] = await tryFn(async () => {
10903
+ const fieldName = config.zoomLevels ? `_geohash_zoom${precision}` : "_geohash";
10904
+ return await this.listPartition({
10905
+ partition: partitionName,
10906
+ partitionValues: { [fieldName]: geohash },
10907
+ limit: limit * 2
10908
+ });
10909
+ });
10910
+ return ok ? records : [];
10911
+ })
10912
+ );
10913
+ allRecords = partitionResults.flat();
10914
+ if (plugin.verbose) {
10915
+ console.log(
10916
+ `[GeoPlugin] findInBounds searched ${geohashesToSearch.length} ${partitionName} partitions, found ${allRecords.length} candidates`
10917
+ );
10918
+ }
10919
+ } else {
10920
+ allRecords = await this.list({ limit: limit * 10 });
10921
+ }
10922
+ } else {
10923
+ allRecords = await this.list({ limit: limit * 10 });
10924
+ }
10925
+ const inBounds = allRecords.filter((record) => {
10926
+ const lat = record[config.latField];
10927
+ const lon = record[config.lonField];
10928
+ if (lat === void 0 || lon === void 0) {
10929
+ return false;
10930
+ }
10931
+ return lat <= north && lat >= south && lon <= east && lon >= west;
10932
+ }).slice(0, limit);
10933
+ return inBounds;
10934
+ };
10935
+ resource.getDistance = async function(id1, id2) {
10936
+ let record1, record2;
10937
+ try {
10938
+ [record1, record2] = await Promise.all([
10939
+ this.get(id1),
10940
+ this.get(id2)
10941
+ ]);
10942
+ } catch (err) {
10943
+ if (err.name === "NoSuchKey" || err.message?.includes("No such key")) {
10944
+ throw new Error("One or both records not found");
10945
+ }
10946
+ throw err;
10947
+ }
10948
+ if (!record1 || !record2) {
10949
+ throw new Error("One or both records not found");
10950
+ }
10951
+ const lat1 = record1[config.latField];
10952
+ const lon1 = record1[config.lonField];
10953
+ const lat2 = record2[config.latField];
10954
+ const lon2 = record2[config.lonField];
10955
+ if (lat1 === void 0 || lon1 === void 0 || lat2 === void 0 || lon2 === void 0) {
10956
+ throw new Error("One or both records missing coordinates");
10957
+ }
10958
+ const distance = plugin.calculateDistance(lat1, lon1, lat2, lon2);
10959
+ return {
10960
+ distance,
10961
+ unit: "km",
10962
+ from: id1,
10963
+ to: id2
10964
+ };
10965
+ };
10966
+ }
10967
+ /**
10968
+ * Encode coordinates to geohash
10969
+ * @param {number} latitude - Latitude (-90 to 90)
10970
+ * @param {number} longitude - Longitude (-180 to 180)
10971
+ * @param {number} precision - Number of characters in geohash
10972
+ * @returns {string} Geohash string
10973
+ */
10974
+ encodeGeohash(latitude, longitude, precision = 5) {
10975
+ let idx = 0;
10976
+ let bit = 0;
10977
+ let evenBit = true;
10978
+ let geohash = "";
10979
+ let latMin = -90;
10980
+ let latMax = 90;
10981
+ let lonMin = -180;
10982
+ let lonMax = 180;
10983
+ while (geohash.length < precision) {
10984
+ if (evenBit) {
10985
+ const lonMid = (lonMin + lonMax) / 2;
10986
+ if (longitude > lonMid) {
10987
+ idx |= 1 << 4 - bit;
10988
+ lonMin = lonMid;
10989
+ } else {
10990
+ lonMax = lonMid;
10991
+ }
10992
+ } else {
10993
+ const latMid = (latMin + latMax) / 2;
10994
+ if (latitude > latMid) {
10995
+ idx |= 1 << 4 - bit;
10996
+ latMin = latMid;
10997
+ } else {
10998
+ latMax = latMid;
10999
+ }
11000
+ }
11001
+ evenBit = !evenBit;
11002
+ if (bit < 4) {
11003
+ bit++;
11004
+ } else {
11005
+ geohash += this.base32[idx];
11006
+ bit = 0;
11007
+ idx = 0;
11008
+ }
11009
+ }
11010
+ return geohash;
11011
+ }
11012
+ /**
11013
+ * Decode geohash to coordinates
11014
+ * @param {string} geohash - Geohash string
11015
+ * @returns {Object} { latitude, longitude, error }
11016
+ */
11017
+ decodeGeohash(geohash) {
11018
+ let evenBit = true;
11019
+ let latMin = -90;
11020
+ let latMax = 90;
11021
+ let lonMin = -180;
11022
+ let lonMax = 180;
11023
+ for (let i = 0; i < geohash.length; i++) {
11024
+ const chr = geohash[i];
11025
+ const idx = this.base32.indexOf(chr);
11026
+ if (idx === -1) {
11027
+ throw new Error(`Invalid geohash character: ${chr}`);
11028
+ }
11029
+ for (let n = 4; n >= 0; n--) {
11030
+ const bitN = idx >> n & 1;
11031
+ if (evenBit) {
11032
+ const lonMid = (lonMin + lonMax) / 2;
11033
+ if (bitN === 1) {
11034
+ lonMin = lonMid;
11035
+ } else {
11036
+ lonMax = lonMid;
11037
+ }
11038
+ } else {
11039
+ const latMid = (latMin + latMax) / 2;
11040
+ if (bitN === 1) {
11041
+ latMin = latMid;
11042
+ } else {
11043
+ latMax = latMid;
11044
+ }
11045
+ }
11046
+ evenBit = !evenBit;
11047
+ }
11048
+ }
11049
+ const latitude = (latMin + latMax) / 2;
11050
+ const longitude = (lonMin + lonMax) / 2;
11051
+ return {
11052
+ latitude,
11053
+ longitude,
11054
+ error: {
11055
+ latitude: latMax - latMin,
11056
+ longitude: lonMax - lonMin
11057
+ }
11058
+ };
11059
+ }
11060
+ /**
11061
+ * Calculate distance between two coordinates using Haversine formula
11062
+ * @param {number} lat1 - Latitude of point 1
11063
+ * @param {number} lon1 - Longitude of point 1
11064
+ * @param {number} lat2 - Latitude of point 2
11065
+ * @param {number} lon2 - Longitude of point 2
11066
+ * @returns {number} Distance in kilometers
11067
+ */
11068
+ calculateDistance(lat1, lon1, lat2, lon2) {
11069
+ const R = 6371;
11070
+ const dLat = this._toRadians(lat2 - lat1);
11071
+ const dLon = this._toRadians(lon2 - lon1);
11072
+ const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(this._toRadians(lat1)) * Math.cos(this._toRadians(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
11073
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
11074
+ return R * c;
11075
+ }
11076
+ /**
11077
+ * Get geohash neighbors (8 surrounding cells)
11078
+ * @param {string} geohash - Center geohash
11079
+ * @returns {Array<string>} Array of 8 neighboring geohashes
11080
+ */
11081
+ getNeighbors(geohash) {
11082
+ const decoded = this.decodeGeohash(geohash);
11083
+ const { latitude, longitude, error } = decoded;
11084
+ const latStep = error.latitude;
11085
+ const lonStep = error.longitude;
11086
+ const neighbors = [];
11087
+ const directions = [
11088
+ [-latStep, -lonStep],
11089
+ // SW
11090
+ [-latStep, 0],
11091
+ // S
11092
+ [-latStep, lonStep],
11093
+ // SE
11094
+ [0, -lonStep],
11095
+ // W
11096
+ [0, lonStep],
11097
+ // E
11098
+ [latStep, -lonStep],
11099
+ // NW
11100
+ [latStep, 0],
11101
+ // N
11102
+ [latStep, lonStep]
11103
+ // NE
11104
+ ];
11105
+ for (const [latDelta, lonDelta] of directions) {
11106
+ const neighborHash = this.encodeGeohash(
11107
+ latitude + latDelta,
11108
+ longitude + lonDelta,
11109
+ geohash.length
11110
+ );
11111
+ neighbors.push(neighborHash);
11112
+ }
11113
+ return neighbors;
11114
+ }
11115
+ /**
11116
+ * Get all geohashes that cover a bounding box
11117
+ * @param {Object} bounds - Bounding box { north, south, east, west, precision }
11118
+ * @returns {Array<string>} Array of unique geohashes covering the area
11119
+ */
11120
+ _getGeohashesInBounds({ north, south, east, west, precision }) {
11121
+ const geohashes = /* @__PURE__ */ new Set();
11122
+ const cellSize = this._getPrecisionDistance(precision);
11123
+ const latStep = cellSize / 111;
11124
+ const lonStep = cellSize / (111 * Math.cos(this._toRadians((north + south) / 2)));
11125
+ for (let lat = south; lat <= north; lat += latStep) {
11126
+ for (let lon = west; lon <= east; lon += lonStep) {
11127
+ const geohash = this.encodeGeohash(lat, lon, precision);
11128
+ geohashes.add(geohash);
11129
+ }
11130
+ }
11131
+ const corners = [
11132
+ [north, west],
11133
+ [north, east],
11134
+ [south, west],
11135
+ [south, east],
11136
+ [(north + south) / 2, west],
11137
+ [(north + south) / 2, east],
11138
+ [north, (east + west) / 2],
11139
+ [south, (east + west) / 2]
11140
+ ];
11141
+ for (const [lat, lon] of corners) {
11142
+ const geohash = this.encodeGeohash(lat, lon, precision);
11143
+ geohashes.add(geohash);
11144
+ }
11145
+ return Array.from(geohashes);
11146
+ }
11147
+ /**
11148
+ * Convert degrees to radians
11149
+ */
11150
+ _toRadians(degrees) {
11151
+ return degrees * (Math.PI / 180);
11152
+ }
11153
+ /**
11154
+ * Get approximate cell size for precision level
11155
+ */
11156
+ _getPrecisionDistance(precision) {
11157
+ const distances = {
11158
+ 1: 5e3,
11159
+ 2: 1250,
11160
+ 3: 156,
11161
+ 4: 39,
11162
+ 5: 4.9,
11163
+ 6: 1.2,
11164
+ 7: 0.15,
11165
+ 8: 0.038,
11166
+ 9: 47e-4,
11167
+ 10: 12e-4,
11168
+ 11: 15e-5,
11169
+ 12: 37e-6
11170
+ };
11171
+ return distances[precision] || 5;
11172
+ }
11173
+ /**
11174
+ * Select optimal zoom level based on search radius
11175
+ * @param {Array<number>} zoomLevels - Available zoom levels
11176
+ * @param {number} radiusKm - Search radius in kilometers
11177
+ * @returns {number} Optimal zoom precision
11178
+ */
11179
+ _selectOptimalZoom(zoomLevels, radiusKm) {
11180
+ if (!zoomLevels || zoomLevels.length === 0) {
11181
+ return null;
11182
+ }
11183
+ const targetCellSize = radiusKm / 2.5;
11184
+ let bestZoom = zoomLevels[0];
11185
+ let bestDiff = Math.abs(this._getPrecisionDistance(bestZoom) - targetCellSize);
11186
+ for (const zoom of zoomLevels) {
11187
+ const cellSize = this._getPrecisionDistance(zoom);
11188
+ const diff = Math.abs(cellSize - targetCellSize);
11189
+ if (diff < bestDiff) {
11190
+ bestDiff = diff;
11191
+ bestZoom = zoom;
11192
+ }
11193
+ }
11194
+ return bestZoom;
11195
+ }
11196
+ /**
11197
+ * Get plugin statistics
11198
+ */
11199
+ getStats() {
11200
+ return {
11201
+ resources: Object.keys(this.resources).length,
11202
+ configurations: Object.entries(this.resources).map(([name, config]) => ({
11203
+ resource: name,
11204
+ latField: config.latField,
11205
+ lonField: config.lonField,
11206
+ precision: config.precision,
11207
+ cellSize: `~${this._getPrecisionDistance(config.precision)}km`
11208
+ }))
11209
+ };
11210
+ }
11211
+ /**
11212
+ * Uninstall the plugin
11213
+ */
11214
+ async uninstall() {
11215
+ if (this.verbose) {
11216
+ console.log("[GeoPlugin] Uninstalled");
11217
+ }
11218
+ this.emit("uninstalled", {
11219
+ plugin: "GeoPlugin"
11220
+ });
11221
+ await super.uninstall();
11222
+ }
11223
+ }
11224
+
10625
11225
  class MetricsPlugin extends Plugin {
10626
11226
  constructor(options = {}) {
10627
11227
  super();
@@ -20431,7 +21031,7 @@ class Database extends EventEmitter {
20431
21031
  this.id = idGenerator(7);
20432
21032
  this.version = "1";
20433
21033
  this.s3dbVersion = (() => {
20434
- const [ok, err, version] = tryFn(() => true ? "12.2.1" : "latest");
21034
+ const [ok, err, version] = tryFn(() => true ? "12.2.3" : "latest");
20435
21035
  return ok ? version : "latest";
20436
21036
  })();
20437
21037
  this._resourcesMap = {};
@@ -35346,6 +35946,532 @@ class TfStatePlugin extends Plugin {
35346
35946
  }
35347
35947
  }
35348
35948
 
35949
+ const GRANULARITIES = {
35950
+ minute: {
35951
+ threshold: 3600,
35952
+ // TTL < 1 hour
35953
+ interval: 1e4,
35954
+ // Check every 10 seconds
35955
+ cohortsToCheck: 3,
35956
+ // Check last 3 minutes
35957
+ cohortFormat: (date) => date.toISOString().substring(0, 16)
35958
+ // '2024-10-25T14:30'
35959
+ },
35960
+ hour: {
35961
+ threshold: 86400,
35962
+ // TTL < 24 hours
35963
+ interval: 6e5,
35964
+ // Check every 10 minutes
35965
+ cohortsToCheck: 2,
35966
+ // Check last 2 hours
35967
+ cohortFormat: (date) => date.toISOString().substring(0, 13)
35968
+ // '2024-10-25T14'
35969
+ },
35970
+ day: {
35971
+ threshold: 2592e3,
35972
+ // TTL < 30 days
35973
+ interval: 36e5,
35974
+ // Check every 1 hour
35975
+ cohortsToCheck: 2,
35976
+ // Check last 2 days
35977
+ cohortFormat: (date) => date.toISOString().substring(0, 10)
35978
+ // '2024-10-25'
35979
+ },
35980
+ week: {
35981
+ threshold: Infinity,
35982
+ // TTL >= 30 days
35983
+ interval: 864e5,
35984
+ // Check every 24 hours
35985
+ cohortsToCheck: 2,
35986
+ // Check last 2 weeks
35987
+ cohortFormat: (date) => {
35988
+ const year = date.getUTCFullYear();
35989
+ const week = getWeekNumber(date);
35990
+ return `${year}-W${String(week).padStart(2, "0")}`;
35991
+ }
35992
+ }
35993
+ };
35994
+ function getWeekNumber(date) {
35995
+ const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
35996
+ const dayNum = d.getUTCDay() || 7;
35997
+ d.setUTCDate(d.getUTCDate() + 4 - dayNum);
35998
+ const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
35999
+ return Math.ceil(((d - yearStart) / 864e5 + 1) / 7);
36000
+ }
36001
+ function detectGranularity(ttl) {
36002
+ if (!ttl) return "day";
36003
+ if (ttl < GRANULARITIES.minute.threshold) return "minute";
36004
+ if (ttl < GRANULARITIES.hour.threshold) return "hour";
36005
+ if (ttl < GRANULARITIES.day.threshold) return "day";
36006
+ return "week";
36007
+ }
36008
+ function getExpiredCohorts(granularity, count) {
36009
+ const config = GRANULARITIES[granularity];
36010
+ const cohorts = [];
36011
+ const now = /* @__PURE__ */ new Date();
36012
+ for (let i = 0; i < count; i++) {
36013
+ let checkDate;
36014
+ switch (granularity) {
36015
+ case "minute":
36016
+ checkDate = new Date(now.getTime() - i * 6e4);
36017
+ break;
36018
+ case "hour":
36019
+ checkDate = new Date(now.getTime() - i * 36e5);
36020
+ break;
36021
+ case "day":
36022
+ checkDate = new Date(now.getTime() - i * 864e5);
36023
+ break;
36024
+ case "week":
36025
+ checkDate = new Date(now.getTime() - i * 6048e5);
36026
+ break;
36027
+ }
36028
+ cohorts.push(config.cohortFormat(checkDate));
36029
+ }
36030
+ return cohorts;
36031
+ }
36032
+ class TTLPlugin extends Plugin {
36033
+ constructor(config = {}) {
36034
+ super(config);
36035
+ this.verbose = config.verbose !== void 0 ? config.verbose : false;
36036
+ this.resources = config.resources || {};
36037
+ this.batchSize = config.batchSize || 100;
36038
+ this.stats = {
36039
+ totalScans: 0,
36040
+ totalExpired: 0,
36041
+ totalDeleted: 0,
36042
+ totalArchived: 0,
36043
+ totalSoftDeleted: 0,
36044
+ totalCallbacks: 0,
36045
+ totalErrors: 0,
36046
+ lastScanAt: null,
36047
+ lastScanDuration: 0
36048
+ };
36049
+ this.intervals = [];
36050
+ this.isRunning = false;
36051
+ this.expirationIndex = null;
36052
+ }
36053
+ /**
36054
+ * Install the plugin
36055
+ */
36056
+ async install(database) {
36057
+ await super.install(database);
36058
+ for (const [resourceName, config] of Object.entries(this.resources)) {
36059
+ this._validateResourceConfig(resourceName, config);
36060
+ }
36061
+ await this._createExpirationIndex();
36062
+ for (const [resourceName, config] of Object.entries(this.resources)) {
36063
+ this._setupResourceHooks(resourceName, config);
36064
+ }
36065
+ this._startIntervals();
36066
+ if (this.verbose) {
36067
+ console.log(`[TTLPlugin] Installed with ${Object.keys(this.resources).length} resources`);
36068
+ }
36069
+ this.emit("installed", {
36070
+ plugin: "TTLPlugin",
36071
+ resources: Object.keys(this.resources)
36072
+ });
36073
+ }
36074
+ /**
36075
+ * Validate resource configuration
36076
+ */
36077
+ _validateResourceConfig(resourceName, config) {
36078
+ if (!config.ttl && !config.field) {
36079
+ throw new Error(
36080
+ `[TTLPlugin] Resource "${resourceName}" must have either "ttl" (seconds) or "field" (timestamp field name)`
36081
+ );
36082
+ }
36083
+ const validStrategies = ["soft-delete", "hard-delete", "archive", "callback"];
36084
+ if (!config.onExpire || !validStrategies.includes(config.onExpire)) {
36085
+ throw new Error(
36086
+ `[TTLPlugin] Resource "${resourceName}" must have an "onExpire" value. Valid options: ${validStrategies.join(", ")}`
36087
+ );
36088
+ }
36089
+ if (config.onExpire === "soft-delete" && !config.deleteField) {
36090
+ config.deleteField = "deletedat";
36091
+ }
36092
+ if (config.onExpire === "archive" && !config.archiveResource) {
36093
+ throw new Error(
36094
+ `[TTLPlugin] Resource "${resourceName}" with onExpire="archive" must have an "archiveResource" specified`
36095
+ );
36096
+ }
36097
+ if (config.onExpire === "callback" && typeof config.callback !== "function") {
36098
+ throw new Error(
36099
+ `[TTLPlugin] Resource "${resourceName}" with onExpire="callback" must have a "callback" function`
36100
+ );
36101
+ }
36102
+ if (!config.field) {
36103
+ config.field = "_createdAt";
36104
+ }
36105
+ if (config.field === "_createdAt" && this.database) {
36106
+ const resource = this.database.resources[resourceName];
36107
+ if (resource && resource.config && resource.config.timestamps === false) {
36108
+ console.warn(
36109
+ `[TTLPlugin] WARNING: Resource "${resourceName}" uses TTL with field "_createdAt" but timestamps are disabled. TTL will be calculated from indexing time, not creation time.`
36110
+ );
36111
+ }
36112
+ }
36113
+ config.granularity = detectGranularity(config.ttl);
36114
+ }
36115
+ /**
36116
+ * Create expiration index (plugin resource)
36117
+ */
36118
+ async _createExpirationIndex() {
36119
+ this.expirationIndex = await this.database.createResource({
36120
+ name: "plg_ttl_expiration_index",
36121
+ attributes: {
36122
+ resourceName: "string|required",
36123
+ recordId: "string|required",
36124
+ expiresAtCohort: "string|required",
36125
+ expiresAtTimestamp: "number|required",
36126
+ // Exact expiration timestamp for precise checking
36127
+ granularity: "string|required",
36128
+ createdAt: "number"
36129
+ },
36130
+ partitions: {
36131
+ byExpiresAtCohort: {
36132
+ fields: { expiresAtCohort: "string" }
36133
+ }
36134
+ },
36135
+ asyncPartitions: false
36136
+ // Sync partitions for deterministic behavior
36137
+ });
36138
+ if (this.verbose) {
36139
+ console.log("[TTLPlugin] Created expiration index with partition");
36140
+ }
36141
+ }
36142
+ /**
36143
+ * Setup hooks for a resource
36144
+ */
36145
+ _setupResourceHooks(resourceName, config) {
36146
+ if (!this.database.resources[resourceName]) {
36147
+ if (this.verbose) {
36148
+ console.warn(`[TTLPlugin] Resource "${resourceName}" not found, skipping hooks`);
36149
+ }
36150
+ return;
36151
+ }
36152
+ const resource = this.database.resources[resourceName];
36153
+ if (typeof resource.insert !== "function" || typeof resource.delete !== "function") {
36154
+ if (this.verbose) {
36155
+ console.warn(`[TTLPlugin] Resource "${resourceName}" missing insert/delete methods, skipping hooks`);
36156
+ }
36157
+ return;
36158
+ }
36159
+ this.addMiddleware(resource, "insert", async (next, data, options) => {
36160
+ const result = await next(data, options);
36161
+ await this._addToIndex(resourceName, result, config);
36162
+ return result;
36163
+ });
36164
+ this.addMiddleware(resource, "delete", async (next, id, options) => {
36165
+ const result = await next(id, options);
36166
+ await this._removeFromIndex(resourceName, id);
36167
+ return result;
36168
+ });
36169
+ if (this.verbose) {
36170
+ console.log(`[TTLPlugin] Setup hooks for resource "${resourceName}"`);
36171
+ }
36172
+ }
36173
+ /**
36174
+ * Add record to expiration index
36175
+ */
36176
+ async _addToIndex(resourceName, record, config) {
36177
+ try {
36178
+ let baseTime = record[config.field];
36179
+ if (!baseTime && config.field === "_createdAt") {
36180
+ baseTime = Date.now();
36181
+ }
36182
+ if (!baseTime) {
36183
+ if (this.verbose) {
36184
+ console.warn(
36185
+ `[TTLPlugin] Record ${record.id} in ${resourceName} missing field "${config.field}", skipping index`
36186
+ );
36187
+ }
36188
+ return;
36189
+ }
36190
+ const baseTimestamp = typeof baseTime === "number" ? baseTime : new Date(baseTime).getTime();
36191
+ const expiresAt = config.ttl ? new Date(baseTimestamp + config.ttl * 1e3) : new Date(baseTimestamp);
36192
+ const cohortConfig = GRANULARITIES[config.granularity];
36193
+ const cohort = cohortConfig.cohortFormat(expiresAt);
36194
+ const indexId = `${resourceName}:${record.id}`;
36195
+ await this.expirationIndex.insert({
36196
+ id: indexId,
36197
+ resourceName,
36198
+ recordId: record.id,
36199
+ expiresAtCohort: cohort,
36200
+ expiresAtTimestamp: expiresAt.getTime(),
36201
+ // Store exact timestamp for precise checking
36202
+ granularity: config.granularity,
36203
+ createdAt: Date.now()
36204
+ });
36205
+ if (this.verbose) {
36206
+ console.log(
36207
+ `[TTLPlugin] Added ${resourceName}:${record.id} to index (cohort: ${cohort}, granularity: ${config.granularity})`
36208
+ );
36209
+ }
36210
+ } catch (error) {
36211
+ console.error(`[TTLPlugin] Error adding to index:`, error);
36212
+ this.stats.totalErrors++;
36213
+ }
36214
+ }
36215
+ /**
36216
+ * Remove record from expiration index (O(1) using deterministic ID)
36217
+ */
36218
+ async _removeFromIndex(resourceName, recordId) {
36219
+ try {
36220
+ const indexId = `${resourceName}:${recordId}`;
36221
+ const [ok, err] = await tryFn(() => this.expirationIndex.delete(indexId));
36222
+ if (this.verbose && ok) {
36223
+ console.log(`[TTLPlugin] Removed index entry for ${resourceName}:${recordId}`);
36224
+ }
36225
+ if (!ok && err?.code !== "NoSuchKey") {
36226
+ throw err;
36227
+ }
36228
+ } catch (error) {
36229
+ console.error(`[TTLPlugin] Error removing from index:`, error);
36230
+ }
36231
+ }
36232
+ /**
36233
+ * Start interval-based cleanup for each granularity
36234
+ */
36235
+ _startIntervals() {
36236
+ const byGranularity = {
36237
+ minute: [],
36238
+ hour: [],
36239
+ day: [],
36240
+ week: []
36241
+ };
36242
+ for (const [name, config] of Object.entries(this.resources)) {
36243
+ byGranularity[config.granularity].push({ name, config });
36244
+ }
36245
+ for (const [granularity, resources] of Object.entries(byGranularity)) {
36246
+ if (resources.length === 0) continue;
36247
+ const granularityConfig = GRANULARITIES[granularity];
36248
+ const handle = setInterval(
36249
+ () => this._cleanupGranularity(granularity, resources),
36250
+ granularityConfig.interval
36251
+ );
36252
+ this.intervals.push(handle);
36253
+ if (this.verbose) {
36254
+ console.log(
36255
+ `[TTLPlugin] Started ${granularity} interval (${granularityConfig.interval}ms) for ${resources.length} resources`
36256
+ );
36257
+ }
36258
+ }
36259
+ this.isRunning = true;
36260
+ }
36261
+ /**
36262
+ * Stop all intervals
36263
+ */
36264
+ _stopIntervals() {
36265
+ for (const handle of this.intervals) {
36266
+ clearInterval(handle);
36267
+ }
36268
+ this.intervals = [];
36269
+ this.isRunning = false;
36270
+ if (this.verbose) {
36271
+ console.log("[TTLPlugin] Stopped all intervals");
36272
+ }
36273
+ }
36274
+ /**
36275
+ * Cleanup expired records for a specific granularity
36276
+ */
36277
+ async _cleanupGranularity(granularity, resources) {
36278
+ const startTime = Date.now();
36279
+ this.stats.totalScans++;
36280
+ try {
36281
+ const granularityConfig = GRANULARITIES[granularity];
36282
+ const cohorts = getExpiredCohorts(granularity, granularityConfig.cohortsToCheck);
36283
+ if (this.verbose) {
36284
+ console.log(`[TTLPlugin] Cleaning ${granularity} granularity, checking cohorts:`, cohorts);
36285
+ }
36286
+ for (const cohort of cohorts) {
36287
+ const expired = await this.expirationIndex.listPartition({
36288
+ partition: "byExpiresAtCohort",
36289
+ partitionValues: { expiresAtCohort: cohort }
36290
+ });
36291
+ const resourceNames = new Set(resources.map((r) => r.name));
36292
+ const filtered = expired.filter((e) => resourceNames.has(e.resourceName));
36293
+ if (this.verbose && filtered.length > 0) {
36294
+ console.log(`[TTLPlugin] Found ${filtered.length} expired records in cohort ${cohort}`);
36295
+ }
36296
+ for (let i = 0; i < filtered.length; i += this.batchSize) {
36297
+ const batch = filtered.slice(i, i + this.batchSize);
36298
+ for (const entry of batch) {
36299
+ const config = this.resources[entry.resourceName];
36300
+ await this._processExpiredEntry(entry, config);
36301
+ }
36302
+ }
36303
+ }
36304
+ this.stats.lastScanAt = (/* @__PURE__ */ new Date()).toISOString();
36305
+ this.stats.lastScanDuration = Date.now() - startTime;
36306
+ this.emit("scanCompleted", {
36307
+ granularity,
36308
+ duration: this.stats.lastScanDuration,
36309
+ cohorts
36310
+ });
36311
+ } catch (error) {
36312
+ console.error(`[TTLPlugin] Error in ${granularity} cleanup:`, error);
36313
+ this.stats.totalErrors++;
36314
+ this.emit("cleanupError", { granularity, error });
36315
+ }
36316
+ }
36317
+ /**
36318
+ * Process a single expired index entry
36319
+ */
36320
+ async _processExpiredEntry(entry, config) {
36321
+ try {
36322
+ if (!this.database.resources[entry.resourceName]) {
36323
+ if (this.verbose) {
36324
+ console.warn(`[TTLPlugin] Resource "${entry.resourceName}" not found during cleanup, skipping`);
36325
+ }
36326
+ return;
36327
+ }
36328
+ const resource = this.database.resources[entry.resourceName];
36329
+ const [ok, err, record] = await tryFn(() => resource.get(entry.recordId));
36330
+ if (!ok || !record) {
36331
+ await this.expirationIndex.delete(entry.id);
36332
+ return;
36333
+ }
36334
+ if (entry.expiresAtTimestamp && Date.now() < entry.expiresAtTimestamp) {
36335
+ return;
36336
+ }
36337
+ switch (config.onExpire) {
36338
+ case "soft-delete":
36339
+ await this._softDelete(resource, record, config);
36340
+ this.stats.totalSoftDeleted++;
36341
+ break;
36342
+ case "hard-delete":
36343
+ await this._hardDelete(resource, record);
36344
+ this.stats.totalDeleted++;
36345
+ break;
36346
+ case "archive":
36347
+ await this._archive(resource, record, config);
36348
+ this.stats.totalArchived++;
36349
+ this.stats.totalDeleted++;
36350
+ break;
36351
+ case "callback":
36352
+ const shouldDelete = await config.callback(record, resource);
36353
+ this.stats.totalCallbacks++;
36354
+ if (shouldDelete) {
36355
+ await this._hardDelete(resource, record);
36356
+ this.stats.totalDeleted++;
36357
+ }
36358
+ break;
36359
+ }
36360
+ await this.expirationIndex.delete(entry.id);
36361
+ this.stats.totalExpired++;
36362
+ this.emit("recordExpired", { resource: entry.resourceName, record });
36363
+ } catch (error) {
36364
+ console.error(`[TTLPlugin] Error processing expired entry:`, error);
36365
+ this.stats.totalErrors++;
36366
+ }
36367
+ }
36368
+ /**
36369
+ * Soft delete: Mark record as deleted
36370
+ */
36371
+ async _softDelete(resource, record, config) {
36372
+ const deleteField = config.deleteField || "deletedat";
36373
+ const updates = {
36374
+ [deleteField]: (/* @__PURE__ */ new Date()).toISOString(),
36375
+ isdeleted: "true"
36376
+ // Add isdeleted field for partition compatibility
36377
+ };
36378
+ await resource.update(record.id, updates);
36379
+ if (this.verbose) {
36380
+ console.log(`[TTLPlugin] Soft-deleted record ${record.id} in ${resource.name}`);
36381
+ }
36382
+ }
36383
+ /**
36384
+ * Hard delete: Remove record from S3
36385
+ */
36386
+ async _hardDelete(resource, record) {
36387
+ await resource.delete(record.id);
36388
+ if (this.verbose) {
36389
+ console.log(`[TTLPlugin] Hard-deleted record ${record.id} in ${resource.name}`);
36390
+ }
36391
+ }
36392
+ /**
36393
+ * Archive: Copy to another resource then delete
36394
+ */
36395
+ async _archive(resource, record, config) {
36396
+ if (!this.database.resources[config.archiveResource]) {
36397
+ throw new Error(`Archive resource "${config.archiveResource}" not found`);
36398
+ }
36399
+ const archiveResource = this.database.resources[config.archiveResource];
36400
+ const archiveData = {};
36401
+ for (const [key, value] of Object.entries(record)) {
36402
+ if (!key.startsWith("_")) {
36403
+ archiveData[key] = value;
36404
+ }
36405
+ }
36406
+ archiveData.archivedAt = (/* @__PURE__ */ new Date()).toISOString();
36407
+ archiveData.archivedFrom = resource.name;
36408
+ archiveData.originalId = record.id;
36409
+ if (!config.keepOriginalId) {
36410
+ delete archiveData.id;
36411
+ }
36412
+ await archiveResource.insert(archiveData);
36413
+ await resource.delete(record.id);
36414
+ if (this.verbose) {
36415
+ console.log(`[TTLPlugin] Archived record ${record.id} from ${resource.name} to ${config.archiveResource}`);
36416
+ }
36417
+ }
36418
+ /**
36419
+ * Manual cleanup of a specific resource
36420
+ */
36421
+ async cleanupResource(resourceName) {
36422
+ const config = this.resources[resourceName];
36423
+ if (!config) {
36424
+ throw new Error(`Resource "${resourceName}" not configured in TTLPlugin`);
36425
+ }
36426
+ const granularity = config.granularity;
36427
+ await this._cleanupGranularity(granularity, [{ name: resourceName, config }]);
36428
+ return {
36429
+ resource: resourceName,
36430
+ granularity
36431
+ };
36432
+ }
36433
+ /**
36434
+ * Manual cleanup of all resources
36435
+ */
36436
+ async runCleanup() {
36437
+ const byGranularity = {
36438
+ minute: [],
36439
+ hour: [],
36440
+ day: [],
36441
+ week: []
36442
+ };
36443
+ for (const [name, config] of Object.entries(this.resources)) {
36444
+ byGranularity[config.granularity].push({ name, config });
36445
+ }
36446
+ for (const [granularity, resources] of Object.entries(byGranularity)) {
36447
+ if (resources.length > 0) {
36448
+ await this._cleanupGranularity(granularity, resources);
36449
+ }
36450
+ }
36451
+ }
36452
+ /**
36453
+ * Get plugin statistics
36454
+ */
36455
+ getStats() {
36456
+ return {
36457
+ ...this.stats,
36458
+ resources: Object.keys(this.resources).length,
36459
+ isRunning: this.isRunning,
36460
+ intervals: this.intervals.length
36461
+ };
36462
+ }
36463
+ /**
36464
+ * Uninstall the plugin
36465
+ */
36466
+ async uninstall() {
36467
+ this._stopIntervals();
36468
+ await super.uninstall();
36469
+ if (this.verbose) {
36470
+ console.log("[TTLPlugin] Uninstalled");
36471
+ }
36472
+ }
36473
+ }
36474
+
35349
36475
  function cosineDistance(a, b) {
35350
36476
  if (a.length !== b.length) {
35351
36477
  throw new Error(`Dimension mismatch: ${a.length} vs ${b.length}`);
@@ -37116,5 +38242,5 @@ var metrics = /*#__PURE__*/Object.freeze({
37116
38242
  silhouetteScore: silhouetteScore
37117
38243
  });
37118
38244
 
37119
- export { AVAILABLE_BEHAVIORS, AnalyticsNotEnabledError, ApiPlugin, AuditPlugin, AuthenticationError, BACKUP_DRIVERS, BackupPlugin, BaseBackupDriver, BaseError, BaseReplicator, BehaviorError, BigqueryReplicator, CONSUMER_DRIVERS, Cache, CachePlugin, Client, ConnectionString, ConnectionStringError, CostsPlugin, CryptoError, DEFAULT_BEHAVIOR, Database, DatabaseError, DynamoDBReplicator, EncryptionError, ErrorMap, EventualConsistencyPlugin, Factory, FilesystemBackupDriver, FilesystemCache, FullTextPlugin, InvalidResourceItem, MemoryCache, MetadataLimitError, MetricsPlugin, MissingMetadata, MongoDBReplicator, MultiBackupDriver, MySQLReplicator, NoSuchBucket, NoSuchKey, NotFound, PartitionAwareFilesystemCache, PartitionDriverError, PartitionError, PermissionError, PlanetScaleReplicator, Plugin, PluginError, PluginObject, PluginStorageError, PostgresReplicator, QueueConsumerPlugin, REPLICATOR_DRIVERS, RabbitMqConsumer, RelationPlugin, ReplicatorPlugin, Resource, ResourceError, ResourceIdsPageReader, ResourceIdsReader, ResourceNotFound, ResourceReader, ResourceWriter, S3BackupDriver, S3Cache, S3QueuePlugin, Database as S3db, S3dbError, S3dbReplicator, SchedulerPlugin, Schema, SchemaError, Seeder, SqsConsumer, SqsReplicator, StateMachinePlugin, StreamError, TfStatePlugin, TursoReplicator, UnknownError, ValidationError, Validator, VectorPlugin, WebhookReplicator, behaviors, calculateAttributeNamesSize, calculateAttributeSizes, calculateEffectiveLimit, calculateSystemOverhead, calculateTotalSize, calculateUTF8Bytes, clearUTF8Memory, createBackupDriver, createConsumer, createReplicator, decode, decodeDecimal, decodeFixedPoint, decodeFixedPointBatch, decrypt, S3db as default, encode, encodeDecimal, encodeFixedPoint, encodeFixedPointBatch, encrypt, generateTypes, getBehavior, getSizeBreakdown, idGenerator, mapAwsError, md5, passwordGenerator, printTypes, sha256, streamToString, transformValue, tryFn, tryFnSync, validateBackupConfig, validateReplicatorConfig };
38245
+ export { AVAILABLE_BEHAVIORS, AnalyticsNotEnabledError, ApiPlugin, AuditPlugin, AuthenticationError, BACKUP_DRIVERS, BackupPlugin, BaseBackupDriver, BaseError, BaseReplicator, BehaviorError, BigqueryReplicator, CONSUMER_DRIVERS, Cache, CachePlugin, Client, ConnectionString, ConnectionStringError, CostsPlugin, CryptoError, DEFAULT_BEHAVIOR, Database, DatabaseError, DynamoDBReplicator, EncryptionError, ErrorMap, EventualConsistencyPlugin, Factory, FilesystemBackupDriver, FilesystemCache, FullTextPlugin, GeoPlugin, InvalidResourceItem, MemoryCache, MetadataLimitError, MetricsPlugin, MissingMetadata, MongoDBReplicator, MultiBackupDriver, MySQLReplicator, NoSuchBucket, NoSuchKey, NotFound, PartitionAwareFilesystemCache, PartitionDriverError, PartitionError, PermissionError, PlanetScaleReplicator, Plugin, PluginError, PluginObject, PluginStorageError, PostgresReplicator, QueueConsumerPlugin, REPLICATOR_DRIVERS, RabbitMqConsumer, RelationPlugin, ReplicatorPlugin, Resource, ResourceError, ResourceIdsPageReader, ResourceIdsReader, ResourceNotFound, ResourceReader, ResourceWriter, S3BackupDriver, S3Cache, S3QueuePlugin, Database as S3db, S3dbError, S3dbReplicator, SchedulerPlugin, Schema, SchemaError, Seeder, SqsConsumer, SqsReplicator, StateMachinePlugin, StreamError, TTLPlugin, TfStatePlugin, TursoReplicator, UnknownError, ValidationError, Validator, VectorPlugin, WebhookReplicator, behaviors, calculateAttributeNamesSize, calculateAttributeSizes, calculateEffectiveLimit, calculateSystemOverhead, calculateTotalSize, calculateUTF8Bytes, clearUTF8Memory, createBackupDriver, createConsumer, createReplicator, decode, decodeDecimal, decodeFixedPoint, decodeFixedPointBatch, decrypt, S3db as default, encode, encodeDecimal, encodeFixedPoint, encodeFixedPointBatch, encrypt, generateTypes, getBehavior, getSizeBreakdown, idGenerator, mapAwsError, md5, passwordGenerator, printTypes, sha256, streamToString, transformValue, tryFn, tryFnSync, validateBackupConfig, validateReplicatorConfig };
37120
38246
  //# sourceMappingURL=s3db.es.js.map