tiny-essentials 1.9.2 → 1.10.1

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.
@@ -2160,6 +2160,7 @@ __webpack_require__.d(__webpack_exports__, {
2160
2160
  getAge: () => (/* reexport */ getAge),
2161
2161
  getSimplePerc: () => (/* reexport */ getSimplePerc),
2162
2162
  getTimeDuration: () => (/* reexport */ getTimeDuration),
2163
+ isJsonObject: () => (/* reexport */ isJsonObject),
2163
2164
  objType: () => (/* reexport */ objType),
2164
2165
  reorderObjTypeOrder: () => (/* reexport */ reorderObjTypeOrder),
2165
2166
  ruleOfThree: () => (/* reexport */ ruleOfThree),
@@ -2939,6 +2940,28 @@ function countObj(obj) {
2939
2940
  return 0;
2940
2941
  }
2941
2942
 
2943
+ /**
2944
+ * Determines whether a given value is a pure JSON object (plain object).
2945
+ *
2946
+ * A pure object satisfies the following:
2947
+ * - It is not null.
2948
+ * - Its type is "object".
2949
+ * - Its internal [[Class]] is "[object Object]".
2950
+ * - It is not an instance of built-in types like Array, Date, Map, Set, etc.
2951
+ *
2952
+ * This function is useful for strict data validation when you want to ensure
2953
+ * a value is a clean JSON-compatible object, free of class instances or special types.
2954
+ *
2955
+ * @param {unknown} value - The value to test.
2956
+ * @returns {value is Record<unknown, unknown>} Returns true if the value is a pure object.
2957
+ */
2958
+ function isJsonObject(value) {
2959
+ if (value === null || typeof value !== 'object') return false;
2960
+ if (Array.isArray(value)) return false;
2961
+ if (Object.prototype.toString.call(value) !== '[object Object]') return false;
2962
+ return true;
2963
+ }
2964
+
2942
2965
  // Insert obj types
2943
2966
 
2944
2967
  extendObjType([
@@ -3022,27 +3045,27 @@ extendObjType([
3022
3045
  ],
3023
3046
  [
3024
3047
  'map',
3025
- /** @param {*} val @returns {val is Map<any, any>} */
3048
+ /** @param {*} val @returns {val is Map<unknown, unknown>} */
3026
3049
  (val) => val instanceof Map,
3027
3050
  ],
3028
3051
  [
3029
3052
  'set',
3030
- /** @param {*} val @returns {val is Set<any>} */
3053
+ /** @param {*} val @returns {val is Set<unknown>} */
3031
3054
  (val) => val instanceof Set,
3032
3055
  ],
3033
3056
  [
3034
3057
  'weakmap',
3035
- /** @param {*} val @returns {val is WeakMap<any, any>} */
3058
+ /** @param {*} val @returns {val is WeakMap<unknown, unknown>} */
3036
3059
  (val) => val instanceof WeakMap,
3037
3060
  ],
3038
3061
  [
3039
3062
  'weakset',
3040
- /** @param {*} val @returns {val is WeakSet<any>} */
3063
+ /** @param {*} val @returns {val is WeakSet<unknown>} */
3041
3064
  (val) => val instanceof WeakSet,
3042
3065
  ],
3043
3066
  [
3044
3067
  'promise',
3045
- /** @param {*} val @returns {val is Promise<any>} */
3068
+ /** @param {*} val @returns {val is Promise<unknown>} */
3046
3069
  (val) => val instanceof Promise,
3047
3070
  ],
3048
3071
  ]);
@@ -3060,8 +3083,8 @@ if (isBrowser) {
3060
3083
  extendObjType([
3061
3084
  [
3062
3085
  'object',
3063
- /** @param {*} val @returns {val is object} */
3064
- (val) => typeof val === 'object' && val !== null && !Array.isArray(val),
3086
+ /** @param {*} val @returns {val is Record<unknown, unknown>} */
3087
+ (val) => isJsonObject(val),
3065
3088
  ],
3066
3089
  ]);
3067
3090
 
@@ -3738,24 +3761,109 @@ class TinyPromiseQueue {
3738
3761
  /* harmony default export */ const libs_TinyPromiseQueue = (TinyPromiseQueue);
3739
3762
 
3740
3763
  ;// ./src/v1/libs/TinyRateLimiter.mjs
3764
+ /** @typedef {(groupId: string) => void} OnMemoryExceeded */
3765
+
3766
+ /** @typedef {(groupId: string) => void} OnGroupExpired */
3767
+
3741
3768
  /**
3742
- * Class representing a flexible rate limiter per user.
3743
- *
3744
- * This rate limiter can be configured by maximum number of hits,
3745
- * time interval, or a combination of both. It supports automatic
3746
- * cleanup of inactive users to optimize memory usage.
3769
+ * Class representing a flexible rate limiter per user or group.
3747
3770
  *
3748
- * @class
3771
+ * This rate limiter supports limiting per user or per group by mapping
3772
+ * userIds to a common groupId. All users within the same group share
3773
+ * rate limits.
3749
3774
  */
3750
3775
  class TinyRateLimiter {
3776
+ /** @type {number|null} */
3777
+ #maxMemory = null;
3778
+
3779
+ /** @type {NodeJS.Timeout|null} */
3780
+ #cleanupTimer = null;
3781
+
3782
+ /** @type {number|null|undefined} */
3783
+ #maxHits = null;
3784
+
3785
+ /** @type {number|null|undefined} */
3786
+ #interval = null;
3787
+
3788
+ /** @type {number|null|undefined} */
3789
+ #cleanupInterval = null;
3790
+
3791
+ /** @type {number|null|undefined} */
3792
+ #maxIdle = null;
3793
+
3794
+ /** @type {Map<string, number[]>} */
3795
+ groupData = new Map(); // groupId -> timestamps[]
3796
+
3797
+ /** @type {Map<string, number>} */
3798
+ lastSeen = new Map(); // groupId -> timestamp
3799
+
3800
+ /** @type {Map<string, string>} */
3801
+ userToGroup = new Map(); // userId -> groupId
3802
+
3803
+ /** @type {Map<string, boolean>} */
3804
+ groupFlags = new Map(); // groupId -> boolean
3805
+
3806
+ /**
3807
+ * @type {Map<string, number>}
3808
+ * Stores TTL (in ms) for each groupId individually
3809
+ */
3810
+ groupTTL = new Map();
3811
+
3812
+ /**
3813
+ * @type {null|OnMemoryExceeded}
3814
+ */
3815
+ #onMemoryExceeded = null;
3816
+
3817
+ /**
3818
+ * Set the callback to be triggered when a group exceeds its limit
3819
+ * @param {OnMemoryExceeded} callback
3820
+ */
3821
+ setOnMemoryExceeded(callback) {
3822
+ if (typeof callback !== 'function') throw new Error('onMemoryExceeded must be a function');
3823
+ this.#onMemoryExceeded = callback;
3824
+ }
3825
+
3826
+ /**
3827
+ * Clear the onMemoryExceeded callback
3828
+ */
3829
+ clearOnMemoryExceeded() {
3830
+ this.#onMemoryExceeded = null;
3831
+ }
3832
+
3833
+ /**
3834
+ * @type {null|OnGroupExpired}
3835
+ */
3836
+ #onGroupExpired = null;
3837
+
3838
+ /**
3839
+ * Set the callback to be triggered when a group expires and is removed.
3840
+ *
3841
+ * This callback is called automatically during cleanup when a group
3842
+ * becomes inactive for longer than its TTL.
3843
+ *
3844
+ * @param {OnGroupExpired} callback - A function that receives the expired groupId.
3845
+ */
3846
+ setOnGroupExpired(callback) {
3847
+ if (typeof callback !== 'function') throw new Error('onGroupExpired must be a function');
3848
+ this.#onGroupExpired = callback;
3849
+ }
3850
+
3851
+ /**
3852
+ * Clear the onGroupExpired callback
3853
+ */
3854
+ clearOnGroupExpired() {
3855
+ this.#onGroupExpired = null;
3856
+ }
3857
+
3751
3858
  /**
3752
3859
  * @param {Object} options
3860
+ * @param {number|null} [options.maxMemory] - Max memory allowed
3753
3861
  * @param {number} [options.maxHits] - Max interactions allowed
3754
3862
  * @param {number} [options.interval] - Time window in milliseconds
3755
3863
  * @param {number} [options.cleanupInterval] - Interval for automatic cleanup (ms)
3756
3864
  * @param {number} [options.maxIdle=300000] - Max idle time for a user before being cleaned (ms)
3757
3865
  */
3758
- constructor({ maxHits, interval, cleanupInterval, maxIdle = 300000 }) {
3866
+ constructor({ maxHits, interval, cleanupInterval, maxIdle = 300000, maxMemory = 100000 }) {
3759
3867
  /** @param {number|undefined} val */
3760
3868
  const isPositiveInteger = (val) =>
3761
3869
  typeof val === 'number' && Number.isFinite(val) && val >= 1 && Number.isInteger(val);
@@ -3775,45 +3883,129 @@ class TinyRateLimiter {
3775
3883
  throw new Error("'cleanupInterval' must be a positive integer in milliseconds if defined.");
3776
3884
  if (!isMaxIdleValid) throw new Error("'maxIdle' must be a positive integer in milliseconds.");
3777
3885
 
3778
- this.maxHits = isMaxHitsValid ? maxHits : null;
3779
- this.interval = isIntervalValid ? interval : null;
3780
- this.cleanupInterval = isCleanupValid ? cleanupInterval : null;
3781
- this.maxIdle = maxIdle;
3782
-
3783
- /** @type {Map<string, number[]>} */
3784
- this.userData = new Map();
3886
+ if (typeof maxMemory === 'number' && Number.isFinite(maxMemory) && maxMemory > 0) {
3887
+ this.#maxMemory = Math.floor(maxMemory);
3888
+ } else if (maxMemory === null || maxMemory === undefined) {
3889
+ this.#maxMemory = null;
3890
+ } else {
3891
+ throw new Error('maxMemory must be a positive number or null');
3892
+ }
3785
3893
 
3786
- /** @type {Map<string, number>} */
3787
- this.lastSeen = new Map();
3894
+ this.#maxHits = isMaxHitsValid ? maxHits : null;
3895
+ this.#interval = isIntervalValid ? interval : null;
3896
+ this.#cleanupInterval = isCleanupValid ? cleanupInterval : null;
3897
+ this.#maxIdle = maxIdle;
3788
3898
 
3789
3899
  // Start automatic cleanup only if cleanupInterval is valid
3790
- if (this.cleanupInterval !== null)
3791
- this._cleanupTimer = setInterval(() => this._cleanup(), this.cleanupInterval);
3900
+ if (this.#cleanupInterval !== null)
3901
+ this.#cleanupTimer = setInterval(() => this._cleanup(), this.#cleanupInterval);
3792
3902
  }
3793
3903
 
3794
3904
  /**
3795
- * Get the interval window in milliseconds.
3796
- *
3797
- * @returns {number} The interval value.
3798
- * @throws {Error} If interval is not a valid finite number.
3905
+ * Check if a given ID is a groupId (not a userId)
3906
+ * @param {string} id
3907
+ * @returns {boolean}
3799
3908
  */
3800
- getInterval() {
3801
- if (typeof this.interval !== 'number' || !Number.isFinite(this.interval))
3802
- throw new Error("'interval' is not a valid finite number.");
3803
- return this.interval;
3909
+ isGroupId(id) {
3910
+ const result = this.groupFlags.get(id);
3911
+ return typeof result === 'boolean' ? result : false;
3804
3912
  }
3805
3913
 
3806
3914
  /**
3807
- * Get the maximum number of allowed hits.
3808
- *
3809
- * @returns {number} The maxHits value.
3810
- * @throws {Error} If maxHits is not a valid finite number.
3915
+ * Get all user IDs that belong to a given group.
3916
+ * @param {string} groupId
3917
+ * @returns {string[]}
3811
3918
  */
3812
- getMaxHits() {
3813
- if (typeof this.maxHits !== 'number' || !Number.isFinite(this.maxHits)) {
3814
- throw new Error("'maxHits' is not a valid finite number.");
3919
+ getUsersInGroup(groupId) {
3920
+ const users = [];
3921
+ for (const [userId, assignedGroup] of this.userToGroup.entries()) {
3922
+ if (assignedGroup === groupId) {
3923
+ users.push(userId);
3924
+ }
3925
+ }
3926
+
3927
+ return users;
3928
+ }
3929
+
3930
+ /**
3931
+ * Set TTL (in milliseconds) for a specific group
3932
+ * @param {string} groupId
3933
+ * @param {number} ttl
3934
+ */
3935
+ setGroupTTL(groupId, ttl) {
3936
+ if (typeof ttl !== 'number' || !Number.isFinite(ttl) || ttl <= 0)
3937
+ throw new Error('TTL must be a positive number in milliseconds');
3938
+ this.groupTTL.set(groupId, ttl);
3939
+ }
3940
+
3941
+ /**
3942
+ * Get TTL (in ms) for a specific group.
3943
+ * @param {string} groupId
3944
+ * @returns {number|null}
3945
+ */
3946
+ getGroupTTL(groupId) {
3947
+ return this.groupTTL.get(groupId) ?? null;
3948
+ }
3949
+
3950
+ /**
3951
+ * Delete the TTL setting for a specific group
3952
+ * @param {string} groupId
3953
+ */
3954
+ deleteGroupTTL(groupId) {
3955
+ this.groupTTL.delete(groupId);
3956
+ }
3957
+
3958
+ /**
3959
+ * Assign a userId to a groupId, with merge if user has existing data.
3960
+ * @param {string} userId
3961
+ * @param {string} groupId
3962
+ * @throws {Error} If userId is already assigned to a different group
3963
+ */
3964
+ assignToGroup(userId, groupId) {
3965
+ const existingGroup = this.userToGroup.get(userId);
3966
+ if (existingGroup && existingGroup !== groupId)
3967
+ throw new Error(`User ${userId} is already assigned to group ${existingGroup}`);
3968
+
3969
+ // If the user is already in the group, nothing needs to be done
3970
+ if (existingGroup === groupId) return;
3971
+ const userData = this.groupData.get(userId);
3972
+
3973
+ // Associates the user to the group
3974
+ if (this.isGroupId(userId)) {
3975
+ for (const [uid, gId] of this.userToGroup.entries())
3976
+ if (gId === userId) this.userToGroup.set(uid, groupId);
3977
+ this.userToGroup.delete(userId);
3978
+ } else this.userToGroup.set(userId, groupId);
3979
+
3980
+ // If the user has no data, nothing needs to be done
3981
+ if (!userData) return;
3982
+
3983
+ const groupData = this.groupData.get(groupId);
3984
+ if (groupData) {
3985
+ for (const item of userData) groupData.push(item);
3986
+ } else {
3987
+ const newData = [];
3988
+ for (const item of userData) newData.push(item);
3989
+ this.groupData.set(groupId, newData);
3815
3990
  }
3816
- return this.maxHits;
3991
+
3992
+ this.lastSeen.set(groupId, Date.now());
3993
+
3994
+ // Removes individual data as they are now in the group
3995
+ this.groupFlags.delete(userId);
3996
+ this.groupData.delete(userId);
3997
+ this.lastSeen.delete(userId);
3998
+ this.groupTTL.delete(userId);
3999
+ this.groupFlags.set(groupId, true);
4000
+ }
4001
+
4002
+ /**
4003
+ * Get the groupId for a given userId
4004
+ * @param {string} userId
4005
+ * @returns {string}
4006
+ */
4007
+ getGroupId(userId) {
4008
+ return this.userToGroup.get(userId) || userId; // fallback: use userId as own group
3817
4009
  }
3818
4010
 
3819
4011
  /**
@@ -3821,20 +4013,22 @@ class TinyRateLimiter {
3821
4013
  * @param {string} userId
3822
4014
  */
3823
4015
  hit(userId) {
4016
+ const groupId = this.getGroupId(userId);
3824
4017
  const now = Date.now();
3825
4018
 
3826
- if (!this.userData.has(userId)) {
3827
- this.userData.set(userId, []);
4019
+ if (!this.groupData.has(groupId)) {
4020
+ this.groupData.set(groupId, []);
4021
+ this.groupFlags.set(groupId, false);
3828
4022
  }
3829
4023
 
3830
- const history = this.userData.get(userId);
3831
- if (!history) throw new Error(`No data found for userId: ${userId}`);
4024
+ const history = this.groupData.get(groupId);
4025
+ if (!history) throw new Error(`No data found for groupId: ${groupId}`);
3832
4026
 
3833
4027
  history.push(now);
3834
- this.lastSeen.set(userId, now);
4028
+ this.lastSeen.set(groupId, now);
3835
4029
 
3836
4030
  // Clean up old entries
3837
- if (this.interval !== null) {
4031
+ if (this.#interval !== null) {
3838
4032
  const interval = this.getInterval();
3839
4033
  const cutoff = now - interval;
3840
4034
  while (history.length && history[0] < cutoff) {
@@ -3843,92 +4037,295 @@ class TinyRateLimiter {
3843
4037
  }
3844
4038
 
3845
4039
  // Optional: keep only the last N entries for memory optimization
3846
- if (this.maxHits !== null) {
3847
- const maxHits = this.getMaxHits();
3848
- if (history.length > maxHits) {
3849
- history.splice(0, history.length - maxHits);
4040
+ if (this.#maxMemory !== null && typeof this.#maxMemory === 'number') {
4041
+ if (history.length > this.#maxMemory) {
4042
+ history.splice(0, history.length - this.#maxMemory);
4043
+ if (typeof this.#onMemoryExceeded === 'function') this.#onMemoryExceeded(groupId);
3850
4044
  }
3851
4045
  }
3852
4046
  }
3853
4047
 
3854
4048
  /**
3855
- * Check if the user is currently rate limited
4049
+ * Check if the user (via their group) is currently rate limited
3856
4050
  * @param {string} userId
3857
4051
  * @returns {boolean}
3858
4052
  */
3859
4053
  isRateLimited(userId) {
3860
- const now = Date.now();
4054
+ const groupId = this.getGroupId(userId);
4055
+ if (!this.groupData.has(groupId)) return false;
3861
4056
 
3862
- if (!this.userData.has(userId)) return false;
4057
+ const history = this.groupData.get(groupId);
4058
+ if (!history) throw new Error(`No data found for groupId: ${groupId}`);
3863
4059
 
3864
- const history = this.userData.get(userId);
3865
- if (!history) throw new Error(`No data found for userId: ${userId}`);
3866
-
3867
- if (this.interval !== null) {
4060
+ if (this.#interval !== null) {
4061
+ const now = Date.now();
3868
4062
  const interval = this.getInterval();
3869
- const recent = history.filter((t) => t > now - interval);
3870
- if (this.maxHits !== null) {
3871
- return recent.length >= this.getMaxHits();
4063
+ const cutoff = now - interval;
4064
+ let count = 0;
4065
+ for (let i = history.length - 1; i >= 0; i--) {
4066
+ if (history[i] > cutoff) count++;
4067
+ else break;
3872
4068
  }
3873
- return recent.length > 0;
4069
+ if (this.#maxHits !== null) return count > this.getMaxHits();
4070
+ return count > 0;
3874
4071
  }
3875
4072
 
3876
- if (this.maxHits !== null) {
3877
- return history.length >= this.getMaxHits();
4073
+ if (this.#maxHits !== null) {
4074
+ return history.length > this.getMaxHits();
3878
4075
  }
3879
4076
 
3880
4077
  return false;
3881
4078
  }
3882
4079
 
3883
4080
  /**
3884
- * Manually reset user data
4081
+ * Manually reset group data
4082
+ * @param {string} groupId
4083
+ */
4084
+ resetGroup(groupId) {
4085
+ this.groupFlags.delete(groupId);
4086
+ this.groupData.delete(groupId);
4087
+ this.lastSeen.delete(groupId);
4088
+ this.groupTTL.delete(groupId);
4089
+ }
4090
+
4091
+ /**
4092
+ * Manually reset user data.
4093
+ *
4094
+ * @deprecated Use `resetUserGroup(userId)` instead. This method will be removed in future versions.
3885
4095
  * @param {string} userId
4096
+ * @returns {void}
3886
4097
  */
3887
4098
  reset(userId) {
3888
- this.userData.delete(userId);
3889
- this.lastSeen.delete(userId);
4099
+ if (false)
4100
+ {}
4101
+ return this.resetUserGroup(userId);
3890
4102
  }
3891
4103
 
3892
4104
  /**
3893
- * Set hit timestamps for a user
4105
+ * Manually reset a user mapping
3894
4106
  * @param {string} userId
4107
+ */
4108
+ resetUserGroup(userId) {
4109
+ this.userToGroup.delete(userId);
4110
+ }
4111
+
4112
+ /**
4113
+ * Set custom timestamps to a group
4114
+ * @param {string} groupId
3895
4115
  * @param {number[]} timestamps
3896
4116
  */
3897
- setData(userId, timestamps) {
3898
- this.userData.set(userId, timestamps);
3899
- this.lastSeen.set(userId, Date.now());
4117
+ setData(groupId, timestamps) {
4118
+ if (!Array.isArray(timestamps)) throw new Error('timestamps must be an array of numbers.');
4119
+ for (const t of timestamps) {
4120
+ if (typeof t !== 'number' || !Number.isFinite(t)) {
4121
+ throw new Error('All timestamps must be finite numbers.');
4122
+ }
4123
+ }
4124
+ if (!this.groupData.has(groupId)) this.groupFlags.set(groupId, false);
4125
+ this.groupData.set(groupId, timestamps);
4126
+ this.lastSeen.set(groupId, Date.now());
3900
4127
  }
3901
4128
 
3902
4129
  /**
3903
- * Get timestamps from user
3904
- * @param {string} userId
4130
+ * Check if a group has data
4131
+ * @param {string} groupId
4132
+ * @returns {boolean}
4133
+ */
4134
+ hasData(groupId) {
4135
+ return this.groupData.has(groupId);
4136
+ }
4137
+
4138
+ /**
4139
+ * Get timestamps from a group
4140
+ * @param {string} groupId
3905
4141
  * @returns {number[]}
3906
4142
  */
3907
- getData(userId) {
3908
- return this.userData.get(userId) || [];
4143
+ getData(groupId) {
4144
+ return this.groupData.get(groupId) || [];
4145
+ }
4146
+
4147
+ /**
4148
+ * Get the maximum idle time (in milliseconds) before a group is considered expired.
4149
+ * @returns {number}
4150
+ */
4151
+ getMaxIdle() {
4152
+ if (typeof this.#maxIdle !== 'number' || !Number.isFinite(this.#maxIdle) || this.#maxIdle < 0) {
4153
+ throw new Error("'maxIdle' must be a non-negative finite number.");
4154
+ }
4155
+ return this.#maxIdle;
4156
+ }
4157
+
4158
+ /**
4159
+ * Set the maximum idle time (in milliseconds) before a group is considered expired.
4160
+ * @param {number} ms
4161
+ */
4162
+ setMaxIdle(ms) {
4163
+ if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) {
4164
+ throw new Error("'maxIdle' must be a non-negative finite number.");
4165
+ }
4166
+ this.#maxIdle = ms;
3909
4167
  }
3910
4168
 
3911
4169
  /**
3912
- * Cleanup old/inactive users
4170
+ * Cleanup old/inactive groups with individual TTLs
3913
4171
  * @private
3914
4172
  */
3915
4173
  _cleanup() {
3916
4174
  const now = Date.now();
3917
- for (const [userId, last] of this.lastSeen.entries()) {
3918
- if (now - last > this.maxIdle) {
3919
- this.userData.delete(userId);
3920
- this.lastSeen.delete(userId);
4175
+ for (const [groupId, last] of this.lastSeen.entries()) {
4176
+ const ttl = this.getGroupTTL(groupId) ?? this.getMaxIdle();
4177
+ if (now - last > ttl) {
4178
+ this.groupFlags.delete(groupId);
4179
+ this.groupData.delete(groupId);
4180
+ this.lastSeen.delete(groupId);
4181
+ this.groupTTL.delete(groupId);
4182
+
4183
+ // Notify subclass or external binding
4184
+ if (typeof this.#onGroupExpired === 'function') {
4185
+ this.#onGroupExpired(groupId);
4186
+ }
3921
4187
  }
3922
4188
  }
3923
4189
  }
3924
4190
 
3925
4191
  /**
3926
- * Destroy the rate limiter, stopping all intervals
4192
+ * Get list of active group IDs
4193
+ * @returns {string[]}
4194
+ */
4195
+ getActiveGroups() {
4196
+ return Array.from(this.groupData.keys());
4197
+ }
4198
+
4199
+ /**
4200
+ * Get a shallow copy of all user-to-group mappings as a plain object
4201
+ * @returns {Record<string, string>}
4202
+ */
4203
+ getAllUserMappings() {
4204
+ return Object.fromEntries(this.userToGroup);
4205
+ }
4206
+
4207
+ /**
4208
+ * Get the interval window in milliseconds.
4209
+ * @returns {number}
4210
+ */
4211
+ getInterval() {
4212
+ if (typeof this.#interval !== 'number' || !Number.isFinite(this.#interval)) {
4213
+ throw new Error("'interval' is not a valid finite number.");
4214
+ }
4215
+ return this.#interval;
4216
+ }
4217
+
4218
+ /**
4219
+ * Get the maximum number of allowed hits.
4220
+ * @returns {number}
4221
+ */
4222
+ getMaxHits() {
4223
+ if (typeof this.#maxHits !== 'number' || !Number.isFinite(this.#maxHits)) {
4224
+ throw new Error("'maxHits' is not a valid finite number.");
4225
+ }
4226
+ return this.#maxHits;
4227
+ }
4228
+
4229
+ /**
4230
+ * Get the total number of hits recorded for a group.
4231
+ * @param {string} groupId
4232
+ * @returns {number}
4233
+ */
4234
+ getTotalHits(groupId) {
4235
+ const history = this.groupData.get(groupId);
4236
+ return Array.isArray(history) ? history.length : 0;
4237
+ }
4238
+
4239
+ /**
4240
+ * Get the timestamp of the last hit for a group.
4241
+ * @param {string} groupId
4242
+ * @returns {number|null}
4243
+ */
4244
+ getLastHit(groupId) {
4245
+ const history = this.groupData.get(groupId);
4246
+ return history?.length ? history[history.length - 1] : null;
4247
+ }
4248
+
4249
+ /**
4250
+ * Get milliseconds since the last hit for a group.
4251
+ * @param {string} groupId
4252
+ * @returns {number|null}
4253
+ */
4254
+ getTimeSinceLastHit(groupId) {
4255
+ const last = this.getLastHit(groupId);
4256
+ return last !== null ? Date.now() - last : null;
4257
+ }
4258
+
4259
+ /**
4260
+ * Internal utility to compute average spacing
4261
+ * @private
4262
+ * @param {number[]|undefined} history
4263
+ * @returns {number|null}
4264
+ */
4265
+ _calculateAverageSpacing(history) {
4266
+ if (!Array.isArray(history) || history.length < 2) return null;
4267
+ let total = 0;
4268
+ for (let i = 1; i < history.length; i++) {
4269
+ total += history[i] - history[i - 1];
4270
+ }
4271
+ return total / (history.length - 1);
4272
+ }
4273
+
4274
+ /**
4275
+ * Get average time between hits for a group (ms).
4276
+ * @param {string} groupId
4277
+ * @returns {number|null}
4278
+ */
4279
+ getAverageHitSpacing(groupId) {
4280
+ return this._calculateAverageSpacing(this.groupData.get(groupId));
4281
+ }
4282
+
4283
+ /**
4284
+ * Get metrics about a group's activity.
4285
+ * @param {string} groupId
4286
+ * @returns {{
4287
+ * totalHits: number,
4288
+ * lastHit: number|null,
4289
+ * timeSinceLastHit: number|null,
4290
+ * averageHitSpacing: number|null
4291
+ * }}
4292
+ */
4293
+ getMetrics(groupId) {
4294
+ const history = this.groupData.get(groupId);
4295
+
4296
+ if (!Array.isArray(history) || history.length === 0) {
4297
+ return {
4298
+ totalHits: 0,
4299
+ lastHit: null,
4300
+ timeSinceLastHit: null,
4301
+ averageHitSpacing: null,
4302
+ };
4303
+ }
4304
+
4305
+ const totalHits = history.length;
4306
+ const lastHit = history[totalHits - 1];
4307
+ const timeSinceLastHit = Date.now() - lastHit;
4308
+ const averageHitSpacing = this._calculateAverageSpacing(history);
4309
+
4310
+ return {
4311
+ totalHits,
4312
+ lastHit,
4313
+ timeSinceLastHit,
4314
+ averageHitSpacing,
4315
+ };
4316
+ }
4317
+
4318
+ /**
4319
+ * Destroy the rate limiter, stopping cleanup and clearing data
3927
4320
  */
3928
4321
  destroy() {
3929
- if (this._cleanupTimer) clearInterval(this._cleanupTimer);
3930
- this.userData.clear();
4322
+ if (this.#cleanupTimer) clearInterval(this.#cleanupTimer);
4323
+ this._cleanup();
4324
+ this.groupData.clear();
3931
4325
  this.lastSeen.clear();
4326
+ this.userToGroup.clear();
4327
+ this.groupTTL.clear();
4328
+ this.groupFlags.clear();
3932
4329
  }
3933
4330
  }
3934
4331