s3db.js 12.2.1 → 12.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/s3db.cjs.js CHANGED
@@ -10645,6 +10645,605 @@ class FullTextPlugin extends Plugin {
10645
10645
  }
10646
10646
  }
10647
10647
 
10648
+ class GeoPlugin extends Plugin {
10649
+ constructor(config = {}) {
10650
+ super(config);
10651
+ this.resources = config.resources || {};
10652
+ this.verbose = config.verbose !== void 0 ? config.verbose : false;
10653
+ this.base32 = "0123456789bcdefghjkmnpqrstuvwxyz";
10654
+ }
10655
+ /**
10656
+ * Install the plugin
10657
+ */
10658
+ async install(database) {
10659
+ await super.install(database);
10660
+ for (const [resourceName, config] of Object.entries(this.resources)) {
10661
+ await this._setupResource(resourceName, config);
10662
+ }
10663
+ this.database.addHook("afterCreateResource", async (context) => {
10664
+ const { resource, config: resourceConfig } = context;
10665
+ const geoConfig = this.resources[resource.name];
10666
+ if (geoConfig) {
10667
+ await this._setupResource(resource.name, geoConfig);
10668
+ }
10669
+ });
10670
+ if (this.verbose) {
10671
+ console.log(`[GeoPlugin] Installed with ${Object.keys(this.resources).length} resources`);
10672
+ }
10673
+ this.emit("installed", {
10674
+ plugin: "GeoPlugin",
10675
+ resources: Object.keys(this.resources)
10676
+ });
10677
+ }
10678
+ /**
10679
+ * Setup a resource with geo capabilities
10680
+ */
10681
+ async _setupResource(resourceName, config) {
10682
+ if (!this.database.resources[resourceName]) {
10683
+ if (this.verbose) {
10684
+ console.warn(`[GeoPlugin] Resource "${resourceName}" not found, will setup when created`);
10685
+ }
10686
+ return;
10687
+ }
10688
+ const resource = this.database.resources[resourceName];
10689
+ if (!resource || typeof resource.addHook !== "function") {
10690
+ if (this.verbose) {
10691
+ console.warn(`[GeoPlugin] Resource "${resourceName}" not found or invalid`);
10692
+ }
10693
+ return;
10694
+ }
10695
+ if (!config.latField || !config.lonField) {
10696
+ throw new Error(
10697
+ `[GeoPlugin] Resource "${resourceName}" must have "latField" and "lonField" configured`
10698
+ );
10699
+ }
10700
+ if (!config.precision || config.precision < 1 || config.precision > 12) {
10701
+ config.precision = 5;
10702
+ }
10703
+ resource._geoConfig = config;
10704
+ const latField = resource.attributes[config.latField];
10705
+ const lonField = resource.attributes[config.lonField];
10706
+ const isLatOptional = typeof latField === "object" && latField.optional === true;
10707
+ const isLonOptional = typeof lonField === "object" && lonField.optional === true;
10708
+ const areCoordinatesOptional = isLatOptional || isLonOptional;
10709
+ const geohashType = areCoordinatesOptional ? "string|optional" : "string";
10710
+ let needsUpdate = false;
10711
+ const newAttributes = { ...resource.attributes };
10712
+ if (config.addGeohash && !newAttributes.geohash) {
10713
+ newAttributes.geohash = geohashType;
10714
+ needsUpdate = true;
10715
+ }
10716
+ if (!newAttributes._geohash) {
10717
+ newAttributes._geohash = geohashType;
10718
+ needsUpdate = true;
10719
+ }
10720
+ if (config.zoomLevels && Array.isArray(config.zoomLevels)) {
10721
+ for (const zoom of config.zoomLevels) {
10722
+ const fieldName = `_geohash_zoom${zoom}`;
10723
+ if (!newAttributes[fieldName]) {
10724
+ newAttributes[fieldName] = geohashType;
10725
+ needsUpdate = true;
10726
+ }
10727
+ }
10728
+ }
10729
+ if (needsUpdate) {
10730
+ resource.updateAttributes(newAttributes);
10731
+ if (this.database.uploadMetadataFile) {
10732
+ await this.database.uploadMetadataFile();
10733
+ }
10734
+ }
10735
+ if (config.usePartitions) {
10736
+ await this._setupPartitions(resource, config);
10737
+ }
10738
+ this._addHooks(resource, config);
10739
+ this._addHelperMethods(resource, config);
10740
+ if (this.verbose) {
10741
+ console.log(
10742
+ `[GeoPlugin] Setup resource "${resourceName}" with precision ${config.precision} (~${this._getPrecisionDistance(config.precision)}km cells)` + (config.usePartitions ? " [Partitions enabled]" : "")
10743
+ );
10744
+ }
10745
+ }
10746
+ /**
10747
+ * Setup geohash partitions for efficient spatial queries
10748
+ * Creates multiple zoom-level partitions if zoomLevels configured
10749
+ */
10750
+ async _setupPartitions(resource, config) {
10751
+ const updatedConfig = { ...resource.config };
10752
+ updatedConfig.partitions = updatedConfig.partitions || {};
10753
+ let partitionsCreated = 0;
10754
+ if (config.zoomLevels && Array.isArray(config.zoomLevels)) {
10755
+ for (const zoom of config.zoomLevels) {
10756
+ const partitionName = `byGeohashZoom${zoom}`;
10757
+ const fieldName = `_geohash_zoom${zoom}`;
10758
+ if (!updatedConfig.partitions[partitionName]) {
10759
+ updatedConfig.partitions[partitionName] = {
10760
+ fields: {
10761
+ [fieldName]: "string"
10762
+ }
10763
+ };
10764
+ partitionsCreated++;
10765
+ if (this.verbose) {
10766
+ console.log(
10767
+ `[GeoPlugin] Created ${partitionName} partition for "${resource.name}" (precision ${zoom}, ~${this._getPrecisionDistance(zoom)}km cells)`
10768
+ );
10769
+ }
10770
+ }
10771
+ }
10772
+ } else {
10773
+ const hasGeohashPartition = resource.config.partitions && resource.config.partitions.byGeohash;
10774
+ if (!hasGeohashPartition) {
10775
+ updatedConfig.partitions.byGeohash = {
10776
+ fields: {
10777
+ _geohash: "string"
10778
+ }
10779
+ };
10780
+ partitionsCreated++;
10781
+ if (this.verbose) {
10782
+ console.log(`[GeoPlugin] Created byGeohash partition for "${resource.name}"`);
10783
+ }
10784
+ }
10785
+ }
10786
+ if (partitionsCreated > 0) {
10787
+ resource.config = updatedConfig;
10788
+ resource.setupPartitionHooks();
10789
+ if (this.database.uploadMetadataFile) {
10790
+ await this.database.uploadMetadataFile();
10791
+ }
10792
+ }
10793
+ }
10794
+ /**
10795
+ * Add hooks to automatically calculate geohash at all zoom levels
10796
+ */
10797
+ _addHooks(resource, config) {
10798
+ const calculateGeohash = async (data) => {
10799
+ const lat = data[config.latField];
10800
+ const lon = data[config.lonField];
10801
+ if (lat !== void 0 && lon !== void 0) {
10802
+ const geohash = this.encodeGeohash(lat, lon, config.precision);
10803
+ if (config.addGeohash) {
10804
+ data.geohash = geohash;
10805
+ }
10806
+ data._geohash = geohash;
10807
+ if (config.zoomLevels && Array.isArray(config.zoomLevels)) {
10808
+ for (const zoom of config.zoomLevels) {
10809
+ const zoomGeohash = this.encodeGeohash(lat, lon, zoom);
10810
+ data[`_geohash_zoom${zoom}`] = zoomGeohash;
10811
+ }
10812
+ }
10813
+ }
10814
+ return data;
10815
+ };
10816
+ resource.addHook("beforeInsert", calculateGeohash);
10817
+ resource.addHook("beforeUpdate", calculateGeohash);
10818
+ }
10819
+ /**
10820
+ * Add helper methods to resource
10821
+ */
10822
+ _addHelperMethods(resource, config) {
10823
+ const plugin = this;
10824
+ resource.findNearby = async function({ lat, lon, radius = 10, limit = 100 }) {
10825
+ if (lat === void 0 || lon === void 0) {
10826
+ throw new Error("lat and lon are required for findNearby");
10827
+ }
10828
+ const longitude = lon;
10829
+ let allRecords = [];
10830
+ if (config.usePartitions) {
10831
+ let partitionName, fieldName, precision;
10832
+ if (config.zoomLevels && config.zoomLevels.length > 0) {
10833
+ const optimalZoom = plugin._selectOptimalZoom(config.zoomLevels, radius);
10834
+ partitionName = `byGeohashZoom${optimalZoom}`;
10835
+ fieldName = `_geohash_zoom${optimalZoom}`;
10836
+ precision = optimalZoom;
10837
+ if (plugin.verbose) {
10838
+ console.log(
10839
+ `[GeoPlugin] Auto-selected zoom${optimalZoom} (${plugin._getPrecisionDistance(optimalZoom)}km cells) for ${radius}km radius query`
10840
+ );
10841
+ }
10842
+ } else {
10843
+ partitionName = "byGeohash";
10844
+ fieldName = "_geohash";
10845
+ precision = config.precision;
10846
+ }
10847
+ if (this.config.partitions?.[partitionName]) {
10848
+ const centerGeohash = plugin.encodeGeohash(lat, longitude, precision);
10849
+ const neighbors = plugin.getNeighbors(centerGeohash);
10850
+ const geohashesToSearch = [centerGeohash, ...neighbors];
10851
+ const partitionResults = await Promise.all(
10852
+ geohashesToSearch.map(async (geohash) => {
10853
+ const [ok, err, records] = await tryFn(async () => {
10854
+ return await this.listPartition({
10855
+ partition: partitionName,
10856
+ partitionValues: { [fieldName]: geohash },
10857
+ limit: limit * 2
10858
+ });
10859
+ });
10860
+ return ok ? records : [];
10861
+ })
10862
+ );
10863
+ allRecords = partitionResults.flat();
10864
+ if (plugin.verbose) {
10865
+ console.log(
10866
+ `[GeoPlugin] findNearby searched ${geohashesToSearch.length} ${partitionName} partitions, found ${allRecords.length} candidates`
10867
+ );
10868
+ }
10869
+ } else {
10870
+ allRecords = await this.list({ limit: limit * 10 });
10871
+ }
10872
+ } else {
10873
+ allRecords = await this.list({ limit: limit * 10 });
10874
+ }
10875
+ const withDistances = allRecords.map((record) => {
10876
+ const recordLat = record[config.latField];
10877
+ const recordLon = record[config.lonField];
10878
+ if (recordLat === void 0 || recordLon === void 0) {
10879
+ return null;
10880
+ }
10881
+ const distance = plugin.calculateDistance(lat, longitude, recordLat, recordLon);
10882
+ return {
10883
+ ...record,
10884
+ _distance: distance
10885
+ };
10886
+ }).filter((record) => record !== null && record._distance <= radius).sort((a, b) => a._distance - b._distance).slice(0, limit);
10887
+ return withDistances;
10888
+ };
10889
+ resource.findInBounds = async function({ north, south, east, west, limit = 100 }) {
10890
+ if (north === void 0 || south === void 0 || east === void 0 || west === void 0) {
10891
+ throw new Error("north, south, east, west are required for findInBounds");
10892
+ }
10893
+ let allRecords = [];
10894
+ if (config.usePartitions) {
10895
+ let partitionName, precision;
10896
+ if (config.zoomLevels && config.zoomLevels.length > 0) {
10897
+ const centerLat = (north + south) / 2;
10898
+ const centerLon = (east + west) / 2;
10899
+ const latRadius = plugin.calculateDistance(centerLat, centerLon, north, centerLon);
10900
+ const lonRadius = plugin.calculateDistance(centerLat, centerLon, centerLat, east);
10901
+ const approximateRadius = Math.max(latRadius, lonRadius);
10902
+ const optimalZoom = plugin._selectOptimalZoom(config.zoomLevels, approximateRadius);
10903
+ partitionName = `byGeohashZoom${optimalZoom}`;
10904
+ precision = optimalZoom;
10905
+ if (plugin.verbose) {
10906
+ console.log(
10907
+ `[GeoPlugin] Auto-selected zoom${optimalZoom} (${plugin._getPrecisionDistance(optimalZoom)}km cells) for ${approximateRadius.toFixed(1)}km bounding box`
10908
+ );
10909
+ }
10910
+ } else {
10911
+ partitionName = "byGeohash";
10912
+ precision = config.precision;
10913
+ }
10914
+ if (this.config.partitions?.[partitionName]) {
10915
+ const geohashesToSearch = plugin._getGeohashesInBounds({
10916
+ north,
10917
+ south,
10918
+ east,
10919
+ west,
10920
+ precision
10921
+ });
10922
+ const partitionResults = await Promise.all(
10923
+ geohashesToSearch.map(async (geohash) => {
10924
+ const [ok, err, records] = await tryFn(async () => {
10925
+ const fieldName = config.zoomLevels ? `_geohash_zoom${precision}` : "_geohash";
10926
+ return await this.listPartition({
10927
+ partition: partitionName,
10928
+ partitionValues: { [fieldName]: geohash },
10929
+ limit: limit * 2
10930
+ });
10931
+ });
10932
+ return ok ? records : [];
10933
+ })
10934
+ );
10935
+ allRecords = partitionResults.flat();
10936
+ if (plugin.verbose) {
10937
+ console.log(
10938
+ `[GeoPlugin] findInBounds searched ${geohashesToSearch.length} ${partitionName} partitions, found ${allRecords.length} candidates`
10939
+ );
10940
+ }
10941
+ } else {
10942
+ allRecords = await this.list({ limit: limit * 10 });
10943
+ }
10944
+ } else {
10945
+ allRecords = await this.list({ limit: limit * 10 });
10946
+ }
10947
+ const inBounds = allRecords.filter((record) => {
10948
+ const lat = record[config.latField];
10949
+ const lon = record[config.lonField];
10950
+ if (lat === void 0 || lon === void 0) {
10951
+ return false;
10952
+ }
10953
+ return lat <= north && lat >= south && lon <= east && lon >= west;
10954
+ }).slice(0, limit);
10955
+ return inBounds;
10956
+ };
10957
+ resource.getDistance = async function(id1, id2) {
10958
+ let record1, record2;
10959
+ try {
10960
+ [record1, record2] = await Promise.all([
10961
+ this.get(id1),
10962
+ this.get(id2)
10963
+ ]);
10964
+ } catch (err) {
10965
+ if (err.name === "NoSuchKey" || err.message?.includes("No such key")) {
10966
+ throw new Error("One or both records not found");
10967
+ }
10968
+ throw err;
10969
+ }
10970
+ if (!record1 || !record2) {
10971
+ throw new Error("One or both records not found");
10972
+ }
10973
+ const lat1 = record1[config.latField];
10974
+ const lon1 = record1[config.lonField];
10975
+ const lat2 = record2[config.latField];
10976
+ const lon2 = record2[config.lonField];
10977
+ if (lat1 === void 0 || lon1 === void 0 || lat2 === void 0 || lon2 === void 0) {
10978
+ throw new Error("One or both records missing coordinates");
10979
+ }
10980
+ const distance = plugin.calculateDistance(lat1, lon1, lat2, lon2);
10981
+ return {
10982
+ distance,
10983
+ unit: "km",
10984
+ from: id1,
10985
+ to: id2
10986
+ };
10987
+ };
10988
+ }
10989
+ /**
10990
+ * Encode coordinates to geohash
10991
+ * @param {number} latitude - Latitude (-90 to 90)
10992
+ * @param {number} longitude - Longitude (-180 to 180)
10993
+ * @param {number} precision - Number of characters in geohash
10994
+ * @returns {string} Geohash string
10995
+ */
10996
+ encodeGeohash(latitude, longitude, precision = 5) {
10997
+ let idx = 0;
10998
+ let bit = 0;
10999
+ let evenBit = true;
11000
+ let geohash = "";
11001
+ let latMin = -90;
11002
+ let latMax = 90;
11003
+ let lonMin = -180;
11004
+ let lonMax = 180;
11005
+ while (geohash.length < precision) {
11006
+ if (evenBit) {
11007
+ const lonMid = (lonMin + lonMax) / 2;
11008
+ if (longitude > lonMid) {
11009
+ idx |= 1 << 4 - bit;
11010
+ lonMin = lonMid;
11011
+ } else {
11012
+ lonMax = lonMid;
11013
+ }
11014
+ } else {
11015
+ const latMid = (latMin + latMax) / 2;
11016
+ if (latitude > latMid) {
11017
+ idx |= 1 << 4 - bit;
11018
+ latMin = latMid;
11019
+ } else {
11020
+ latMax = latMid;
11021
+ }
11022
+ }
11023
+ evenBit = !evenBit;
11024
+ if (bit < 4) {
11025
+ bit++;
11026
+ } else {
11027
+ geohash += this.base32[idx];
11028
+ bit = 0;
11029
+ idx = 0;
11030
+ }
11031
+ }
11032
+ return geohash;
11033
+ }
11034
+ /**
11035
+ * Decode geohash to coordinates
11036
+ * @param {string} geohash - Geohash string
11037
+ * @returns {Object} { latitude, longitude, error }
11038
+ */
11039
+ decodeGeohash(geohash) {
11040
+ let evenBit = true;
11041
+ let latMin = -90;
11042
+ let latMax = 90;
11043
+ let lonMin = -180;
11044
+ let lonMax = 180;
11045
+ for (let i = 0; i < geohash.length; i++) {
11046
+ const chr = geohash[i];
11047
+ const idx = this.base32.indexOf(chr);
11048
+ if (idx === -1) {
11049
+ throw new Error(`Invalid geohash character: ${chr}`);
11050
+ }
11051
+ for (let n = 4; n >= 0; n--) {
11052
+ const bitN = idx >> n & 1;
11053
+ if (evenBit) {
11054
+ const lonMid = (lonMin + lonMax) / 2;
11055
+ if (bitN === 1) {
11056
+ lonMin = lonMid;
11057
+ } else {
11058
+ lonMax = lonMid;
11059
+ }
11060
+ } else {
11061
+ const latMid = (latMin + latMax) / 2;
11062
+ if (bitN === 1) {
11063
+ latMin = latMid;
11064
+ } else {
11065
+ latMax = latMid;
11066
+ }
11067
+ }
11068
+ evenBit = !evenBit;
11069
+ }
11070
+ }
11071
+ const latitude = (latMin + latMax) / 2;
11072
+ const longitude = (lonMin + lonMax) / 2;
11073
+ return {
11074
+ latitude,
11075
+ longitude,
11076
+ error: {
11077
+ latitude: latMax - latMin,
11078
+ longitude: lonMax - lonMin
11079
+ }
11080
+ };
11081
+ }
11082
+ /**
11083
+ * Calculate distance between two coordinates using Haversine formula
11084
+ * @param {number} lat1 - Latitude of point 1
11085
+ * @param {number} lon1 - Longitude of point 1
11086
+ * @param {number} lat2 - Latitude of point 2
11087
+ * @param {number} lon2 - Longitude of point 2
11088
+ * @returns {number} Distance in kilometers
11089
+ */
11090
+ calculateDistance(lat1, lon1, lat2, lon2) {
11091
+ const R = 6371;
11092
+ const dLat = this._toRadians(lat2 - lat1);
11093
+ const dLon = this._toRadians(lon2 - lon1);
11094
+ 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);
11095
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
11096
+ return R * c;
11097
+ }
11098
+ /**
11099
+ * Get geohash neighbors (8 surrounding cells)
11100
+ * @param {string} geohash - Center geohash
11101
+ * @returns {Array<string>} Array of 8 neighboring geohashes
11102
+ */
11103
+ getNeighbors(geohash) {
11104
+ const decoded = this.decodeGeohash(geohash);
11105
+ const { latitude, longitude, error } = decoded;
11106
+ const latStep = error.latitude;
11107
+ const lonStep = error.longitude;
11108
+ const neighbors = [];
11109
+ const directions = [
11110
+ [-latStep, -lonStep],
11111
+ // SW
11112
+ [-latStep, 0],
11113
+ // S
11114
+ [-latStep, lonStep],
11115
+ // SE
11116
+ [0, -lonStep],
11117
+ // W
11118
+ [0, lonStep],
11119
+ // E
11120
+ [latStep, -lonStep],
11121
+ // NW
11122
+ [latStep, 0],
11123
+ // N
11124
+ [latStep, lonStep]
11125
+ // NE
11126
+ ];
11127
+ for (const [latDelta, lonDelta] of directions) {
11128
+ const neighborHash = this.encodeGeohash(
11129
+ latitude + latDelta,
11130
+ longitude + lonDelta,
11131
+ geohash.length
11132
+ );
11133
+ neighbors.push(neighborHash);
11134
+ }
11135
+ return neighbors;
11136
+ }
11137
+ /**
11138
+ * Get all geohashes that cover a bounding box
11139
+ * @param {Object} bounds - Bounding box { north, south, east, west, precision }
11140
+ * @returns {Array<string>} Array of unique geohashes covering the area
11141
+ */
11142
+ _getGeohashesInBounds({ north, south, east, west, precision }) {
11143
+ const geohashes = /* @__PURE__ */ new Set();
11144
+ const cellSize = this._getPrecisionDistance(precision);
11145
+ const latStep = cellSize / 111;
11146
+ const lonStep = cellSize / (111 * Math.cos(this._toRadians((north + south) / 2)));
11147
+ for (let lat = south; lat <= north; lat += latStep) {
11148
+ for (let lon = west; lon <= east; lon += lonStep) {
11149
+ const geohash = this.encodeGeohash(lat, lon, precision);
11150
+ geohashes.add(geohash);
11151
+ }
11152
+ }
11153
+ const corners = [
11154
+ [north, west],
11155
+ [north, east],
11156
+ [south, west],
11157
+ [south, east],
11158
+ [(north + south) / 2, west],
11159
+ [(north + south) / 2, east],
11160
+ [north, (east + west) / 2],
11161
+ [south, (east + west) / 2]
11162
+ ];
11163
+ for (const [lat, lon] of corners) {
11164
+ const geohash = this.encodeGeohash(lat, lon, precision);
11165
+ geohashes.add(geohash);
11166
+ }
11167
+ return Array.from(geohashes);
11168
+ }
11169
+ /**
11170
+ * Convert degrees to radians
11171
+ */
11172
+ _toRadians(degrees) {
11173
+ return degrees * (Math.PI / 180);
11174
+ }
11175
+ /**
11176
+ * Get approximate cell size for precision level
11177
+ */
11178
+ _getPrecisionDistance(precision) {
11179
+ const distances = {
11180
+ 1: 5e3,
11181
+ 2: 1250,
11182
+ 3: 156,
11183
+ 4: 39,
11184
+ 5: 4.9,
11185
+ 6: 1.2,
11186
+ 7: 0.15,
11187
+ 8: 0.038,
11188
+ 9: 47e-4,
11189
+ 10: 12e-4,
11190
+ 11: 15e-5,
11191
+ 12: 37e-6
11192
+ };
11193
+ return distances[precision] || 5;
11194
+ }
11195
+ /**
11196
+ * Select optimal zoom level based on search radius
11197
+ * @param {Array<number>} zoomLevels - Available zoom levels
11198
+ * @param {number} radiusKm - Search radius in kilometers
11199
+ * @returns {number} Optimal zoom precision
11200
+ */
11201
+ _selectOptimalZoom(zoomLevels, radiusKm) {
11202
+ if (!zoomLevels || zoomLevels.length === 0) {
11203
+ return null;
11204
+ }
11205
+ const targetCellSize = radiusKm / 2.5;
11206
+ let bestZoom = zoomLevels[0];
11207
+ let bestDiff = Math.abs(this._getPrecisionDistance(bestZoom) - targetCellSize);
11208
+ for (const zoom of zoomLevels) {
11209
+ const cellSize = this._getPrecisionDistance(zoom);
11210
+ const diff = Math.abs(cellSize - targetCellSize);
11211
+ if (diff < bestDiff) {
11212
+ bestDiff = diff;
11213
+ bestZoom = zoom;
11214
+ }
11215
+ }
11216
+ return bestZoom;
11217
+ }
11218
+ /**
11219
+ * Get plugin statistics
11220
+ */
11221
+ getStats() {
11222
+ return {
11223
+ resources: Object.keys(this.resources).length,
11224
+ configurations: Object.entries(this.resources).map(([name, config]) => ({
11225
+ resource: name,
11226
+ latField: config.latField,
11227
+ lonField: config.lonField,
11228
+ precision: config.precision,
11229
+ cellSize: `~${this._getPrecisionDistance(config.precision)}km`
11230
+ }))
11231
+ };
11232
+ }
11233
+ /**
11234
+ * Uninstall the plugin
11235
+ */
11236
+ async uninstall() {
11237
+ if (this.verbose) {
11238
+ console.log("[GeoPlugin] Uninstalled");
11239
+ }
11240
+ this.emit("uninstalled", {
11241
+ plugin: "GeoPlugin"
11242
+ });
11243
+ await super.uninstall();
11244
+ }
11245
+ }
11246
+
10648
11247
  class MetricsPlugin extends Plugin {
10649
11248
  constructor(options = {}) {
10650
11249
  super();
@@ -20454,7 +21053,7 @@ class Database extends EventEmitter {
20454
21053
  this.id = idGenerator(7);
20455
21054
  this.version = "1";
20456
21055
  this.s3dbVersion = (() => {
20457
- const [ok, err, version] = tryFn(() => true ? "12.2.1" : "latest");
21056
+ const [ok, err, version] = tryFn(() => true ? "12.2.2" : "latest");
20458
21057
  return ok ? version : "latest";
20459
21058
  })();
20460
21059
  this._resourcesMap = {};
@@ -35369,6 +35968,532 @@ class TfStatePlugin extends Plugin {
35369
35968
  }
35370
35969
  }
35371
35970
 
35971
+ const GRANULARITIES = {
35972
+ minute: {
35973
+ threshold: 3600,
35974
+ // TTL < 1 hour
35975
+ interval: 1e4,
35976
+ // Check every 10 seconds
35977
+ cohortsToCheck: 3,
35978
+ // Check last 3 minutes
35979
+ cohortFormat: (date) => date.toISOString().substring(0, 16)
35980
+ // '2024-10-25T14:30'
35981
+ },
35982
+ hour: {
35983
+ threshold: 86400,
35984
+ // TTL < 24 hours
35985
+ interval: 6e5,
35986
+ // Check every 10 minutes
35987
+ cohortsToCheck: 2,
35988
+ // Check last 2 hours
35989
+ cohortFormat: (date) => date.toISOString().substring(0, 13)
35990
+ // '2024-10-25T14'
35991
+ },
35992
+ day: {
35993
+ threshold: 2592e3,
35994
+ // TTL < 30 days
35995
+ interval: 36e5,
35996
+ // Check every 1 hour
35997
+ cohortsToCheck: 2,
35998
+ // Check last 2 days
35999
+ cohortFormat: (date) => date.toISOString().substring(0, 10)
36000
+ // '2024-10-25'
36001
+ },
36002
+ week: {
36003
+ threshold: Infinity,
36004
+ // TTL >= 30 days
36005
+ interval: 864e5,
36006
+ // Check every 24 hours
36007
+ cohortsToCheck: 2,
36008
+ // Check last 2 weeks
36009
+ cohortFormat: (date) => {
36010
+ const year = date.getUTCFullYear();
36011
+ const week = getWeekNumber(date);
36012
+ return `${year}-W${String(week).padStart(2, "0")}`;
36013
+ }
36014
+ }
36015
+ };
36016
+ function getWeekNumber(date) {
36017
+ const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
36018
+ const dayNum = d.getUTCDay() || 7;
36019
+ d.setUTCDate(d.getUTCDate() + 4 - dayNum);
36020
+ const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
36021
+ return Math.ceil(((d - yearStart) / 864e5 + 1) / 7);
36022
+ }
36023
+ function detectGranularity(ttl) {
36024
+ if (!ttl) return "day";
36025
+ if (ttl < GRANULARITIES.minute.threshold) return "minute";
36026
+ if (ttl < GRANULARITIES.hour.threshold) return "hour";
36027
+ if (ttl < GRANULARITIES.day.threshold) return "day";
36028
+ return "week";
36029
+ }
36030
+ function getExpiredCohorts(granularity, count) {
36031
+ const config = GRANULARITIES[granularity];
36032
+ const cohorts = [];
36033
+ const now = /* @__PURE__ */ new Date();
36034
+ for (let i = 0; i < count; i++) {
36035
+ let checkDate;
36036
+ switch (granularity) {
36037
+ case "minute":
36038
+ checkDate = new Date(now.getTime() - i * 6e4);
36039
+ break;
36040
+ case "hour":
36041
+ checkDate = new Date(now.getTime() - i * 36e5);
36042
+ break;
36043
+ case "day":
36044
+ checkDate = new Date(now.getTime() - i * 864e5);
36045
+ break;
36046
+ case "week":
36047
+ checkDate = new Date(now.getTime() - i * 6048e5);
36048
+ break;
36049
+ }
36050
+ cohorts.push(config.cohortFormat(checkDate));
36051
+ }
36052
+ return cohorts;
36053
+ }
36054
+ class TTLPlugin extends Plugin {
36055
+ constructor(config = {}) {
36056
+ super(config);
36057
+ this.verbose = config.verbose !== void 0 ? config.verbose : false;
36058
+ this.resources = config.resources || {};
36059
+ this.batchSize = config.batchSize || 100;
36060
+ this.stats = {
36061
+ totalScans: 0,
36062
+ totalExpired: 0,
36063
+ totalDeleted: 0,
36064
+ totalArchived: 0,
36065
+ totalSoftDeleted: 0,
36066
+ totalCallbacks: 0,
36067
+ totalErrors: 0,
36068
+ lastScanAt: null,
36069
+ lastScanDuration: 0
36070
+ };
36071
+ this.intervals = [];
36072
+ this.isRunning = false;
36073
+ this.expirationIndex = null;
36074
+ }
36075
+ /**
36076
+ * Install the plugin
36077
+ */
36078
+ async install(database) {
36079
+ await super.install(database);
36080
+ for (const [resourceName, config] of Object.entries(this.resources)) {
36081
+ this._validateResourceConfig(resourceName, config);
36082
+ }
36083
+ await this._createExpirationIndex();
36084
+ for (const [resourceName, config] of Object.entries(this.resources)) {
36085
+ this._setupResourceHooks(resourceName, config);
36086
+ }
36087
+ this._startIntervals();
36088
+ if (this.verbose) {
36089
+ console.log(`[TTLPlugin] Installed with ${Object.keys(this.resources).length} resources`);
36090
+ }
36091
+ this.emit("installed", {
36092
+ plugin: "TTLPlugin",
36093
+ resources: Object.keys(this.resources)
36094
+ });
36095
+ }
36096
+ /**
36097
+ * Validate resource configuration
36098
+ */
36099
+ _validateResourceConfig(resourceName, config) {
36100
+ if (!config.ttl && !config.field) {
36101
+ throw new Error(
36102
+ `[TTLPlugin] Resource "${resourceName}" must have either "ttl" (seconds) or "field" (timestamp field name)`
36103
+ );
36104
+ }
36105
+ const validStrategies = ["soft-delete", "hard-delete", "archive", "callback"];
36106
+ if (!config.onExpire || !validStrategies.includes(config.onExpire)) {
36107
+ throw new Error(
36108
+ `[TTLPlugin] Resource "${resourceName}" must have an "onExpire" value. Valid options: ${validStrategies.join(", ")}`
36109
+ );
36110
+ }
36111
+ if (config.onExpire === "soft-delete" && !config.deleteField) {
36112
+ config.deleteField = "deletedat";
36113
+ }
36114
+ if (config.onExpire === "archive" && !config.archiveResource) {
36115
+ throw new Error(
36116
+ `[TTLPlugin] Resource "${resourceName}" with onExpire="archive" must have an "archiveResource" specified`
36117
+ );
36118
+ }
36119
+ if (config.onExpire === "callback" && typeof config.callback !== "function") {
36120
+ throw new Error(
36121
+ `[TTLPlugin] Resource "${resourceName}" with onExpire="callback" must have a "callback" function`
36122
+ );
36123
+ }
36124
+ if (!config.field) {
36125
+ config.field = "_createdAt";
36126
+ }
36127
+ if (config.field === "_createdAt" && this.database) {
36128
+ const resource = this.database.resources[resourceName];
36129
+ if (resource && resource.config && resource.config.timestamps === false) {
36130
+ console.warn(
36131
+ `[TTLPlugin] WARNING: Resource "${resourceName}" uses TTL with field "_createdAt" but timestamps are disabled. TTL will be calculated from indexing time, not creation time.`
36132
+ );
36133
+ }
36134
+ }
36135
+ config.granularity = detectGranularity(config.ttl);
36136
+ }
36137
+ /**
36138
+ * Create expiration index (plugin resource)
36139
+ */
36140
+ async _createExpirationIndex() {
36141
+ this.expirationIndex = await this.database.createResource({
36142
+ name: "plg_ttl_expiration_index",
36143
+ attributes: {
36144
+ resourceName: "string|required",
36145
+ recordId: "string|required",
36146
+ expiresAtCohort: "string|required",
36147
+ expiresAtTimestamp: "number|required",
36148
+ // Exact expiration timestamp for precise checking
36149
+ granularity: "string|required",
36150
+ createdAt: "number"
36151
+ },
36152
+ partitions: {
36153
+ byExpiresAtCohort: {
36154
+ fields: { expiresAtCohort: "string" }
36155
+ }
36156
+ },
36157
+ asyncPartitions: false
36158
+ // Sync partitions for deterministic behavior
36159
+ });
36160
+ if (this.verbose) {
36161
+ console.log("[TTLPlugin] Created expiration index with partition");
36162
+ }
36163
+ }
36164
+ /**
36165
+ * Setup hooks for a resource
36166
+ */
36167
+ _setupResourceHooks(resourceName, config) {
36168
+ if (!this.database.resources[resourceName]) {
36169
+ if (this.verbose) {
36170
+ console.warn(`[TTLPlugin] Resource "${resourceName}" not found, skipping hooks`);
36171
+ }
36172
+ return;
36173
+ }
36174
+ const resource = this.database.resources[resourceName];
36175
+ if (typeof resource.insert !== "function" || typeof resource.delete !== "function") {
36176
+ if (this.verbose) {
36177
+ console.warn(`[TTLPlugin] Resource "${resourceName}" missing insert/delete methods, skipping hooks`);
36178
+ }
36179
+ return;
36180
+ }
36181
+ this.addMiddleware(resource, "insert", async (next, data, options) => {
36182
+ const result = await next(data, options);
36183
+ await this._addToIndex(resourceName, result, config);
36184
+ return result;
36185
+ });
36186
+ this.addMiddleware(resource, "delete", async (next, id, options) => {
36187
+ const result = await next(id, options);
36188
+ await this._removeFromIndex(resourceName, id);
36189
+ return result;
36190
+ });
36191
+ if (this.verbose) {
36192
+ console.log(`[TTLPlugin] Setup hooks for resource "${resourceName}"`);
36193
+ }
36194
+ }
36195
+ /**
36196
+ * Add record to expiration index
36197
+ */
36198
+ async _addToIndex(resourceName, record, config) {
36199
+ try {
36200
+ let baseTime = record[config.field];
36201
+ if (!baseTime && config.field === "_createdAt") {
36202
+ baseTime = Date.now();
36203
+ }
36204
+ if (!baseTime) {
36205
+ if (this.verbose) {
36206
+ console.warn(
36207
+ `[TTLPlugin] Record ${record.id} in ${resourceName} missing field "${config.field}", skipping index`
36208
+ );
36209
+ }
36210
+ return;
36211
+ }
36212
+ const baseTimestamp = typeof baseTime === "number" ? baseTime : new Date(baseTime).getTime();
36213
+ const expiresAt = config.ttl ? new Date(baseTimestamp + config.ttl * 1e3) : new Date(baseTimestamp);
36214
+ const cohortConfig = GRANULARITIES[config.granularity];
36215
+ const cohort = cohortConfig.cohortFormat(expiresAt);
36216
+ const indexId = `${resourceName}:${record.id}`;
36217
+ await this.expirationIndex.insert({
36218
+ id: indexId,
36219
+ resourceName,
36220
+ recordId: record.id,
36221
+ expiresAtCohort: cohort,
36222
+ expiresAtTimestamp: expiresAt.getTime(),
36223
+ // Store exact timestamp for precise checking
36224
+ granularity: config.granularity,
36225
+ createdAt: Date.now()
36226
+ });
36227
+ if (this.verbose) {
36228
+ console.log(
36229
+ `[TTLPlugin] Added ${resourceName}:${record.id} to index (cohort: ${cohort}, granularity: ${config.granularity})`
36230
+ );
36231
+ }
36232
+ } catch (error) {
36233
+ console.error(`[TTLPlugin] Error adding to index:`, error);
36234
+ this.stats.totalErrors++;
36235
+ }
36236
+ }
36237
+ /**
36238
+ * Remove record from expiration index (O(1) using deterministic ID)
36239
+ */
36240
+ async _removeFromIndex(resourceName, recordId) {
36241
+ try {
36242
+ const indexId = `${resourceName}:${recordId}`;
36243
+ const [ok, err] = await tryFn(() => this.expirationIndex.delete(indexId));
36244
+ if (this.verbose && ok) {
36245
+ console.log(`[TTLPlugin] Removed index entry for ${resourceName}:${recordId}`);
36246
+ }
36247
+ if (!ok && err?.code !== "NoSuchKey") {
36248
+ throw err;
36249
+ }
36250
+ } catch (error) {
36251
+ console.error(`[TTLPlugin] Error removing from index:`, error);
36252
+ }
36253
+ }
36254
+ /**
36255
+ * Start interval-based cleanup for each granularity
36256
+ */
36257
+ _startIntervals() {
36258
+ const byGranularity = {
36259
+ minute: [],
36260
+ hour: [],
36261
+ day: [],
36262
+ week: []
36263
+ };
36264
+ for (const [name, config] of Object.entries(this.resources)) {
36265
+ byGranularity[config.granularity].push({ name, config });
36266
+ }
36267
+ for (const [granularity, resources] of Object.entries(byGranularity)) {
36268
+ if (resources.length === 0) continue;
36269
+ const granularityConfig = GRANULARITIES[granularity];
36270
+ const handle = setInterval(
36271
+ () => this._cleanupGranularity(granularity, resources),
36272
+ granularityConfig.interval
36273
+ );
36274
+ this.intervals.push(handle);
36275
+ if (this.verbose) {
36276
+ console.log(
36277
+ `[TTLPlugin] Started ${granularity} interval (${granularityConfig.interval}ms) for ${resources.length} resources`
36278
+ );
36279
+ }
36280
+ }
36281
+ this.isRunning = true;
36282
+ }
36283
+ /**
36284
+ * Stop all intervals
36285
+ */
36286
+ _stopIntervals() {
36287
+ for (const handle of this.intervals) {
36288
+ clearInterval(handle);
36289
+ }
36290
+ this.intervals = [];
36291
+ this.isRunning = false;
36292
+ if (this.verbose) {
36293
+ console.log("[TTLPlugin] Stopped all intervals");
36294
+ }
36295
+ }
36296
+ /**
36297
+ * Cleanup expired records for a specific granularity
36298
+ */
36299
+ async _cleanupGranularity(granularity, resources) {
36300
+ const startTime = Date.now();
36301
+ this.stats.totalScans++;
36302
+ try {
36303
+ const granularityConfig = GRANULARITIES[granularity];
36304
+ const cohorts = getExpiredCohorts(granularity, granularityConfig.cohortsToCheck);
36305
+ if (this.verbose) {
36306
+ console.log(`[TTLPlugin] Cleaning ${granularity} granularity, checking cohorts:`, cohorts);
36307
+ }
36308
+ for (const cohort of cohorts) {
36309
+ const expired = await this.expirationIndex.listPartition({
36310
+ partition: "byExpiresAtCohort",
36311
+ partitionValues: { expiresAtCohort: cohort }
36312
+ });
36313
+ const resourceNames = new Set(resources.map((r) => r.name));
36314
+ const filtered = expired.filter((e) => resourceNames.has(e.resourceName));
36315
+ if (this.verbose && filtered.length > 0) {
36316
+ console.log(`[TTLPlugin] Found ${filtered.length} expired records in cohort ${cohort}`);
36317
+ }
36318
+ for (let i = 0; i < filtered.length; i += this.batchSize) {
36319
+ const batch = filtered.slice(i, i + this.batchSize);
36320
+ for (const entry of batch) {
36321
+ const config = this.resources[entry.resourceName];
36322
+ await this._processExpiredEntry(entry, config);
36323
+ }
36324
+ }
36325
+ }
36326
+ this.stats.lastScanAt = (/* @__PURE__ */ new Date()).toISOString();
36327
+ this.stats.lastScanDuration = Date.now() - startTime;
36328
+ this.emit("scanCompleted", {
36329
+ granularity,
36330
+ duration: this.stats.lastScanDuration,
36331
+ cohorts
36332
+ });
36333
+ } catch (error) {
36334
+ console.error(`[TTLPlugin] Error in ${granularity} cleanup:`, error);
36335
+ this.stats.totalErrors++;
36336
+ this.emit("cleanupError", { granularity, error });
36337
+ }
36338
+ }
36339
+ /**
36340
+ * Process a single expired index entry
36341
+ */
36342
+ async _processExpiredEntry(entry, config) {
36343
+ try {
36344
+ if (!this.database.resources[entry.resourceName]) {
36345
+ if (this.verbose) {
36346
+ console.warn(`[TTLPlugin] Resource "${entry.resourceName}" not found during cleanup, skipping`);
36347
+ }
36348
+ return;
36349
+ }
36350
+ const resource = this.database.resources[entry.resourceName];
36351
+ const [ok, err, record] = await tryFn(() => resource.get(entry.recordId));
36352
+ if (!ok || !record) {
36353
+ await this.expirationIndex.delete(entry.id);
36354
+ return;
36355
+ }
36356
+ if (entry.expiresAtTimestamp && Date.now() < entry.expiresAtTimestamp) {
36357
+ return;
36358
+ }
36359
+ switch (config.onExpire) {
36360
+ case "soft-delete":
36361
+ await this._softDelete(resource, record, config);
36362
+ this.stats.totalSoftDeleted++;
36363
+ break;
36364
+ case "hard-delete":
36365
+ await this._hardDelete(resource, record);
36366
+ this.stats.totalDeleted++;
36367
+ break;
36368
+ case "archive":
36369
+ await this._archive(resource, record, config);
36370
+ this.stats.totalArchived++;
36371
+ this.stats.totalDeleted++;
36372
+ break;
36373
+ case "callback":
36374
+ const shouldDelete = await config.callback(record, resource);
36375
+ this.stats.totalCallbacks++;
36376
+ if (shouldDelete) {
36377
+ await this._hardDelete(resource, record);
36378
+ this.stats.totalDeleted++;
36379
+ }
36380
+ break;
36381
+ }
36382
+ await this.expirationIndex.delete(entry.id);
36383
+ this.stats.totalExpired++;
36384
+ this.emit("recordExpired", { resource: entry.resourceName, record });
36385
+ } catch (error) {
36386
+ console.error(`[TTLPlugin] Error processing expired entry:`, error);
36387
+ this.stats.totalErrors++;
36388
+ }
36389
+ }
36390
+ /**
36391
+ * Soft delete: Mark record as deleted
36392
+ */
36393
+ async _softDelete(resource, record, config) {
36394
+ const deleteField = config.deleteField || "deletedat";
36395
+ const updates = {
36396
+ [deleteField]: (/* @__PURE__ */ new Date()).toISOString(),
36397
+ isdeleted: "true"
36398
+ // Add isdeleted field for partition compatibility
36399
+ };
36400
+ await resource.update(record.id, updates);
36401
+ if (this.verbose) {
36402
+ console.log(`[TTLPlugin] Soft-deleted record ${record.id} in ${resource.name}`);
36403
+ }
36404
+ }
36405
+ /**
36406
+ * Hard delete: Remove record from S3
36407
+ */
36408
+ async _hardDelete(resource, record) {
36409
+ await resource.delete(record.id);
36410
+ if (this.verbose) {
36411
+ console.log(`[TTLPlugin] Hard-deleted record ${record.id} in ${resource.name}`);
36412
+ }
36413
+ }
36414
+ /**
36415
+ * Archive: Copy to another resource then delete
36416
+ */
36417
+ async _archive(resource, record, config) {
36418
+ if (!this.database.resources[config.archiveResource]) {
36419
+ throw new Error(`Archive resource "${config.archiveResource}" not found`);
36420
+ }
36421
+ const archiveResource = this.database.resources[config.archiveResource];
36422
+ const archiveData = {};
36423
+ for (const [key, value] of Object.entries(record)) {
36424
+ if (!key.startsWith("_")) {
36425
+ archiveData[key] = value;
36426
+ }
36427
+ }
36428
+ archiveData.archivedAt = (/* @__PURE__ */ new Date()).toISOString();
36429
+ archiveData.archivedFrom = resource.name;
36430
+ archiveData.originalId = record.id;
36431
+ if (!config.keepOriginalId) {
36432
+ delete archiveData.id;
36433
+ }
36434
+ await archiveResource.insert(archiveData);
36435
+ await resource.delete(record.id);
36436
+ if (this.verbose) {
36437
+ console.log(`[TTLPlugin] Archived record ${record.id} from ${resource.name} to ${config.archiveResource}`);
36438
+ }
36439
+ }
36440
+ /**
36441
+ * Manual cleanup of a specific resource
36442
+ */
36443
+ async cleanupResource(resourceName) {
36444
+ const config = this.resources[resourceName];
36445
+ if (!config) {
36446
+ throw new Error(`Resource "${resourceName}" not configured in TTLPlugin`);
36447
+ }
36448
+ const granularity = config.granularity;
36449
+ await this._cleanupGranularity(granularity, [{ name: resourceName, config }]);
36450
+ return {
36451
+ resource: resourceName,
36452
+ granularity
36453
+ };
36454
+ }
36455
+ /**
36456
+ * Manual cleanup of all resources
36457
+ */
36458
+ async runCleanup() {
36459
+ const byGranularity = {
36460
+ minute: [],
36461
+ hour: [],
36462
+ day: [],
36463
+ week: []
36464
+ };
36465
+ for (const [name, config] of Object.entries(this.resources)) {
36466
+ byGranularity[config.granularity].push({ name, config });
36467
+ }
36468
+ for (const [granularity, resources] of Object.entries(byGranularity)) {
36469
+ if (resources.length > 0) {
36470
+ await this._cleanupGranularity(granularity, resources);
36471
+ }
36472
+ }
36473
+ }
36474
+ /**
36475
+ * Get plugin statistics
36476
+ */
36477
+ getStats() {
36478
+ return {
36479
+ ...this.stats,
36480
+ resources: Object.keys(this.resources).length,
36481
+ isRunning: this.isRunning,
36482
+ intervals: this.intervals.length
36483
+ };
36484
+ }
36485
+ /**
36486
+ * Uninstall the plugin
36487
+ */
36488
+ async uninstall() {
36489
+ this._stopIntervals();
36490
+ await super.uninstall();
36491
+ if (this.verbose) {
36492
+ console.log("[TTLPlugin] Uninstalled");
36493
+ }
36494
+ }
36495
+ }
36496
+
35372
36497
  function cosineDistance(a, b) {
35373
36498
  if (a.length !== b.length) {
35374
36499
  throw new Error(`Dimension mismatch: ${a.length} vs ${b.length}`);
@@ -37170,6 +38295,7 @@ exports.Factory = Factory;
37170
38295
  exports.FilesystemBackupDriver = FilesystemBackupDriver;
37171
38296
  exports.FilesystemCache = FilesystemCache;
37172
38297
  exports.FullTextPlugin = FullTextPlugin;
38298
+ exports.GeoPlugin = GeoPlugin;
37173
38299
  exports.InvalidResourceItem = InvalidResourceItem;
37174
38300
  exports.MemoryCache = MemoryCache;
37175
38301
  exports.MetadataLimitError = MetadataLimitError;
@@ -37217,6 +38343,7 @@ exports.SqsConsumer = SqsConsumer;
37217
38343
  exports.SqsReplicator = SqsReplicator;
37218
38344
  exports.StateMachinePlugin = StateMachinePlugin;
37219
38345
  exports.StreamError = StreamError;
38346
+ exports.TTLPlugin = TTLPlugin;
37220
38347
  exports.TfStatePlugin = TfStatePlugin;
37221
38348
  exports.TursoReplicator = TursoReplicator;
37222
38349
  exports.UnknownError = UnknownError;