diff --git a/src/client/Client.js b/src/client/Client.js index 3e0decee3..a28beb2e8 100644 --- a/src/client/Client.js +++ b/src/client/Client.js @@ -176,7 +176,7 @@ class Client extends BaseClient { if (this.options.messageSweepInterval > 0) { process.emitWarning( - 'The message sweeping client options are deprecated, use the makeCache option with a SweptCollection instead.', + 'The message sweeping client options are deprecated, use the makeCache option with LimitedCollection instead.', 'DeprecationWarning', ); this.sweepMessageInterval = setInterval( diff --git a/src/index.js b/src/index.js index eca7628da..29e2b76df 100644 --- a/src/index.js +++ b/src/index.js @@ -27,7 +27,6 @@ module.exports = { Permissions: require('./util/Permissions'), RateLimitError: require('./rest/RateLimitError'), SnowflakeUtil: require('./util/SnowflakeUtil'), - SweptCollection: require('./util/SweptCollection'), SystemChannelFlags: require('./util/SystemChannelFlags'), ThreadMemberFlags: require('./util/ThreadMemberFlags'), UserFlags: require('./util/UserFlags'), diff --git a/src/managers/CachedManager.js b/src/managers/CachedManager.js index eb16a0cc2..f532db68e 100644 --- a/src/managers/CachedManager.js +++ b/src/managers/CachedManager.js @@ -14,14 +14,14 @@ class CachedManager extends DataManager { Object.defineProperty(this, '_cache', { value: this.client.options.makeCache(this.constructor, this.holds) }); - let cleanup = this._cache[_cleanupSymbol]; + let cleanup = this._cache[_cleanupSymbol]?.(); if (cleanup) { cleanup = cleanup.bind(this._cache); client._cleanups.add(cleanup); client._finalizers.register(this, { cleanup, message: - `Garbage Collection completed on ${this.constructor.name}, ` + + `Garbage collection completed on ${this.constructor.name}, ` + `which had a ${this._cache.constructor.name} of ${this.holds.name}.`, name: this.constructor.name, }); diff --git a/src/util/LimitedCollection.js b/src/util/LimitedCollection.js index 17a3e77fb..f2266b2e3 100644 --- a/src/util/LimitedCollection.js +++ b/src/util/LimitedCollection.js @@ -1,30 +1,151 @@ 'use strict'; const { Collection } = require('@discordjs/collection'); +const { _cleanupSymbol } = require('./Constants.js'); +const { TypeError } = require('../errors/DJSError.js'); /** - * A Collection which holds a max amount of entries. The first key is deleted if the Collection has - * reached max size. + * @typedef {Function} SweepFilter + * @param {LimitedCollection} collection The collection being swept + * @returns {Function|null} Return `null` to skip sweeping, otherwise a function passed to `sweep()`, + * See {@link [Collection#sweep](https://discord.js.org/#/docs/collection/master/class/Collection?scrollTo=sweep)} + * for the definition of this function. + */ + +/** + * Options for defining the behavior of a LimitedCollection + * @typedef {Object} LimitedCollectionOptions + * @property {?number} [maxSize=0] The maximum size of the Collection + * @property {?Function} [keepOverLimit=null] A function, which is passed the value and key of an entry, ran to decide + * to keep an entry past the maximum size + * @property {?SweepFilter} [sweepFilter=null] A function ran every `sweepInterval` to determine how to sweep + * @property {?number} [sweepInterval=0] How frequently, in seconds, to sweep the collection. + */ + +/** + * A Collection which holds a max amount of entries and sweeps periodically. * @extends {Collection} - * @param {number} [maxSize=0] The maximum size of the Collection + * @param {LimitedCollectionOptions} [options={}] Options for constructing the Collection. * @param {Iterable} [iterable=null] Optional entries passed to the Map constructor. */ class LimitedCollection extends Collection { - constructor(maxSize = 0, iterable = null) { + constructor(options = {}, iterable) { + if (typeof options !== 'object' || options === null) { + throw new TypeError('INVALID_TYPE', 'options', 'object', true); + } + const { maxSize = Infinity, keepOverLimit = null, sweepInterval = 0, sweepFilter = null } = options; + + if (typeof maxSize !== 'number') { + throw new TypeError('INVALID_TYPE', 'maxSize', 'number'); + } + if (keepOverLimit !== null && typeof keepOverLimit !== 'function') { + throw new TypeError('INVALID_TYPE', 'keepOverLimit', 'function'); + } + if (typeof sweepInterval !== 'number') { + throw new TypeError('INVALID_TYPE', 'sweepInterval', 'number'); + } + if (sweepFilter !== null && typeof sweepFilter !== 'function') { + throw new TypeError('INVALID_TYPE', 'sweepFilter', 'function'); + } + super(iterable); + /** * The max size of the Collection. * @type {number} */ this.maxSize = maxSize; + + /** + * A function called to check if an entry should be kept when the Collection is at max size. + * @type {?Function} + */ + this.keepOverLimit = keepOverLimit; + + /** + * A function called every sweep interval that returns a function passed to `sweep`. + * @type {?SweepFilter} + */ + this.sweepFilter = sweepFilter; + + /** + * The id of the interval being used to sweep. + * @type {?Timeout} + */ + this.interval = + sweepInterval > 0 && sweepInterval !== Infinity && sweepFilter + ? setInterval(() => { + const sweepFn = this.sweepFilter(this); + if (sweepFn === null) return; + if (typeof sweepFn !== 'function') throw new TypeError('SWEEP_FILTER_RETURN'); + this.sweep(sweepFn); + }, sweepInterval * 1000).unref() + : null; } set(key, value) { if (this.maxSize === 0) return this; - if (this.size >= this.maxSize && !this.has(key)) this.delete(this.firstKey()); + if (this.size >= this.maxSize && !this.has(key)) { + for (const [k, v] of this.entries()) { + const keep = this.keepOverLimit?.(v, k, this) ?? false; + if (!keep) { + this.delete(k); + break; + } + } + } return super.set(key, value); } + /** + * Options for generating a filter function based on lifetime + * @typedef {Object} LifetimeFilterOptions + * @property {number} [lifetime=14400] How long an entry should stay in the collection before it is considered + * sweepable. + * @property {Function} [getComparisonTimestamp=`e => e.createdTimestamp`] A function that takes an entry, key, + * and the collection and returns a timestamp to compare against in order to determine the lifetime of the entry. + * @property {Function} [excludeFromSweep=`() => false`] A function that takes an entry, key, and the collection + * and returns a boolean, `true` when the entry should not be checked for sweepability. + */ + + /** + * Create a sweepFilter function that uses a lifetime to determine sweepability. + * @param {LifetimeFilterOptions} [options={}] The options used to generate the filter function + * @returns {SweepFilter} + */ + static filterByLifetime({ + lifetime = 14400, + getComparisonTimestamp = e => e?.createdTimestamp, + excludeFromSweep = () => false, + } = {}) { + if (typeof lifetime !== 'number') { + throw new TypeError('INVALID_TYPE', 'lifetime', 'number'); + } + if (typeof getComparisonTimestamp !== 'function') { + throw new TypeError('INVALID_TYPE', 'getComparisonTimestamp', 'function'); + } + if (typeof excludeFromSweep !== 'function') { + throw new TypeError('INVALID_TYPE', 'excludeFromSweep', 'function'); + } + return () => { + if (lifetime <= 0) return null; + const lifetimeMs = lifetime * 1000; + const now = Date.now(); + return (entry, key, coll) => { + if (excludeFromSweep(entry, key, coll)) { + return false; + } + const comparisonTimestamp = getComparisonTimestamp(entry, key, coll); + if (!comparisonTimestamp || typeof comparisonTimestamp !== 'number') return false; + return now - comparisonTimestamp > lifetimeMs; + }; + }; + } + + [_cleanupSymbol]() { + return this.interval ? () => clearInterval(this.interval) : null; + } + static get [Symbol.species]() { return Collection; } diff --git a/src/util/Options.js b/src/util/Options.js index 1f461b10f..e0dd578f7 100644 --- a/src/util/Options.js +++ b/src/util/Options.js @@ -35,9 +35,9 @@ * (e.g. recommended shard count, shard count of the ShardingManager) * @property {CacheFactory} [makeCache] Function to create a cache. * You can use your own function, or the {@link Options} class to customize the Collection used for the cache. - * @property {number} [messageCacheLifetime=0] DEPRECATED: Use `makeCache` with a `SweptCollection` instead. + * @property {number} [messageCacheLifetime=0] DEPRECATED: Use `makeCache` with a `LimitedCollection` instead. * How long a message should stay in the cache until it is considered sweepable (in seconds, 0 for forever) - * @property {number} [messageSweepInterval=0] DEPRECATED: Use `makeCache` with a `SweptCollection` instead. + * @property {number} [messageSweepInterval=0] DEPRECATED: Use `makeCache` with a `LimitedCollection` instead. * How frequently to remove messages from the cache that are older than the message cache lifetime * (in seconds, 0 for never) * @property {MessageMentionOptions} [allowedMentions] Default value for {@link MessageOptions#allowedMentions} @@ -103,7 +103,8 @@ class Options extends null { makeCache: this.cacheWithLimits({ MessageManager: 200, ThreadManager: { - sweepFilter: require('./SweptCollection').filterByLifetime({ + sweepInterval: 3600, + sweepFilter: require('./LimitedCollection').filterByLifetime({ getComparisonTimestamp: e => e.archiveTimestamp, excludeFromSweep: e => !e.archived, }), @@ -144,17 +145,18 @@ class Options extends null { /** * Create a cache factory using predefined settings to sweep or limit. - * @param {Object} [settings={}] Settings passed to the relevant constructor. + * @param {Object} [settings={}] Settings passed to the relevant constructor. * If no setting is provided for a manager, it uses Collection. - * If SweptCollectionOptions are provided for a manager, it uses those settings to form a SweptCollection - * If a number is provided for a manager, it uses that number as the max size for a LimitedCollection + * If a number is provided for a manager, it uses that number as the max size for a LimitedCollection. + * If LimitedCollectionOptions are provided for a manager, it uses those settings to form a LimitedCollection. * @returns {CacheFactory} * @example * // Store up to 200 messages per channel and discard archived threads if they were archived more than 4 hours ago. * Options.cacheWithLimits({ * MessageManager: 200, * ThreadManager: { - * sweepFilter: SweptCollection.filterByLifetime({ + * sweepInterval: 3600, + * sweepFilter: LimitedCollection.filterByLifetime({ * getComparisonTimestamp: e => e.archiveTimestamp, * excludeFromSweep: e => !e.archived, * }), @@ -165,7 +167,7 @@ class Options extends null { * Options.cacheWithLimits({ * MessageManager: { * sweepInterval: 300, - * sweepFilter: SweptCollection.filterByLifetime({ + * sweepFilter: LimitedCollection.filterByLifetime({ * lifetime: 1800, * getComparisonTimestamp: e => e.editedTimestamp ?? e.createdTimestamp, * }) @@ -175,20 +177,31 @@ class Options extends null { static cacheWithLimits(settings = {}) { const { Collection } = require('@discordjs/collection'); const LimitedCollection = require('./LimitedCollection'); - const SweptCollection = require('./SweptCollection'); return manager => { const setting = settings[manager.name]; - if (typeof setting === 'number' && setting !== Infinity) return new LimitedCollection(setting); - if ( - /* eslint-disable-next-line eqeqeq */ - (setting?.sweepInterval == null && setting?.sweepFilter == null) || - setting.sweepInterval <= 0 || - setting.sweepInterval === Infinity - ) { + /* eslint-disable-next-line eqeqeq */ + if (setting == null) { return new Collection(); } - return new SweptCollection(setting); + if (typeof setting === 'number') { + if (setting === Infinity) { + return new Collection(); + } + return new LimitedCollection({ maxSize: setting }); + } + /* eslint-disable eqeqeq */ + const noSweeping = + setting.sweepFilter == null || + setting.sweepInterval == null || + setting.sweepInterval <= 0 || + setting.sweepInterval === Infinity; + const noLimit = setting.maxSize == null || setting.maxSize === Infinity; + /* eslint-enable eqeqeq */ + if (noSweeping && noLimit) { + return new Collection(); + } + return new LimitedCollection(setting); }; } diff --git a/src/util/SweptCollection.js b/src/util/SweptCollection.js deleted file mode 100644 index ebac68fb5..000000000 --- a/src/util/SweptCollection.js +++ /dev/null @@ -1,115 +0,0 @@ -'use strict'; - -const { Collection } = require('@discordjs/collection'); -const { _cleanupSymbol } = require('./Constants.js'); -const { TypeError } = require('../errors/DJSError.js'); - -/** - * @typedef {Function} SweepFilter - * @param {SweptCollection} collection The collection being swept - * @returns {Function|null} Return `null` to skip sweeping, otherwise a function passed to `sweep()`, - * See {@link [Collection#sweep](https://discord.js.org/#/docs/collection/master/class/Collection?scrollTo=sweep)} - * for the definition of this function. - */ - -/** - * Options for defining the behavior of a Swept Collection - * @typedef {Object} SweptCollectionOptions - * @property {?SweepFitler} [sweepFilter=null] A function run every `sweepInterval` to determine how to sweep - * @property {number} [sweepInterval=3600] How frequently, in seconds, to sweep the collection. - */ - -/** - * A Collection which holds a max amount of entries and sweeps periodically. - * @extends {Collection} - * @param {SweptCollectionOptions} [options={}] Options for constructing the swept collection. - * @param {Iterable} [iterable=null] Optional entries passed to the Map constructor. - */ -class SweptCollection extends Collection { - constructor(options = {}, iterable) { - if (typeof options !== 'object' || options === null) { - throw new TypeError('INVALID_TYPE', 'options', 'object or iterable', true); - } - const { sweepFilter = null, sweepInterval = 3600 } = options; - - if (sweepFilter !== null && typeof sweepFilter !== 'function') { - throw new TypeError('INVALID_TYPE', 'sweepFunction', 'function'); - } - if (typeof sweepInterval !== 'number') throw new TypeError('INVALID_TYPE', 'sweepInterval', 'number'); - - super(iterable); - - /** - * A function called every sweep interval that returns a function passed to `sweep` - * @type {?SweepFilter} - */ - this.sweepFilter = sweepFilter; - - /** - * The id of the interval being used to sweep. - * @type {?Timeout} - */ - this.interval = - sweepInterval > 0 && sweepFilter - ? setInterval(() => { - const sweepFn = this.sweepFilter(this); - if (sweepFn === null) return; - if (typeof sweepFn !== 'function') throw new TypeError('SWEEP_FILTER_RETURN'); - this.sweep(sweepFn); - }, sweepInterval * 1000).unref() - : null; - } - - /** - * Options for generating a filter function based on lifetime - * @typedef {Object} LifetimeFilterOptions - * @property {number} [lifetime=14400] How long an entry should stay in the collection - * before it is considered sweepable - * @property {Function} [getComparisonTimestamp=`e => e.createdTimestamp`] A function that takes an entry, key, - * and the collection and returns a timestamp to compare against in order to determine the lifetime of the entry. - * @property {Function} [excludeFromSweep=`() => false`] A function that takes an entry, key, and the collection - * and returns a boolean, `true` when the entry should not be checked for sweepability. - */ - - /** - * Create a sweepFilter function that uses a lifetime to determine sweepability. - * @param {LifetimeFilterOptions} [options={}] The options used to generate the filter function - * @returns {SweepFilter} - */ - static filterByLifetime({ - lifetime = 14400, - getComparisonTimestamp = e => e?.createdTimestamp, - excludeFromSweep = () => false, - } = {}) { - if (typeof lifetime !== 'number') throw new TypeError('INVALID_TYPE', 'lifetime', 'number'); - if (typeof getComparisonTimestamp !== 'function') { - throw new TypeError('INVALID_TYPE', 'getComparisonTimestamp', 'function'); - } - if (typeof excludeFromSweep !== 'function') { - throw new TypeError('INVALID_TYPE', 'excludeFromSweep', 'function'); - } - return () => { - if (lifetime <= 0) return null; - const lifetimeMs = lifetime * 1000; - const now = Date.now(); - return (entry, key, coll) => { - if (excludeFromSweep(entry, key, coll)) { - return false; - } - const comparisonTimestamp = getComparisonTimestamp(entry, key, coll); - if (!comparisonTimestamp || typeof comparisonTimestamp !== 'number') return false; - return now - comparisonTimestamp > lifetimeMs; - }; - }; - } - - [_cleanupSymbol]() { - clearInterval(this.interval); - } - - static get [Symbol.species]() { - return Collection; - } -} - -module.exports = SweptCollection; diff --git a/test/tester2000.js b/test/tester2000.js index c47b73fdf..e3e1822f4 100644 --- a/test/tester2000.js +++ b/test/tester2000.js @@ -12,6 +12,14 @@ const client = new Client({ makeCache: Options.cacheWithLimits({ MessageManager: 10, PresenceManager: 10, + UserManager: { + maxSize: 1, + keepOverLimit: v => v.id === client.user.id, + }, + GuildMemberManager: { + maxSize: 1, + keepOverLimit: v => v.id === client.user.id, + }, }), }); diff --git a/typings/index.d.ts b/typings/index.d.ts index 7a19df09f..6ce273f38 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -965,8 +965,13 @@ export class InviteGuild extends AnonymousGuild { } export class LimitedCollection extends Collection { - public constructor(maxSize?: number, iterable?: Iterable); + public constructor(options?: LimitedCollectionOptions, iterable?: Iterable); public maxSize: number; + public keepOverLimit: ((value: V, key: K, collection: this) => boolean) | null; + public interval: NodeJS.Timeout | null; + public sweepFilter: SweepFilter | null; + + public static filterByLifetime(options?: LifetimeFilterOptions): SweepFilter; } export class Message extends Base { @@ -1611,14 +1616,6 @@ export class StoreChannel extends GuildChannel { public type: 'GUILD_STORE'; } -export class SweptCollection extends Collection { - public constructor(options?: SweptCollectionOptions, iterable?: Iterable); - public interval: NodeJS.Timeout | null; - public sweepFilter: SweptCollectionSweepFilter | null; - - public static filterByLifetime(options?: LifetimeFilterOptions): SweptCollectionSweepFilter; -} - export class SystemChannelFlags extends BitField { public static FLAGS: Record; public static resolve(bit?: BitFieldResolvable): number; @@ -2859,13 +2856,7 @@ export type BitFieldResolvable = export type BufferResolvable = Buffer | string; -export type CachedManagerTypes = keyof CacheFactoryArgs; - -export type CacheFactory = ( - ...args: CacheFactoryArgs[T] -) => Collection; - -export interface CacheFactoryArgs { +export interface Caches { ApplicationCommandManager: [manager: typeof ApplicationCommandManager, holds: typeof ApplicationCommand]; BaseGuildEmojiManager: [manager: typeof BaseGuildEmojiManager, holds: typeof GuildEmoji]; ChannelManager: [manager: typeof ChannelManager, holds: typeof Channel]; @@ -2886,8 +2877,21 @@ export interface CacheFactoryArgs { VoiceStateManager: [manager: typeof VoiceStateManager, holds: typeof VoiceState]; } +export type CacheConstructors = { + [K in keyof Caches]: Caches[K][0] & { name: K }; +}; + +// This doesn't actually work the way it looks 😢. +// Narrowing the type of `manager.name` doesn't propagate type information to `holds` and the return type. +export type CacheFactory = ( + manager: CacheConstructors[keyof Caches], + holds: Caches[typeof manager['name']][1], +) => typeof manager['prototype'] extends DataManager ? Collection : never; + export type CacheWithLimitsOptions = { - [K in CachedManagerTypes]?: SweptCollectionOptions | number; + [K in keyof Caches]?: Caches[K][0]['prototype'] extends DataManager + ? LimitedCollectionOptions | number + : never; }; export interface ChannelCreationOverwrites { @@ -3019,9 +3023,9 @@ export interface ClientOptions { shards?: number | number[] | 'auto'; shardCount?: number; makeCache?: CacheFactory; - /** @deprecated Use `makeCache` with a `SweptCollection` for `MessageManager` instead. */ + /** @deprecated Use `makeCache` with a `LimitedCollection` for `MessageManager` instead. */ messageCacheLifetime?: number; - /** @deprecated Use `makeCache` with a `SweptCollection` for `MessageManager` instead. */ + /** @deprecated Use `makeCache` with a `LimitedCollection` for `MessageManager` instead. */ messageSweepInterval?: number; allowedMentions?: MessageMentionOptions; invalidRequestWarningInterval?: number; @@ -3773,8 +3777,8 @@ export type InviteScope = | 'webhook.incoming'; export interface LifetimeFilterOptions { - excludeFromSweep?: (value: V, key: K, collection: SweptCollection) => boolean; - getComparisonTimestamp?: (value: V, key: K, collection: SweptCollection) => number; + excludeFromSweep?: (value: V, key: K, collection: LimitedCollection) => boolean; + getComparisonTimestamp?: (value: V, key: K, collection: LimitedCollection) => number; lifetime?: number; } @@ -4313,12 +4317,14 @@ export interface StageInstanceEditOptions { privacyLevel?: PrivacyLevel | number; } -export type SweptCollectionSweepFilter = ( - collection: SweptCollection, -) => ((value: V, key: K, collection: SweptCollection) => boolean) | null; +export type SweepFilter = ( + collection: LimitedCollection, +) => ((value: V, key: K, collection: LimitedCollection) => boolean) | null; -export interface SweptCollectionOptions { - sweepFilter?: SweptCollectionSweepFilter; +export interface LimitedCollectionOptions { + maxSize?: number; + keepOverLimit?: (value: V, key: K, collection: LimitedCollection) => boolean; + sweepFilter?: SweepFilter; sweepInterval?: number; } diff --git a/typings/index.ts b/typings/index.ts index 51e86c3c2..050167d25 100644 --- a/typings/index.ts +++ b/typings/index.ts @@ -3,6 +3,8 @@ import { ApplicationCommandData, ApplicationCommandManager, ApplicationCommandResolvable, + CacheFactory, + Caches, CategoryChannel, Client, ClientApplication, @@ -22,12 +24,14 @@ import { GuildResolvable, Intents, Interaction, + LimitedCollection, Message, MessageActionRow, MessageAttachment, MessageButton, MessageCollector, MessageEmbed, + MessageManager, MessageReaction, NewsChannel, Options, @@ -59,9 +63,12 @@ const client: Client = new Client({ // @ts-expect-error Message: 100, ThreadManager: { - sweepInterval: require('./SweptCollection').filterByLifetime({ - getComparisonTimestamp: (e: any) => e.archiveTimestamp, - excludeFromSweep: (e: any) => !e.archived, + maxSize: 1000, + keepOverLimit: (x: ThreadChannel) => x.id === '123', + sweepInterval: 5000, + sweepFilter: LimitedCollection.filterByLifetime({ + getComparisonTimestamp: (x: ThreadChannel) => x.archiveTimestamp ?? 0, + excludeFromSweep: (x: ThreadChannel) => !x.archived, }), }, }),