diff --git a/.eslintrc.json b/.eslintrc.json index 201525f6f255..3d85319b927d 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -2,10 +2,10 @@ "extends": ["eslint:recommended", "plugin:prettier/recommended"], "plugins": ["import"], "parserOptions": { - "ecmaVersion": 2020 + "ecmaVersion": 2021 }, "env": { - "es2020": true, + "es2021": true, "node": true }, "rules": { diff --git a/package-lock.json b/package-lock.json index b0bc83102f60..e874abf8623c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "license": "Apache-2.0", "dependencies": { "@discordjs/builders": "^0.2.0", - "@discordjs/collection": "^0.1.6", + "@discordjs/collection": "^0.2.0", "@discordjs/form-data": "^3.0.1", "@sapphire/async-queue": "^1.1.4", "@types/ws": "^7.4.5", @@ -40,7 +40,7 @@ "typescript": "^4.3.4" }, "engines": { - "node": ">=14.0.0", + "node": ">=14.6.0", "npm": ">=7.0.0" } }, @@ -1008,9 +1008,12 @@ "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==" }, "node_modules/@discordjs/collection": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-0.1.6.tgz", - "integrity": "sha512-utRNxnd9kSS2qhyivo9lMlt5qgAUasH2gb7BEOn6p0efFh24gjGomHzWKMAPn2hEReOPQZCJaRKoURwRotKucQ==" + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-0.2.0.tgz", + "integrity": "sha512-ZSiyfQsQmJq5EDgTocUg6n7IOft64MH/53RC8q3+Z5Ltipgc6eH1lLyDMJz2fcY/xq5zrILd9LyqFgEdragDNA==", + "engines": { + "node": ">=14.0.0" + } }, "node_modules/@discordjs/docgen": { "version": "0.10.0", @@ -12231,9 +12234,9 @@ } }, "@discordjs/collection": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-0.1.6.tgz", - "integrity": "sha512-utRNxnd9kSS2qhyivo9lMlt5qgAUasH2gb7BEOn6p0efFh24gjGomHzWKMAPn2hEReOPQZCJaRKoURwRotKucQ==" + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-0.2.0.tgz", + "integrity": "sha512-ZSiyfQsQmJq5EDgTocUg6n7IOft64MH/53RC8q3+Z5Ltipgc6eH1lLyDMJz2fcY/xq5zrILd9LyqFgEdragDNA==" }, "@discordjs/docgen": { "version": "0.10.0", diff --git a/package.json b/package.json index d07277fc4b70..7a682eca0389 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "homepage": "https://github.com/discordjs/discord.js#readme", "dependencies": { "@discordjs/builders": "^0.2.0", - "@discordjs/collection": "^0.1.6", + "@discordjs/collection": "^0.2.0", "@discordjs/form-data": "^3.0.1", "@sapphire/async-queue": "^1.1.4", "@types/ws": "^7.4.5", @@ -77,7 +77,7 @@ "typescript": "^4.3.4" }, "engines": { - "node": ">=14.0.0", + "node": ">=14.6.0", "npm": ">=7.0.0" } } diff --git a/src/client/Client.js b/src/client/Client.js index dd00cb3f3f4c..3cd87612341a 100644 --- a/src/client/Client.js +++ b/src/client/Client.js @@ -72,6 +72,20 @@ class Client extends BaseClient { this._validateOptions(); + /** + * Functions called when a cache is garbage collected or the Client is destroyed + * @type {Set} + * @private + */ + this._cleanups = new Set(); + + /** + * The finalizers used to cleanup items. + * @type {FinalizationRegistry} + * @private + */ + this._finalizers = new FinalizationRegistry(this._finalize.bind(this)); + /** * The WebSocket manager of the client * @type {WebSocketManager} @@ -161,6 +175,10 @@ class Client extends BaseClient { this.readyAt = null; if (this.options.messageSweepInterval > 0) { + process.emitWarning( + 'The message sweeping client options are deprecated, use the makeCache option with a SweptCollection instead.', + 'DeprecationWarning', + ); this.sweepMessageInterval = setInterval( this.sweepMessages.bind(this), this.options.messageSweepInterval * 1000, @@ -247,6 +265,10 @@ class Client extends BaseClient { */ destroy() { super.destroy(); + + for (const fn of this._cleanups) fn(); + this._cleanups.clear(); + if (this.sweepMessageInterval) clearInterval(this.sweepMessageInterval); this.ws.destroy(); @@ -346,6 +368,23 @@ class Client extends BaseClient { const data = await this.api('sticker-packs').get(); return new Collection(data.sticker_packs.map(p => [p.id, new StickerPack(this, p)])); } + /** + * A last ditch cleanup function for garbage collection. + * @param {Function} options.cleanup The function called to GC + * @param {string} [options.message] The message to send after a successful GC + * @param {string} [options.name] The name of the item being GCed + */ + _finalize({ cleanup, message, name }) { + try { + cleanup(); + this._cleanups.delete(cleanup); + if (message) { + this.emit(Events.DEBUG, message); + } + } catch { + this.emit(Events.DEBUG, `Garbage collection failed on ${name ?? 'an unknown item'}.`); + } + } /** * Sweeps all text-based channels' messages and removes the ones older than the max message lifetime. diff --git a/src/errors/Messages.js b/src/errors/Messages.js index 91ad16c263ab..e745d83204c3 100644 --- a/src/errors/Messages.js +++ b/src/errors/Messages.js @@ -143,6 +143,8 @@ const Messages = { INVITE_MISSING_SCOPES: 'At least one valid scope must be provided for the invite', NOT_IMPLEMENTED: (what, name) => `Method ${what} not implemented on ${name}.`, + + SWEEP_FILTER_RETURN: 'The return value of the sweepFilter function was not false or a Function', }; for (const [name, message] of Object.entries(Messages)) register(name, message); diff --git a/src/index.js b/src/index.js index 477d454393bd..d23b2e9838c1 100644 --- a/src/index.js +++ b/src/index.js @@ -27,6 +27,7 @@ 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/ApplicationCommandPermissionsManager.js b/src/managers/ApplicationCommandPermissionsManager.js index 71648f250306..bcf09c4e0e44 100644 --- a/src/managers/ApplicationCommandPermissionsManager.js +++ b/src/managers/ApplicationCommandPermissionsManager.js @@ -16,6 +16,7 @@ class ApplicationCommandPermissionsManager extends BaseManager { /** * The manager or command that this manager belongs to * @type {ApplicationCommandManager|ApplicationCommand} + * @private */ this.manager = manager; diff --git a/src/managers/CachedManager.js b/src/managers/CachedManager.js index 1a3856853613..eb16a0cc20b5 100644 --- a/src/managers/CachedManager.js +++ b/src/managers/CachedManager.js @@ -1,6 +1,7 @@ 'use strict'; const DataManager = require('./DataManager'); +const { _cleanupSymbol } = require('../util/Constants'); /** * Manages the API methods of a data model with a mutable cache of instances. @@ -13,6 +14,19 @@ class CachedManager extends DataManager { Object.defineProperty(this, '_cache', { value: this.client.options.makeCache(this.constructor, this.holds) }); + 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}, ` + + `which had a ${this._cache.constructor.name} of ${this.holds.name}.`, + name: this.constructor.name, + }); + } + if (iterable) { for (const item of iterable) { this._add(item); diff --git a/src/managers/GuildEmojiRoleManager.js b/src/managers/GuildEmojiRoleManager.js index 784ad4faf09e..73ec4bc9a2b7 100644 --- a/src/managers/GuildEmojiRoleManager.js +++ b/src/managers/GuildEmojiRoleManager.js @@ -72,7 +72,7 @@ class GuildEmojiRoleManager extends DataManager { resolvedRoleIds.push(roleId); } - const newRoles = this.cache.keyArray().filter(id => !resolvedRoleIds.includes(id)); + const newRoles = [...this.cache.keys()].filter(id => !resolvedRoleIds.includes(id)); return this.set(newRoles); } @@ -97,7 +97,7 @@ class GuildEmojiRoleManager extends DataManager { clone() { const clone = new this.constructor(this.emoji); - clone._patch(this.cache.keyArray().slice()); + clone._patch([...this.cache.keys()]); return clone; } diff --git a/src/managers/GuildMemberRoleManager.js b/src/managers/GuildMemberRoleManager.js index f060c7e5449b..ed5f5601770e 100644 --- a/src/managers/GuildMemberRoleManager.js +++ b/src/managers/GuildMemberRoleManager.js @@ -172,7 +172,7 @@ class GuildMemberRoleManager extends DataManager { clone() { const clone = new this.constructor(this.member); - clone.member._roles = [...this.cache.keyArray()]; + clone.member._roles = [...this.cache.keys()]; return clone; } } diff --git a/src/util/Collection.js b/src/util/Collection.js index 259244f15e19..79428a9ad368 100644 --- a/src/util/Collection.js +++ b/src/util/Collection.js @@ -1,6 +1,6 @@ 'use strict'; -const BaseCollection = require('@discordjs/collection'); +const { Collection: BaseCollection } = require('@discordjs/collection'); const Util = require('./Util'); class Collection extends BaseCollection { diff --git a/src/util/Constants.js b/src/util/Constants.js index c4ef7039345f..c245cc97da1d 100644 --- a/src/util/Constants.js +++ b/src/util/Constants.js @@ -971,6 +971,8 @@ exports.PrivacyLevels = createEnum([null, 'PUBLIC', 'GUILD_ONLY']); */ exports.PremiumTiers = createEnum(['NONE', 'TIER_1', 'TIER_2', 'TIER_3']); +exports._cleanupSymbol = Symbol('djsCleanup'); + function keyMirror(arr) { let tmp = Object.create(null); for (const value of arr) tmp[value] = value; diff --git a/src/util/Options.js b/src/util/Options.js index cb366cb09be8..c86ef6696954 100644 --- a/src/util/Options.js +++ b/src/util/Options.js @@ -35,10 +35,11 @@ * (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] How long a message should stay in the cache until it is considered - * sweepable (in seconds, 0 for forever) - * @property {number} [messageSweepInterval=0] How frequently to remove messages from the cache that are older than - * the message cache lifetime (in seconds, 0 for never) + * @property {number} [messageCacheLifetime=0] DEPRECATED: Use `makeCache` with a `SweptCollection` 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. + * 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} * @property {number} [invalidRequestWarningInterval=0] The number of invalid REST requests (those that return * 401, 403, or 429) in a 10 minute window between emitted warnings (0 for no warnings). That is, if set to 500, @@ -99,7 +100,15 @@ class Options extends null { static createDefault() { return { shardCount: 1, - makeCache: this.cacheWithLimits({ MessageManager: 200 }), + makeCache: this.cacheWithLimits({ + MessageManager: 200, + ThreadManager: { + sweepFilter: require('./SweptCollection').filterByLifetime({ + getComparisonTimestamp: e => e.archiveTimestamp, + excludeFromSweep: e => !e.archived, + }), + }, + }), messageCacheLifetime: 0, messageSweepInterval: 0, invalidRequestWarningInterval: 0, @@ -134,20 +143,52 @@ class Options extends null { } /** - * Create a cache factory using predefined limits. - * @param {Record} [limits={}] Limits for structures. + * Create a cache factory using predefined settings to sweep or limit. + * @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 * @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({ + * getComparisonTimestamp: e => e.archiveTimestamp, + * excludeFromSweep: e => !e.archived, + * }), + * }, + * }); + * @example + * // Sweep messages every 5 minutes, removing messages that have not been edited or created in the last 30 minutes + * Options.cacheWithLimits({ + * MessageManager: { + * sweepInterval: 300, + * sweepFilter: SweptCollection.filterByLifetime({ + * lifetime: 1800, + * getComparisonTimestamp: e => e.editedTimestamp ?? e.createdTimestamp, + * }) + * } + * }); */ - static cacheWithLimits(limits = {}) { + static cacheWithLimits(settings = {}) { const Collection = require('./Collection'); const LimitedCollection = require('./LimitedCollection'); + const SweptCollection = require('./SweptCollection'); return manager => { - const limit = limits[manager.name]; - if (limit === null || limit === undefined || limit === Infinity) { + 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 + ) { return new Collection(); } - return new LimitedCollection(limit); + return new SweptCollection(setting); }; } diff --git a/src/util/SweptCollection.js b/src/util/SweptCollection.js new file mode 100644 index 000000000000..793a17df7e23 --- /dev/null +++ b/src/util/SweptCollection.js @@ -0,0 +1,115 @@ +'use strict'; + +const Collection = require('./Collection.js'); +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/typings/index.d.ts b/typings/index.d.ts index 281fa3a2e91c..1cf584e706c0 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -358,7 +358,7 @@ export class ClientUser extends User { export class Options extends null { private constructor(); public static createDefaultOptions(): ClientOptions; - public static cacheWithLimits(limits?: CacheWithLimitOptions): CacheFactory; + public static cacheWithLimits(settings?: CacheWithLimitsOptions): CacheFactory; public static cacheEverything(): CacheFactory; } @@ -1613,6 +1613,14 @@ 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; @@ -2203,11 +2211,12 @@ export class ApplicationCommandPermissionsManager< CommandIdType, > extends BaseManager { public constructor(manager: ApplicationCommandManager | GuildApplicationCommandManager | ApplicationCommand); + private manager: ApplicationCommandManager | GuildApplicationCommandManager | ApplicationCommand; + public client: Client; public commandId: CommandIdType; public guild: GuildType; public guildId: Snowflake | null; - public manager: ApplicationCommandManager | GuildApplicationCommandManager | ApplicationCommand; public add( options: FetchSingleOptions & { permissions: ApplicationCommandPermissionData[] }, ): Promise; @@ -2870,8 +2879,8 @@ export interface CacheFactoryArgs { VoiceStateManager: [manager: typeof VoiceStateManager, holds: typeof VoiceState]; } -export type CacheWithLimitOptions = { - [K in CachedManagerTypes]?: number; +export type CacheWithLimitsOptions = { + [K in CachedManagerTypes]?: SweptCollectionOptions | number; }; export interface ChannelCreationOverwrites { @@ -3003,7 +3012,9 @@ export interface ClientOptions { shards?: number | number[] | 'auto'; shardCount?: number; makeCache?: CacheFactory; + /** @deprecated Use `makeCache` with a `SweptCollection` for `MessageManager` instead. */ messageCacheLifetime?: number; + /** @deprecated Use `makeCache` with a `SweptCollection` for `MessageManager` instead. */ messageSweepInterval?: number; allowedMentions?: MessageMentionOptions; invalidRequestWarningInterval?: number; @@ -3758,6 +3769,12 @@ export type InviteScope = | 'gdm.join' | 'webhook.incoming'; +export interface LifetimeFilterOptions { + excludeFromSweep?: (value: V, key: K, collection: SweptCollection) => boolean; + getComparisonTimestamp?: (value: V, key: K, collection: SweptCollection) => number; + lifetime?: number; +} + export interface MakeErrorOptions { name: string; message: string; @@ -4278,6 +4295,15 @@ export interface StageInstanceEditOptions { privacyLevel?: PrivacyLevel | number; } +export type SweptCollectionSweepFilter = ( + collection: SweptCollection, +) => ((value: V, key: K, collection: SweptCollection) => boolean) | null; + +export interface SweptCollectionOptions { + sweepFilter?: SweptCollectionSweepFilter; + sweepInterval?: number; +} + export type TextBasedChannelTypes = | 'DM' | 'GUILD_TEXT' diff --git a/typings/index.ts b/typings/index.ts index 6cdc2d4c478c..b1b5812d3fc6 100644 --- a/typings/index.ts +++ b/typings/index.ts @@ -58,6 +58,12 @@ const client: Client = new Client({ MessageManager: 200, // @ts-expect-error Message: 100, + ThreadManager: { + sweepInterval: require('./SweptCollection').filterByLifetime({ + getComparisonTimestamp: (e: any) => e.archiveTimestamp, + excludeFromSweep: (e: any) => !e.archived, + }), + }, }), });