From 7ab4136557f39040fb66f60b10203dbf136d6128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20Rom=C3=A1n?= Date: Tue, 23 Jun 2020 00:26:46 +0200 Subject: [PATCH] refactor(SG): make the settings system lighter and easier (#1048) Co-authored-by: Vlad Frangu Co-authored-by: bdistin --- src/commands/Admin/conf.ts | 72 +- .../General/User Settings/userconf.ts | 74 +- src/events/argumentError.ts | 7 +- src/index.ts | 5 +- src/lib/Client.ts | 19 +- src/lib/extensions/KlasaMessage.ts | 12 +- src/lib/settings/Settings.ts | 657 +++++++++- src/lib/settings/SettingsFolder.ts | 844 ------------- src/lib/settings/gateway/Gateway.ts | 126 +- src/lib/settings/gateway/GatewayStorage.ts | 137 --- .../{GatewayDriver.ts => GatewayStore.ts} | 14 +- src/lib/settings/schema/Schema.ts | 202 +--- src/lib/settings/schema/SchemaEntry.ts | 52 +- src/lib/settings/schema/SchemaFolder.ts | 26 - src/lib/structures/Provider.ts | 12 +- src/lib/structures/SQLProvider.ts | 9 +- src/lib/util/QueryBuilder.ts | 2 +- src/lib/util/constants.ts | 1 - test/Gateway.ts | 87 +- test/GatewayStorage.ts | 101 -- test/{GatewayDriver.ts => GatewayStore.ts} | 4 +- test/Schema.ts | 207 +--- test/SchemaEntry.ts | 19 +- test/SchemaFolder.ts | 44 - test/Settings.ts | 849 ++++++++++++- test/SettingsFolder.ts | 1056 ----------------- test/lib/MockLanguage.ts | 8 +- 27 files changed, 1812 insertions(+), 2834 deletions(-) delete mode 100644 src/lib/settings/SettingsFolder.ts delete mode 100644 src/lib/settings/gateway/GatewayStorage.ts rename src/lib/settings/gateway/{GatewayDriver.ts => GatewayStore.ts} (70%) delete mode 100644 src/lib/settings/schema/SchemaFolder.ts delete mode 100644 test/GatewayStorage.ts rename test/{GatewayDriver.ts => GatewayStore.ts} (97%) delete mode 100644 test/SchemaFolder.ts delete mode 100644 test/SettingsFolder.ts diff --git a/src/commands/Admin/conf.ts b/src/commands/Admin/conf.ts index 515cffcbb8..a03c2f327d 100644 --- a/src/commands/Admin/conf.ts +++ b/src/commands/Admin/conf.ts @@ -1,13 +1,12 @@ -import { Argument, Command, CommandStore, GatewayStorage, Schema, SchemaEntry, SchemaFolder, SettingsFolder } from 'klasa'; -import { toTitleCase, codeBlock } from '@klasa/utils'; +import { Argument, Command, CommandStore, SchemaEntry, Gateway, Settings } from 'klasa'; +import { toTitleCase } from '@klasa/utils'; import { ChannelType } from '@klasa/dapi-types'; +import { codeblock } from 'discord-md-tags'; import type { Message, Guild } from '@klasa/core'; export default class extends Command { - private readonly configurableSchemaKeys = new Map(); - public constructor(store: CommandStore, directory: string, files: readonly string[]) { super(store, directory, files, { runIn: [ChannelType.GuildText], @@ -31,26 +30,25 @@ export default class extends Command { }); } + private get gateway(): Gateway { + return this.client.gateways.get('guilds') as Gateway; + } + public show(message: Message, [key]: [string]): Promise { - const schemaOrEntry = this.configurableSchemaKeys.get(key); - if (typeof schemaOrEntry === 'undefined') throw message.language.get('COMMAND_CONF_GET_NOEXT', key); + const guild = message.guild as Guild; + if (!key) return message.replyLocale('COMMAND_CONF_SERVER', [key, codeblock('asciidoc')`${this.displayFolder(guild.settings)}`]); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const value = key ? message.guild!.settings.get(key) : message.guild!.settings; - if (SchemaEntry.is(schemaOrEntry)) { - return message.replyLocale('COMMAND_CONF_GET', [key, this.displayEntry(schemaOrEntry, value, message.guild as Guild)]); - } + const entry = this.gateway.schema.get(key); + if (!entry) throw message.language.get('COMMAND_CONF_GET_NOEXT', key); - return message.replyLocale('COMMAND_CONF_SERVER', [ - key ? `: ${key.split('.').map(toTitleCase).join('/')}` : '', - codeBlock('asciidoc', this.displayFolder(value as SettingsFolder)) - ]); + const value = guild.settings.get(key); + return message.replyLocale('COMMAND_CONF_GET', [key, this.displayEntry(entry, value, guild)]); } public async set(message: Message, [key, valueToSet]: [string, string]): Promise { try { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const [update] = await message.guild!.settings.update(key, valueToSet, { onlyConfigurable: true, arrayAction: 'add' }); + const [update] = await message.guild!.settings.update(key, valueToSet, { arrayAction: 'add' }); return message.replyLocale('COMMAND_CONF_UPDATED', [key, this.displayEntry(update.entry, update.next, message.guild as Guild)]); } catch (error) { throw String(error); @@ -60,7 +58,7 @@ export default class extends Command { public async remove(message: Message, [key, valueToRemove]: [string, string]): Promise { try { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const [update] = await message.guild!.settings.update(key, valueToRemove, { onlyConfigurable: true, arrayAction: 'remove' }); + const [update] = await message.guild!.settings.update(key, valueToRemove, { arrayAction: 'remove' }); return message.replyLocale('COMMAND_CONF_UPDATED', [key, this.displayEntry(update.entry, update.next, message.guild as Guild)]); } catch (error) { throw String(error); @@ -77,37 +75,24 @@ export default class extends Command { } } - public init(): void { - const { schema } = this.client.gateways.get('guilds') as GatewayStorage; - if (this.initFolderConfigurableRecursive(schema)) this.configurableSchemaKeys.set(schema.path, schema); - } - - private displayFolder(settings: SettingsFolder): string { + private displayFolder(settings: Settings): string { const array = []; - const folders = []; const sections = new Map(); let longest = 0; - for (const [key, value] of settings.schema.entries()) { - if (!this.configurableSchemaKeys.has(value.path)) continue; + for (const [key, value] of settings.gateway.schema.entries()) { + const values = sections.get(value.type) || []; + values.push(key); - if (value.type === 'Folder') { - folders.push(`// ${key}`); - } else { - const values = sections.get(value.type) || []; - values.push(key); - - if (key.length > longest) longest = key.length; - if (values.length === 1) sections.set(value.type, values); - } + if (key.length > longest) longest = key.length; + if (values.length === 1) sections.set(value.type, values); } - if (folders.length) array.push('= Folders =', ...folders.sort(), ''); if (sections.size) { for (const keyType of [...sections.keys()].sort()) { array.push(`= ${toTitleCase(keyType)}s =`, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion ...sections.get(keyType)!.sort().map(key => // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - `${key.padEnd(longest)} :: ${this.displayEntry(settings.schema.get(key) as SchemaEntry, settings.get(key), settings.base!.target as Guild)}`), + `${key.padEnd(longest)} :: ${this.displayEntry(settings.gateway.schema.get(key) as SchemaEntry, settings.get(key), settings.target as Guild)}`), ''); } } @@ -131,17 +116,4 @@ export default class extends Command { `[ ${values.map(value => this.displayEntrySingle(entry, value, guild)).join(' | ')} ]`; } - private initFolderConfigurableRecursive(folder: Schema): boolean { - const previousConfigurableCount = this.configurableSchemaKeys.size; - for (const value of folder.values()) { - if (SchemaFolder.is(value)) { - if (this.initFolderConfigurableRecursive(value)) this.configurableSchemaKeys.set(value.path, value); - } else if (value.configurable) { - this.configurableSchemaKeys.set(value.path, value); - } - } - - return previousConfigurableCount !== this.configurableSchemaKeys.size; - } - } diff --git a/src/commands/General/User Settings/userconf.ts b/src/commands/General/User Settings/userconf.ts index 5c92ca8667..a90c5f3626 100644 --- a/src/commands/General/User Settings/userconf.ts +++ b/src/commands/General/User Settings/userconf.ts @@ -1,12 +1,11 @@ -import { Argument, Command, CommandStore, GatewayStorage, Schema, SchemaEntry, SchemaFolder, SettingsFolder } from 'klasa'; -import { toTitleCase, codeBlock } from '@klasa/utils'; +import { Argument, Command, CommandStore, Settings, SchemaEntry, Gateway } from 'klasa'; +import { toTitleCase } from '@klasa/utils'; +import { codeblock } from 'discord-md-tags'; import type { Message, Guild } from '@klasa/core'; export default class extends Command { - private readonly configurableSchemaKeys = new Map(); - public constructor(store: CommandStore, directory: string, files: readonly string[]) { super(store, directory, files, { guarded: true, @@ -28,24 +27,23 @@ export default class extends Command { }); } + private get gateway(): Gateway { + return this.client.gateways.get('users') as Gateway; + } + public show(message: Message, [key]: [string]): Promise { - const schemaOrEntry = this.configurableSchemaKeys.get(key); - if (typeof schemaOrEntry === 'undefined') throw message.language.get('COMMAND_CONF_GET_NOEXT', key); + if (!key) return message.replyLocale('COMMAND_CONF_SERVER', [key, codeblock('asciidoc')`${this.displayFolder(message.author.settings)}`]); - const value = key ? message.author.settings.get(key) : message.author.settings; - if (SchemaEntry.is(schemaOrEntry)) { - return message.replyLocale('COMMAND_CONF_GET', [key, this.displayEntry(schemaOrEntry, value, message.guild)]); - } + const entry = this.gateway.schema.get(key); + if (!entry) throw message.language.get('COMMAND_CONF_GET_NOEXT', key); - return message.replyLocale('COMMAND_CONF_SERVER', [ - key ? `: ${key.split('.').map(toTitleCase).join('/')}` : '', - codeBlock('asciidoc', this.displayFolder(value as SettingsFolder)) - ]); + const value = message.author.settings.get(key); + return message.replyLocale('COMMAND_CONF_GET', [key, this.displayEntry(entry, value, message.guild)]); } public async set(message: Message, [key, valueToSet]: [string, string]): Promise { try { - const [update] = await message.author.settings.update(key, valueToSet, { onlyConfigurable: true, arrayAction: 'add' }); + const [update] = await message.author.settings.update(key, valueToSet, { arrayAction: 'add' }); return message.replyLocale('COMMAND_CONF_UPDATED', [key, this.displayEntry(update.entry, update.next, message.guild)]); } catch (error) { throw String(error); @@ -54,8 +52,8 @@ export default class extends Command { public async remove(message: Message, [key, valueToRemove]: [string, string]): Promise { try { - const [update] = await message.author.settings.update(key, valueToRemove, { onlyConfigurable: true, arrayAction: 'remove' }); - return message.replyLocale('COMMAND_CONF_UPDATED', [key, this.displayEntry(update.entry, update.next, message.guild as Guild)]); + const [update] = await message.author.settings.update(key, valueToRemove, { arrayAction: 'remove' }); + return message.replyLocale('COMMAND_CONF_UPDATED', [key, this.displayEntry(update.entry, update.next, message.guild)]); } catch (error) { throw String(error); } @@ -64,43 +62,30 @@ export default class extends Command { public async reset(message: Message, [key]: [string]): Promise { try { const [update] = await message.author.settings.reset(key); - return message.replyLocale('COMMAND_CONF_RESET', [key, this.displayEntry(update.entry, update.next, message.guild as Guild)]); + return message.replyLocale('COMMAND_CONF_RESET', [key, this.displayEntry(update.entry, update.next, message.guild)]); } catch (error) { throw String(error); } } - public init(): void { - const { schema } = this.client.gateways.get('users') as GatewayStorage; - if (this.initFolderConfigurableRecursive(schema)) this.configurableSchemaKeys.set(schema.path, schema); - } - - private displayFolder(settings: SettingsFolder): string { + private displayFolder(settings: Settings): string { const array = []; - const folders = []; const sections = new Map(); let longest = 0; - for (const [key, value] of settings.schema.entries()) { - if (!this.configurableSchemaKeys.has(value.path)) continue; + for (const [key, value] of settings.gateway.schema.entries()) { + const values = sections.get(value.type) || []; + values.push(key); - if (value.type === 'Folder') { - folders.push(`// ${key}`); - } else { - const values = sections.get(value.type) || []; - values.push(key); - - if (key.length > longest) longest = key.length; - if (values.length === 1) sections.set(value.type, values); - } + if (key.length > longest) longest = key.length; + if (values.length === 1) sections.set(value.type, values); } - if (folders.length) array.push('= Folders =', ...folders.sort(), ''); if (sections.size) { for (const keyType of [...sections.keys()].sort()) { array.push(`= ${toTitleCase(keyType)}s =`, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion ...sections.get(keyType)!.sort().map(key => // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - `${key.padEnd(longest)} :: ${this.displayEntry(settings.schema.get(key) as SchemaEntry, settings.get(key), settings.base!.target as Guild)}`), + `${key.padEnd(longest)} :: ${this.displayEntry(settings.gateway.schema.get(key) as SchemaEntry, settings.get(key), null)}`), ''); } } @@ -124,17 +109,4 @@ export default class extends Command { `[ ${values.map(value => this.displayEntrySingle(entry, value, guild)).join(' | ')} ]`; } - private initFolderConfigurableRecursive(folder: Schema): boolean { - const previousConfigurableCount = this.configurableSchemaKeys.size; - for (const value of folder.values()) { - if (SchemaFolder.is(value)) { - if (this.initFolderConfigurableRecursive(value)) this.configurableSchemaKeys.set(value.path, value); - } else if (value.configurable) { - this.configurableSchemaKeys.set(value.path, value); - } - } - - return previousConfigurableCount !== this.configurableSchemaKeys.size; - } - } diff --git a/src/events/argumentError.ts b/src/events/argumentError.ts index d879301f6e..93bf55046d 100644 --- a/src/events/argumentError.ts +++ b/src/events/argumentError.ts @@ -1,11 +1,14 @@ import { Event, Message } from '@klasa/core'; +import { codeblock } from 'discord-md-tags'; import type { Argument } from 'klasa'; export default class extends Event { - public async run(message: Message, _argument: Argument, _params: readonly unknown[], error: string): Promise { - await message.reply(mb => mb.setContent(error)); + public async run(message: Message, argument: Argument, _params: readonly unknown[], error: Error | string): Promise { + if (error instanceof Error) this.client.emit('wtf', `[ARGUMENT] ${argument.path}\n${error.stack || error}`); + if (typeof error === 'string') await message.reply(mb => mb.setContent(error)); + else await message.reply(mb => mb.setContent(codeblock('JSON') `${error.message}`)); } } diff --git a/src/index.ts b/src/index.ts index 36a9acd9e3..5dab70cd9e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,13 +15,10 @@ export * from './lib/schedule/ScheduledTask'; // lib/settings export * from './lib/settings/gateway/Gateway'; -export * from './lib/settings/gateway/GatewayDriver'; -export * from './lib/settings/gateway/GatewayStorage'; +export * from './lib/settings/gateway/GatewayStore'; export * from './lib/settings/schema/Schema'; -export * from './lib/settings/schema/SchemaFolder'; export * from './lib/settings/schema/SchemaEntry'; export * from './lib/settings/Settings'; -export * from './lib/settings/SettingsFolder'; // lib/structures export * from './lib/structures/Argument'; diff --git a/src/lib/Client.ts b/src/lib/Client.ts index a8b7c342b9..22c76a07b1 100644 --- a/src/lib/Client.ts +++ b/src/lib/Client.ts @@ -33,7 +33,7 @@ import { SerializerStore } from './structures/SerializerStore'; import { TaskStore } from './structures/TaskStore'; // lib/settings -import { GatewayDriver } from './settings/gateway/GatewayDriver'; +import { GatewayStore } from './settings/gateway/GatewayStore'; import { Gateway } from './settings/gateway/Gateway'; // lib/settings/schema @@ -146,12 +146,6 @@ export interface CommandHandlingOptions { */ prefix?: string | string[] | null; - /** - * The regular expression prefix if one is provided - * @default null - */ - regexPrefix?: RegExp | null; - /** * Amount of time in ms before the bot will respond to a users command since the last command that user has run * @default 0 @@ -380,10 +374,10 @@ export class KlasaClient extends Client { public permissionLevels: PermissionLevels; /** - * The GatewayDriver instance where the gateways are stored + * The GatewayStore instance where the gateways are stored * @since 0.5.0 */ - public gateways: GatewayDriver; + public gateways: GatewayStore; /** * The Schedule that runs the tasks @@ -443,7 +437,7 @@ export class KlasaClient extends Client { // eslint-disable-next-line this.permissionLevels['validate'](); - this.gateways = new GatewayDriver(this); + this.gateways = new GatewayStore(this); const { guilds, users, clientStorage } = this.options.settings.gateways; const guildSchema = guilds.schema(new Schema()); @@ -461,8 +455,6 @@ export class KlasaClient extends Client { guildSchema.add('language', 'language', { default: this.options.language }); } - guildSchema.add('disableNaturalPrefix', 'boolean', { configurable: Boolean(this.options.commands.regexPrefix) }); - // Register default gateways this.gateways .register(new Gateway(this, 'guilds', { ...guilds, schema: guildSchema })) @@ -618,7 +610,6 @@ export class KlasaClient extends Client { public static defaultGuildSchema = new Schema() .add('prefix', 'string') .add('language', 'language') - .add('disableNaturalPrefix', 'boolean') .add('disabledCommands', 'command', { array: true, filter: (_client, command: Command, { language }) => { @@ -656,7 +647,7 @@ declare module '@klasa/core/dist/src/lib/client/Client' { tasks: TaskStore; serializers: SerializerStore; permissionLevels: PermissionLevels; - gateways: GatewayDriver; + gateways: GatewayStore; schedule: Schedule; ready: boolean; mentionPrefix: RegExp | null; diff --git a/src/lib/extensions/KlasaMessage.ts b/src/lib/extensions/KlasaMessage.ts index da2cd05971..faf4977d60 100644 --- a/src/lib/extensions/KlasaMessage.ts +++ b/src/lib/extensions/KlasaMessage.ts @@ -231,7 +231,7 @@ export class KlasaMessage extends extender.get('Message') { this.prompter = null; try { - const prefix = this._mentionPrefix() || this._customPrefix() || this._naturalPrefix() || this._prefixLess(); + const prefix = this._mentionPrefix() || this._customPrefix() || this._prefixLess(); if (!prefix) return; @@ -277,16 +277,6 @@ export class KlasaMessage extends extender.get('Message') { return mentionPrefix ? { length: mentionPrefix[0].length, regex: this.client.mentionPrefix } : null; } - /** - * Checks if the natural prefix is used - * @since 0.5.0 - */ - private _naturalPrefix(): CachedPrefix | null { - if (this.guildSettings.get('disableNaturalPrefix') || !this.client.options.commands.regexPrefix) return null; - const results = this.client.options.commands.regexPrefix.exec(this.content); - return results ? { length: results[0].length, regex: this.client.options.commands.regexPrefix } : null; - } - /** * Checks if a prefixless scenario is possible * @since 0.5.0 diff --git a/src/lib/settings/Settings.ts b/src/lib/settings/Settings.ts index 6f3e0da6d6..22bb7906a6 100644 --- a/src/lib/settings/Settings.ts +++ b/src/lib/settings/Settings.ts @@ -1,8 +1,12 @@ -import { SettingsFolder, SettingsExistenceStatus } from './SettingsFolder'; +import { Cache } from '@klasa/cache'; +import { objectToTuples, arrayStrictEquals, isObject } from '@klasa/utils'; +import type { Client, Guild } from '@klasa/core'; +import type { SerializerUpdateContext, Serializer } from '../structures/Serializer'; +import type { SchemaEntry } from './schema/SchemaEntry'; import type { Gateway } from './gateway/Gateway'; -export class Settings extends SettingsFolder { +export class Settings extends Cache { /** * The ID of the database entry this instance manages. @@ -26,13 +30,12 @@ export class Settings extends SettingsFolder { public existenceStatus: SettingsExistenceStatus; public constructor(gateway: Gateway, target: unknown, id: string) { - super(gateway.schema); - this.base = this; + super(); this.id = id; this.gateway = gateway; this.target = target; this.existenceStatus = SettingsExistenceStatus.Unsynchronized; - this._init(this, this.schema); + this._init(); } /** @@ -76,11 +79,653 @@ export class Settings extends SettingsFolder { if (provider === null) throw new Error('The provider was not available during the destroy operation.'); await provider.delete(this.gateway.name, this.id); this.gateway.client.emit('settingsDelete', this); - this._init(this, this.schema); + this._init(); this.existenceStatus = SettingsExistenceStatus.NotExists; } return this; } + /** + * The client that manages this instance. + */ + public get client(): Client { + return this.gateway.client; + } + + /** + * Plucks out one or more attributes from either an object or a sequence of objects + * @param paths The paths to take + * @example + * const [x, y] = message.guild.settings.pluck('x', 'y'); + * console.log(x, y); + */ + public pluck(...paths: readonly string[]): unknown[] { + return paths.map(path => this.get(path)); + } + + /** + * Resolves paths into their full objects or values depending on the current set value + * @param paths The paths to resolve + */ + public resolve(...paths: readonly string[]): Promise { + const guild = this.client.guilds.resolve(this.target); + const language = guild?.language ?? this.client.languages.default; + return Promise.all(paths.map(path => { + const entry = this.gateway.schema.get(path); + if (typeof entry === 'undefined') return undefined; + return this._resolveEntry({ + entry, + language, + guild, + extraContext: null + }); + })); + } + + /** + * Resets all keys from the instance. + * @example + * // Resets all entries: + * await message.guild.settings.reset(); + */ + public async reset(): Promise; + /** + * Resets a key from the instance. + * @param path The path of the key to reset from the instance + * @param options The options for this action + * @example + * // Resets an entry: + * await message.guild.settings.reset('prefix'); + */ + public async reset(path: string, options?: Readonly): Promise; + /** + * Resets multiple keys from the instance. + * @param paths The paths of the keys to reset from the instance + * @param options The options for this action + * @example + * // Resets an entry: + * await message.guild.settings.reset(['prefix']); + * + * @example + * // Resets multiple entries: + * await message.guild.settings.reset(['prefix', 'administrator']); + */ + public async reset(paths: readonly string[], options?: Readonly): Promise; + /** + * Resets multiple keys from the instance. + * @param object The object to retrieve the paths of the keys to reset from the instance + * @param options The options for this action + * @example + * // Resets an entry: + * await message.guild.settings.reset({ prefix: null }); + * + * @example + * // Resets multiple entries with a regular object: + * await message.guild.settings.reset({ prefix: null, administrator: null }); + */ + public async reset(object: ReadonlyKeyedObject, options?: Readonly): Promise; + public async reset(paths: string | ReadonlyKeyedObject | readonly string[] = [...this.keys()], options: Readonly = {}): Promise { + if (this.existenceStatus === SettingsExistenceStatus.Unsynchronized) { + throw new Error('Cannot reset keys from an unsynchronized settings instance. Perhaps you want to call `sync()` first.'); + } + + if (this.existenceStatus === SettingsExistenceStatus.NotExists) { + return []; + } + + if (typeof paths === 'string') paths = [paths]; + else if (isObject(paths)) paths = objectToTuples(paths as Record).map(entries => entries[0]); + + const { client, gateway } = this; + const guild = client.guilds.resolve(typeof options.guild === 'undefined' ? this.target : options.guild); + const language = guild?.language ?? client.languages.default; + const extra = options.extraContext; + + const changes: SettingsUpdateResult[] = []; + for (const path of paths as readonly string[]) { + const entry = gateway.schema.get(path); + + // If the key does not exist, throw + if (typeof entry === 'undefined') throw new Error(language.get('SETTING_GATEWAY_KEY_NOEXT', path)); + this._resetSettingsEntry(changes, entry); + } + + if (changes.length !== 0) await this._save({ changes, guild, language, extraContext: extra }); + return changes; + } + + /** + * Update a key from the instance. + * @param path The path of the key to update + * @param value The new value to validate and set + * @param options The options for this update + * @example + * // Change the prefix to '$': + * await message.guild.settings.update('prefix', '$'); + * + * @example + * // Add a new value to an array + * await message.guild.settings.update('disabledCommands', 'ping', { arrayAction: 'add' }); + * + * @example + * // Remove a value from an array + * await message.guild.settings.update('disabledCommands', 'ping', { arrayAction: 'remove' }); + * + * @example + * // Remove a value from an array of tuples ([[k1, v1], [k2, v2], ...]) + * const tags = message.guild.settings.get('tags'); + * const index = tags.findIndex(([tag]) => tag === 'foo'); + * await message.guild.settings.update('tags', null, { arrayIndex: index }); + */ + public update(path: string, value: unknown, options?: SettingsUpdateOptions): Promise; + /** + * Update one or more keys from the instance. + * @param entries The key and value pairs to update + * @param options The options for this update + * @example + * // Change the prefix to '$' and update disabledCommands adding/removing 'ping': + * await message.guild.settings.update([['prefix', '$'], ['disabledCommands', 'ping']]); + * + * @example + * // Add a new value to an array + * await message.guild.settings.update([['disabledCommands', 'ping']], { arrayAction: 'add' }); + * + * @example + * // Remove a value from an array + * await message.guild.settings.update([['disabledCommands', 'ping']], { arrayAction: 'remove' }); + * + * @example + * // Remove a value from an array of tuples ([[k1, v1], [k2, v2], ...]) + * const tags = message.guild.settings.get('tags'); + * const index = tags.findIndex(([tag]) => tag === 'foo'); + * await message.guild.settings.update([['tags', null]], { arrayIndex: index }); + */ + public update(entries: [string, unknown][], options?: SettingsUpdateOptions): Promise; + /** + * Update one or more keys using an object approach. + * @param entries An object to flatten and update + * @param options The options for this update + * @example + * // Change the prefix to '$' and update disabledCommands adding/removing 'ping': + * await message.guild.settings.update({ prefix: '$', disabledCommands: 'ping' }); + * + * @example + * // Add a new value to an array + * await message.guild.settings.update({ disabledCommands: ['ping'] }, { arrayAction: 'add' }); + * + * @example + * // Remove a value from an array + * await message.guild.settings.update({ disabledCommands: ['ping'] }, { arrayAction: 'remove' }); + * + * @example + * // Remove a value from an array of tuples ([[k1, v1], [k2, v2], ...]) + * const tags = message.guild.settings.get('tags'); + * const index = tags.findIndex(([tag]) => tag === 'foo'); + * await message.guild.settings.update({ tags: null }, { arrayIndex: index }); + */ + public update(entries: ReadonlyKeyedObject, options?: SettingsUpdateOptions): Promise; + public async update(pathOrEntries: PathOrEntries, valueOrOptions?: ValueOrOptions, options?: SettingsUpdateOptions): Promise { + if (this.existenceStatus === SettingsExistenceStatus.Unsynchronized) { + throw new Error('Cannot reset keys from an unsynchronized settings instance. Perhaps you want to call `sync()` first.'); + } + + let entries: [string, unknown][]; + if (typeof pathOrEntries === 'string') { + entries = [[pathOrEntries, valueOrOptions as unknown]]; + options = typeof options === 'undefined' ? {} : options; + } else if (isObject(pathOrEntries)) { + entries = Object.entries(pathOrEntries as ReadonlyKeyedObject) as [string, unknown][]; + options = typeof valueOrOptions === 'undefined' ? {} : valueOrOptions as SettingsUpdateOptions; + } else { + entries = pathOrEntries as [string, unknown][]; + options = typeof valueOrOptions === 'undefined' ? {} : valueOrOptions as SettingsUpdateOptions; + } + + return this._processUpdate(entries, options as InternalRawUpdateOptions); + } + + /** + * Overload to serialize this entry to JSON. + */ + public toJSON(): SettingsJson { + return Object.fromEntries(this.map((value, key) => [key, value])); + } + + /** + * Patch an object against this instance. + * @param data The data to apply to this instance + */ + protected _patch(data: unknown): void { + for (const [key, value] of Object.entries(data as Record)) { + // Retrieve the key and guard it, if it's undefined, it's not in the schema. + const childValue = this.get(key); + if (typeof childValue !== 'undefined') this.set(key, value); + } + } + + /** + * Initializes the instance, preparing it for later usage. + */ + protected _init(): void { + for (const [key, value] of this.gateway.schema.entries()) { + this.set(key, value.default); + } + } + + protected async _save(context: SettingsUpdateContext): Promise { + const updateObject: KeyedObject = Object.fromEntries(context.changes.map(change => [change.entry.key, change.next])); + const { gateway, id } = this; + + /* istanbul ignore if: Extremely hard to reproduce in coverage testing */ + if (gateway.provider === null) throw new Error('Cannot update due to the gateway missing a reference to the provider.'); + if (this.existenceStatus === SettingsExistenceStatus.Exists) { + await gateway.provider.update(gateway.name, id, context.changes); + this._patch(updateObject); + gateway.client.emit('settingsUpdate', this, updateObject, context); + } else { + await gateway.provider.create(gateway.name, id, context.changes); + this.existenceStatus = SettingsExistenceStatus.Exists; + this._patch(updateObject); + gateway.client.emit('settingsCreate', this, updateObject, context); + } + } + + private async _resolveEntry(context: SerializerUpdateContext): Promise { + const values = this.get(context.entry.key); + if (typeof values === 'undefined') return undefined; + + if (!context.entry.shouldResolve) return values; + + const { serializer } = context.entry; + if (serializer === null) throw new Error('The serializer was not available during the resolve.'); + if (context.entry.array) { + return (await Promise.all((values as readonly unknown[]) + .map(value => serializer.resolve(value, context)))) + .filter(value => value !== null); + } + + return serializer.resolve(values, context); + } + + private _resetSettingsEntry(changes: SettingsUpdateResult[], schemaEntry: SchemaEntry): void { + const previous = this.get(schemaEntry.key); + const next = schemaEntry.default; + + const equals = schemaEntry.array ? + arrayStrictEquals(previous as unknown as readonly unknown[], next as readonly unknown[]) : + previous === next; + + if (!equals) { + changes.push({ + previous, + next, + entry: schemaEntry + }); + } + } + + private async _processUpdate(entries: [string, unknown][], options: InternalRawUpdateOptions): Promise { + const { client, gateway } = this; + const arrayAction = typeof options.arrayAction === 'undefined' ? ArrayActions.Auto : options.arrayAction as ArrayActions; + const arrayIndex = typeof options.arrayIndex === 'undefined' ? null : options.arrayIndex; + const guild = client.guilds.resolve(typeof options.guild === 'undefined' ? this.target : options.guild); + const language = guild?.language ?? client.languages.default; + const extra = options.extraContext; + const internalOptions: InternalSettingsUpdateOptions = { arrayAction, arrayIndex }; + + const promises: Promise[] = []; + for (const [path, value] of entries) { + const entry = gateway.schema.get(path); + + // If the key does not exist, throw + if (typeof entry === 'undefined') throw new Error(language.get('SETTING_GATEWAY_KEY_NOEXT', path)); + promises.push(this._updateSettingsEntry(path, value, { entry: entry as SchemaEntry, language, guild, extraContext: extra }, internalOptions)); + } + + const changes = await Promise.all(promises); + if (changes.length !== 0) await this._save({ changes, guild, language, extraContext: extra }); + return changes; + } + + private async _updateSettingsEntry(key: string, rawValue: unknown, context: SerializerUpdateContext, options: InternalSettingsUpdateOptions): Promise { + const previous = this.get(key); + + // If null or undefined, return the default value instead + if (rawValue === null || typeof rawValue === 'undefined') { + return { previous, next: context.entry.default, entry: context.entry }; + } + + // If the entry doesn't take an array, all the extra steps should be skipped + if (!context.entry.array) { + const values = await this._updateSchemaEntryValue(rawValue, context, true); + return { previous, next: this._resolveNextValue(values, context), entry: context.entry }; + } + + // If the action is overwrite, resolve values accepting null, afterwards filter them out + if (options.arrayAction === ArrayActions.Overwrite) { + return { previous, next: this._resolveNextValue(await this._resolveValues(rawValue, context, true), context), entry: context.entry }; + } + + // The next value depends on whether arrayIndex was set or not + const next = options.arrayIndex === null ? + this._updateSettingsEntryNotIndexed(previous as unknown[], await this._resolveValues(rawValue, context, false), context, options) : + this._updateSettingsEntryAtIndex(previous as unknown[], await this._resolveValues(rawValue, context, options.arrayAction === ArrayActions.Remove), options.arrayIndex, options.arrayAction); + + return { + previous, + next, + entry: context.entry + }; + } + + private _updateSettingsEntryNotIndexed(previous: readonly unknown[], values: readonly unknown[], context: SerializerUpdateContext, options: InternalSettingsUpdateOptions): unknown[] { + const clone = previous.slice(0); + const serializer = context.entry.serializer as Serializer; + if (options.arrayAction === ArrayActions.Auto) { + // Array action auto must add or remove values, depending on their existence + for (const value of values) { + const index = clone.indexOf(value); + if (index === -1) clone.push(value); + else clone.splice(index, 1); + } + } else if (options.arrayAction === ArrayActions.Add) { + // Array action add must add values, throw on existent + for (const value of values) { + if (clone.includes(value)) throw new Error(context.language.get('SETTING_GATEWAY_DUPLICATE_VALUE', context.entry, serializer.stringify(value, context.guild))); + clone.push(value); + } + } else if (options.arrayAction === ArrayActions.Remove) { + // Array action remove must add values, throw on non-existent + for (const value of values) { + const index = clone.indexOf(value); + if (index === -1) throw new Error(context.language.get('SETTING_GATEWAY_MISSING_VALUE', context.entry, serializer.stringify(value, context.guild))); + clone.splice(index, 1); + } + } else { + throw new TypeError(`The ${options.arrayAction} array action is not a valid array action.`); + } + + return clone; + } + + private _updateSettingsEntryAtIndex(previous: readonly unknown[], values: readonly unknown[], arrayIndex: number, arrayAction: ArrayActions | null): unknown[] { + if (arrayIndex < 0 || arrayIndex > previous.length) { + throw new RangeError(`The index ${arrayIndex} is bigger than the current array. It must be a value in the range of 0..${previous.length}.`); + } + + let clone = previous.slice(); + if (arrayAction === ArrayActions.Add) { + clone.splice(arrayIndex, 0, ...values); + } else if (arrayAction === ArrayActions.Remove || values.every(nv => nv === null)) { + clone.splice(arrayIndex, values.length); + } else { + clone.splice(arrayIndex, values.length, ...values); + clone = clone.filter(nv => nv !== null); + } + + return clone; + } + + private async _resolveValues(value: unknown, context: SerializerUpdateContext, acceptNull: boolean): Promise { + return Array.isArray(value) ? + await Promise.all(value.map(val => this._updateSchemaEntryValue(val, context, acceptNull))) : + [await this._updateSchemaEntryValue(value, context, acceptNull)]; + } + + private _resolveNextValue(value: unknown, context: SerializerUpdateContext): unknown { + if (Array.isArray(value)) { + const filtered = value.filter(nv => nv !== null); + return filtered.length === 0 ? context.entry.default : filtered; + } + + return value === null ? context.entry.default : value; + } + + private async _updateSchemaEntryValue(value: unknown, context: SerializerUpdateContext, acceptNull: boolean): Promise { + if (acceptNull && value === null) return null; + + const { serializer } = context.entry; + + /* istanbul ignore if: Extremely hard to reproduce in coverage testing */ + if (serializer === null) throw new TypeError('The serializer was not available during the update.'); + const parsed = await serializer.validate(value, context); + + if (context.entry.filter !== null && context.entry.filter(this.client, parsed, context)) throw new Error(context.language.get('SETTING_GATEWAY_INVALID_FILTERED_VALUE', context.entry, value)); + return serializer.serialize(parsed); + } + +} + +/** + * The existence status of this settings entry. They're the possible values for {@link Settings#existenceStatus} and + * represents its status in disk. + * @memberof Settings + */ +export const enum SettingsExistenceStatus { + /** + * The settings has not been synchronized, in this status, any update operation will error. To prevent this, call + * `settings.sync()` first. + */ + Unsynchronized, + /** + * The settings entry exists in disk, any disk operation will be done through an update. + */ + Exists, + /** + * The settings entry does not exist in disk, the first disk operation will be done through a create. Afterwards it + * sets itself to Exists. + */ + NotExists +} + +/** + * The options for {@link Settings#reset}. + * @memberof Settings + */ +export interface SettingsResetOptions { + /** + * The guild to use as the context. It's not required when the settings' target can be resolved into a Guild, e.g. + * a TextChannel, a Role, a GuildMember, or a Guild instance. + */ + guild?: Guild; + /** + * The extra context to be passed through resolvers and events. + */ + extraContext?: unknown; +} + +/** + * The options for {@link Settings#update} when specifying `arrayAction` as overwrite. + * @memberof Settings + */ +export interface SettingsUpdateOptionsOverwrite extends SettingsResetOptions { + /** + * The array action, in this case overwrite and not supporting `arrayIndex`. + * @example + * settings.get('words'); + * // -> ['foo', 'bar'] + * + * await settings.update('words', ['hello', 'world'], { arrayAction: 'overwrite' }); + * settings.get('words'); + * // -> ['hello', 'world'] + */ + arrayAction: ArrayActions.Overwrite | 'overwrite'; +} + +/** + * The options for {@link Settings#update} when not specifying `arrayAction` as overwrite or leaving it default. + * @memberof Settings + */ +export interface SettingsUpdateOptionsNonOverwrite extends SettingsResetOptions { + /** + * The array action to take, check {@link ArrayActions} for the available actions. + */ + arrayAction?: Exclude | Exclude; + /** + * The index to do the array updates at. This is option is ignored when `arrayAction` is set to overwrite. + */ + arrayIndex?: number | null; } + +/** + * The options for {@link Settings#update}. + * @memberof Settings + */ +export type SettingsUpdateOptions = SettingsUpdateOptionsOverwrite | SettingsUpdateOptionsNonOverwrite; + +/** + * The update context that is passed to the {@link Client#settingsUpdate} and {@link Client#settingsCreate} events. + * @memberof Settings + */ +export interface SettingsUpdateContext extends Omit { + /** + * The changes done. + */ + readonly changes: SettingsUpdateResults; +} + +/** + * One of the update results from {@link Settings#update}, containing the previous and next values, and the + * {@link SchemaEntry} instance that controlled the value. + * @memberof Settings + */ +export interface SettingsUpdateResult { + /** + * The value prior to the update. + */ + readonly previous: unknown; + /** + * The serialized value that has been set. + */ + readonly next: unknown; + /** + * The SchemaEntry instance that contains the metadata for this update result. + */ + readonly entry: SchemaEntry; +} + +/** + * The update results from {@link Settings#update}, it contains an array of {@link SettingsUpdateResults results}. + * @memberof Settings + */ +export type SettingsUpdateResults = readonly SettingsUpdateResult[]; + +/** + * A plain object keyed by the schema's keys and containing serialized values. + * @memberof Settings + */ +export type SettingsJson = Record; + +/** + * The actions to take on Settings#update calls. + * @memberof Settings + */ +export const enum ArrayActions { + /** + * Override the insert/remove behaviour by pushing new keys to the array to the end or to the specified position. + * @example + * // Push to the end: + * + * settings.get('words'); + * // -> ['foo', 'bar'] + * + * await settings.update('words', ['hello', 'world'], { arrayAction: 'add' }); + * settings.get('words'); + * // -> ['foo', 'bar', 'hello', 'world'] + * + * @example + * // Push to a position: + * + * settings.get('words'); + * // -> ['foo', 'bar'] + * + * await settings.update('words', ['hello', 'world'], { arrayAction: 'add', arrayPosition: 1 }); + * settings.get('words'); + * // -> ['foo', 'hello', 'world', 'bar'] + */ + Add = 'add', + /** + * Override the insert/remove behaviour by removing keys to the array to the end or to the specified position. + * @throws Throws an error when a value does not exist. + * @example + * // Remove: + * + * settings.get('words'); + * // -> ['foo', 'bar'] + * + * await settings.update('words', ['foo'], { arrayAction: 'remove' }); + * settings.get('words'); + * // -> ['bar'] + * + * @example + * // Remove from position: + * + * settings.get('words'); + * // -> ['foo', 'hello', 'world', 'bar'] + * + * await settings.update('words', [null, null], { arrayAction: 'remove', arrayPosition: 1 }); + * settings.get('words'); + * // -> ['foo', 'bar'] + */ + Remove = 'remove', + /** + * Set insert/remove behaviour, this is the value set by default. + * @example + * settings.get('words'); + * // -> ['foo', 'bar'] + * + * await settings.update('words', ['foo', 'hello']); + * settings.get('words'); + * // -> ['bar', 'hello'] + */ + Auto = 'auto', + /** + * Overwrite the array with newly set values. The `arrayIndex` option is ignored when specifying overwrite. + * @example + * settings.get('words'); + * // -> ['foo', 'bar'] + * + * await settings.update('words', ['hello', 'world'], { arrayAction: 'overwrite' }); + * settings.get('words'); + * // -> ['hello', 'world'] + */ + Overwrite = 'overwrite' +} + +export type KeyedObject = Record; +export type ReadonlyKeyedObject = Readonly>>; + +/** + * The actions as a string, done for retrocompatibility. + * @memberof Settings + * @internal + */ +export type ArrayActionsString = 'add' | 'remove' | 'auto' | 'overwrite'; + +/** + * The internal sanitized options created by {@link Settings#update} to avoid mutation of the original options. + * @memberof Settings + * @internal + */ +interface InternalSettingsUpdateOptions { + readonly arrayAction: ArrayActions; + readonly arrayIndex: number | null; +} + +/** + * The values {@link Settings#reset} and {@link Settings#update} accept. + * @memberof Settings + */ +type PathOrEntries = string | [string, unknown][] | ReadonlyKeyedObject; + +/** + * The possible values or the options passed. + */ +type ValueOrOptions = unknown | SettingsUpdateOptions; + +/** + * @memberof Settings + * @internal + */ +type InternalRawUpdateOptions = SettingsUpdateOptions & SettingsUpdateOptionsNonOverwrite; diff --git a/src/lib/settings/SettingsFolder.ts b/src/lib/settings/SettingsFolder.ts deleted file mode 100644 index 97d4804147..0000000000 --- a/src/lib/settings/SettingsFolder.ts +++ /dev/null @@ -1,844 +0,0 @@ -import { isObject, objectToTuples, mergeObjects, makeObject, arrayStrictEquals } from '@klasa/utils'; -import { SchemaEntry } from './schema/SchemaEntry'; - -import type { Guild, Client } from '@klasa/core'; -import type { Language } from '../structures/Language'; -import type { Schema } from './schema/Schema'; -import type { SchemaFolder } from './schema/SchemaFolder'; -import type { SerializerUpdateContext, Serializer } from '../structures/Serializer'; -import type { Settings } from './Settings'; - -/* eslint-disable no-dupe-class-members */ - -export class SettingsFolder extends Map { - - /** - * The reference to the base Settings instance. - */ - public base: Settings | null; - - /** - * The schema that manages this folder's structure. - */ - public readonly schema: Schema; - - public constructor(schema: Schema) { - super(); - this.base = null; - this.schema = schema; - } - - /** - * The client that manages this instance. - */ - public get client(): Client { - if (this.base === null) throw new Error('Cannot retrieve gateway from a non-ready settings instance.'); - return this.base.gateway.client; - } - - /** - * Get a value from the configuration. Accepts nested objects separating by dot - * @param path The path of the key's value to get from this instance - * @example - * // Simple get - * const prefix = message.guild.settings.get('prefix'); - * - * // Nested entry - * const channel = message.guild.settings.get('channels.moderation-logs'); - */ - public get(path: string): unknown { - try { - return path.split('.').reduce((folder, key) => Map.prototype.get.call(folder, key), this); - } catch { - return undefined; - } - } - - /** - * Plucks out one or more attributes from either an object or a sequence of objects - * @param paths The paths to take - * @example - * const [x, y] = message.guild.settings.pluck('x', 'y'); - * console.log(x, y); - */ - public pluck(...paths: readonly string[]): unknown[] { - return paths.map(path => { - const value = this.get(path); - return value instanceof SettingsFolder ? value.toJSON() : value; - }); - } - - /** - * Resolves paths into their full objects or values depending on the current set value - * @param paths The paths to resolve - */ - public resolve(...paths: readonly string[]): Promise { - if (this.base === null) return Promise.reject(new Error('Cannot retrieve guild from a non-ready settings instance.')); - - const guild = this.client.guilds.resolve(this.base.target); - const language = guild?.language ?? this.base.gateway.client.languages.default; - return Promise.all(paths.map(path => { - const entry = this.schema.get(path); - if (typeof entry === 'undefined') return undefined; - return SchemaEntry.is(entry) ? - this._resolveEntry({ - entry, - language, - guild, - extraContext: null - }) : - this._resolveFolder({ - folder: entry, - language, - guild, - extraContext: null - }); - })); - } - - /** - * Resets all keys from this settings folder. - * @example - * // Resets all entries: - * await message.guild.settings.reset(); - * - * @example - * // Resets all entries from a folder: - * await message.guild.settings.get('roles').reset(); - */ - public async reset(): Promise; - /** - * Resets a key from this settings folder. - * @param path The path of the key to reset from this settings folder - * @param options The options for this action - * @example - * // Resets an entry: - * await message.guild.settings.reset('prefix'); - * - * @example - * // Resets an entry contained by a folder: - * await message.guild.settings.reset('roles.administrator'); - * - * @example - * // Resets an entry from a folder: - * await message.guild.settings.get('roles').reset('administrator'); - */ - public async reset(path: string, options?: Readonly): Promise; - /** - * Resets multiple keys from this settings folder. - * @param paths The paths of the keys to reset from this settings folder - * @param options The options for this action - * @example - * // Resets an entry: - * await message.guild.settings.reset(['prefix']); - * - * @example - * // Resets multiple entries: - * await message.guild.settings.reset(['prefix', 'roles.administrator']); - * - * @example - * // Resets a key and an entire folder: - * await message.guild.settings.reset(['prefix', 'roles']); - */ - public async reset(paths: readonly string[], options?: Readonly): Promise; - /** - * Resets multiple keys from this settings folder. - * @param object The object to retrieve the paths of the keys to reset from this settings folder - * @param options The options for this action - * @example - * // Resets an entry: - * await message.guild.settings.reset({ prefix: null }); - * - * @example - * // Resets multiple entries with a regular object: - * await message.guild.settings.reset({ prefix: null, roles: { administrator: null } }); - * - * @example - * // Resets multiple entries with a dotted object: - * await message.guild.settings.reset({ prefix: null, 'roles.administrator': null }); - * - * @example - * // Resets a key and an entire folder: - * await message.guild.settings.reset({ prefix: null, roles: null }); - */ - public async reset(object: ReadonlyKeyedObject, options?: Readonly): Promise; - public async reset(paths: string | ReadonlyKeyedObject | readonly string[] = [...this.keys()], options: Readonly = {}): Promise { - if (this.base === null) { - throw new Error('Cannot reset keys from a non-ready settings instance.'); - } - - if (this.base.existenceStatus === SettingsExistenceStatus.Unsynchronized) { - throw new Error('Cannot reset keys from a pending to synchronize settings instance. Perhaps you want to call `sync()` first.'); - } - - if (this.base.existenceStatus === SettingsExistenceStatus.NotExists) { - return []; - } - - if (typeof paths === 'string') paths = [paths]; - else if (isObject(paths)) paths = objectToTuples(paths as Record).map(entries => entries[0]); - - const { client, schema } = this; - const onlyConfigurable = typeof options.onlyConfigurable === 'undefined' ? false : options.onlyConfigurable; - const guild = client.guilds.resolve(typeof options.guild === 'undefined' ? this.base.target : options.guild); - const language = guild?.language ?? client.languages.default; - const extra = options.extraContext; - - const changes: SettingsUpdateResult[] = []; - for (const path of paths as readonly string[]) { - const entry = schema.get(path); - - // If the key does not exist, throw - if (typeof entry === 'undefined') throw language.get('SETTING_GATEWAY_KEY_NOEXT', path); - if (SchemaEntry.is(entry)) this._resetSettingsEntry(changes, entry, language, onlyConfigurable); - else this._resetSettingsFolder(changes, entry, language, onlyConfigurable); - } - - if (changes.length !== 0) await this._save({ changes, guild, language, extraContext: extra }); - return changes; - } - - /** - * Update a key from this settings folder. - * @param path The path of the key to update - * @param value The new value to validate and set - * @param options The options for this update - * @example - * // Change the prefix to '$': - * await message.guild.settings.update('prefix', '$'); - * - * @example - * // Add a new value to an array - * await message.guild.settings.update('disabledCommands', 'ping', { arrayAction: 'add' }); - * - * @example - * // Remove a value from an array - * await message.guild.settings.update('disabledCommands', 'ping', { arrayAction: 'remove' }); - * - * @example - * // Remove a value from an array of tuples ([[k1, v1], [k2, v2], ...]) - * const tags = message.guild.settings.get('tags'); - * const index = tags.findIndex(([tag]) => tag === 'foo'); - * await message.guild.settings.update('tags', null, { arrayIndex: index }); - */ - public update(path: string, value: unknown, options?: SettingsFolderUpdateOptions): Promise; - /** - * Update one or more keys from this settings folder. - * @param entries The key and value pairs to update - * @param options The options for this update - * @example - * // Change the prefix to '$' and update disabledCommands adding/removing 'ping': - * await message.guild.settings.update([['prefix', '$'], ['disabledCommands', 'ping']]); - * - * @example - * // Add a new value to an array - * await message.guild.settings.update([['disabledCommands', 'ping']], { arrayAction: 'add' }); - * - * @example - * // Remove a value from an array - * await message.guild.settings.update([['disabledCommands', 'ping']], { arrayAction: 'remove' }); - * - * @example - * // Remove a value from an array of tuples ([[k1, v1], [k2, v2], ...]) - * const tags = message.guild.settings.get('tags'); - * const index = tags.findIndex(([tag]) => tag === 'foo'); - * await message.guild.settings.update([['tags', null]], { arrayIndex: index }); - */ - public update(entries: [string, unknown][], options?: SettingsFolderUpdateOptions): Promise; - /** - * Update one or more keys using an object approach. - * @param entries An object to flatten and update - * @param options The options for this update - * @example - * // Change the prefix to '$' and update disabledCommands adding/removing 'ping': - * await message.guild.settings.update({ prefix: '$', disabledCommands: 'ping' }); - * - * @example - * // Add a new value to an array - * await message.guild.settings.update({ disabledCommands: ['ping'] }, { arrayAction: 'add' }); - * - * @example - * // Remove a value from an array - * await message.guild.settings.update({ disabledCommands: ['ping'] }, { arrayAction: 'remove' }); - * - * @example - * // Remove a value from an array of tuples ([[k1, v1], [k2, v2], ...]) - * const tags = message.guild.settings.get('tags'); - * const index = tags.findIndex(([tag]) => tag === 'foo'); - * await message.guild.settings.update({ tags: null }, { arrayIndex: index }); - */ - public update(entries: ReadonlyKeyedObject, options?: SettingsFolderUpdateOptions): Promise; - public async update(pathOrEntries: PathOrEntries, valueOrOptions?: ValueOrOptions, options?: SettingsFolderUpdateOptions): Promise { - if (this.base === null) { - throw new Error('Cannot update keys from a non-ready settings instance.'); - } - - if (this.base.existenceStatus === SettingsExistenceStatus.Unsynchronized) { - throw new Error('Cannot update keys from a pending to synchronize settings instance. Perhaps you want to call `sync()` first.'); - } - - let entries: [string, unknown][]; - if (typeof pathOrEntries === 'string') { - entries = [[pathOrEntries, valueOrOptions as unknown]]; - options = typeof options === 'undefined' ? {} : options; - } else if (isObject(pathOrEntries)) { - entries = objectToTuples(pathOrEntries as ReadonlyKeyedObject) as [string, unknown][]; - options = typeof valueOrOptions === 'undefined' ? {} : valueOrOptions as SettingsFolderUpdateOptions; - } else { - entries = pathOrEntries as [string, unknown][]; - options = typeof valueOrOptions === 'undefined' ? {} : valueOrOptions as SettingsFolderUpdateOptions; - } - - return this._processUpdate(entries, options as InternalRawFolderUpdateOptions); - } - - /** - * Overload to serialize this entry to JSON. - */ - public toJSON(): SettingsFolderJson { - return Object.fromEntries([...super.entries()].map(([key, value]) => [key, value instanceof SettingsFolder ? value.toJSON() : value])); - } - - /** - * Patch an object against this instance. - * @param data The data to apply to this instance - */ - protected _patch(data: unknown): void { - for (const [key, value] of Object.entries(data as Record)) { - // Retrieve the key and guard it, if it's undefined, it's not in the schema. - const childValue = super.get(key); - if (typeof childValue === 'undefined') continue; - - if (childValue instanceof SettingsFolder) childValue._patch(value); - else super.set(key, value); - } - } - - /** - * Initializes a SettingsFolder, preparing it for later usage. - * @param folder The children folder of this instance - * @param schema The schema that manages the folder - */ - protected _init(folder: SettingsFolder, schema: Schema | SchemaFolder): void { - folder.base = this.base; - - for (const [key, value] of schema.entries()) { - if (SchemaEntry.is(value)) { - folder.set(key, value.default); - } else { - const settings = new SettingsFolder(value); - folder.set(key, settings); - this._init(settings, value); - } - } - } - - protected async _save(context: SettingsUpdateContext): Promise { - const updateObject: KeyedObject = {}; - for (const change of context.changes) { - mergeObjects(updateObject, makeObject(change.entry.path, change.next)); - } - - const base = this.base as Settings; - const { gateway, id } = base; - - /* istanbul ignore if: Extremely hard to reproduce in coverage testing */ - if (gateway.provider === null) throw new Error('Cannot update due to the gateway missing a reference to the provider.'); - if (base.existenceStatus === SettingsExistenceStatus.Exists) { - await gateway.provider.update(gateway.name, id, context.changes); - this._patch(updateObject); - gateway.client.emit('settingsUpdate', base, updateObject, context); - } else { - await gateway.provider.create(gateway.name, id, context.changes); - base.existenceStatus = SettingsExistenceStatus.Exists; - this._patch(updateObject); - gateway.client.emit('settingsCreate', base, updateObject, context); - } - } - - private async _resolveFolder(context: InternalFolderUpdateContext): Promise { - const promises: Promise<[string, unknown]>[] = []; - for (const entry of context.folder.values()) { - if (SchemaEntry.is(entry)) { - promises.push(this._resolveEntry({ - entry, - language: context.language, - guild: context.guild, - extraContext: context.extraContext - }).then(value => [entry.key, value])); - } else { - promises.push(this._resolveFolder({ - folder: entry, - language: context.language, - guild: context.guild, - extraContext: context.extraContext - }).then(value => [entry.key, value])); - } - } - - return Object.fromEntries(await Promise.all(promises)); - } - - private async _resolveEntry(context: SerializerUpdateContext): Promise { - const values = this.get(context.entry.path); - if (typeof values === 'undefined') return undefined; - - if (!context.entry.shouldResolve) return values; - - const { serializer } = context.entry; - if (serializer === null) throw new Error('The serializer was not available during the resolve.'); - if (context.entry.array) { - return (await Promise.all((values as readonly unknown[]) - .map(value => serializer.resolve(value, context)))) - .filter(value => value !== null); - } - - return serializer.resolve(values, context); - } - - private _resetSettingsFolder(changes: SettingsUpdateResult[], schemaFolder: SchemaFolder, language: Language, onlyConfigurable: boolean): void { - let nonConfigurable = 0; - let skipped = 0; - let processed = 0; - - // Recurse to all sub-pieces - for (const entry of schemaFolder.values(true)) { - if (onlyConfigurable && !entry.configurable) { - ++nonConfigurable; - continue; - } - - const previous = (this.base as Settings).get(entry.path); - const next = entry.default; - const equals = entry.array ? - arrayStrictEquals(previous as unknown as readonly unknown[], next as readonly unknown[]) : - previous === entry.default; - - if (equals) { - ++skipped; - } else { - ++processed; - changes.push({ - previous, - next, - entry - }); - } - } - - // If there are no changes, no skipped entries, and it only triggered non-configurable entries, throw. - if (processed === 0 && skipped === 0 && nonConfigurable !== 0) throw language.get('SETTING_GATEWAY_UNCONFIGURABLE_FOLDER'); - } - - private _resetSettingsEntry(changes: SettingsUpdateResult[], schemaEntry: SchemaEntry, language: Language, onlyConfigurable: boolean): void { - if (onlyConfigurable && !schemaEntry.configurable) { - throw language.get('SETTING_GATEWAY_UNCONFIGURABLE_KEY', schemaEntry.key); - } - - const previous = (this.base as Settings).get(schemaEntry.path); - const next = schemaEntry.default; - - const equals = schemaEntry.array ? - arrayStrictEquals(previous as unknown as readonly unknown[], next as readonly unknown[]) : - previous === next; - - if (!equals) { - changes.push({ - previous, - next, - entry: schemaEntry - }); - } - } - - private async _processUpdate(entries: [string, unknown][], options: InternalRawFolderUpdateOptions): Promise { - const { client, schema } = this; - const onlyConfigurable = typeof options.onlyConfigurable === 'undefined' ? false : options.onlyConfigurable; - const arrayAction = typeof options.arrayAction === 'undefined' ? ArrayActions.Auto : options.arrayAction as ArrayActions; - const arrayIndex = typeof options.arrayIndex === 'undefined' ? null : options.arrayIndex; - const guild = client.guilds.resolve(typeof options.guild === 'undefined' ? (this.base as Settings).target : options.guild); - const language = guild?.language ?? client.languages.default; - const extra = options.extraContext; - const internalOptions: InternalSettingsFolderUpdateOptions = { arrayAction, arrayIndex, onlyConfigurable }; - - const promises: Promise[] = []; - for (const [path, value] of entries) { - const entry = schema.get(path); - - // If the key does not exist, throw - if (typeof entry === 'undefined') throw language.get('SETTING_GATEWAY_KEY_NOEXT', path); - if (!SchemaEntry.is(entry)) { - const keys = onlyConfigurable ? - [...entry.values()].filter(val => SchemaEntry.is(val) && val.configurable).map(val => val.key) : - [...entry.keys()]; - throw keys.length > 0 ? - language.get('SETTING_GATEWAY_CHOOSE_KEY', keys) : - language.get('SETTING_GATEWAY_UNCONFIGURABLE_FOLDER'); - } else if (!(entry as SchemaEntry).configurable && onlyConfigurable) { - throw language.get('SETTING_GATEWAY_UNCONFIGURABLE_KEY', path); - } - - promises.push(this._updateSettingsEntry(path, value, { entry: entry as SchemaEntry, language, guild, extraContext: extra }, internalOptions)); - } - - const changes = await Promise.all(promises); - if (changes.length !== 0) await this._save({ changes, guild, language, extraContext: extra }); - return changes; - } - - private async _updateSettingsEntry(key: string, rawValue: unknown, context: SerializerUpdateContext, options: InternalSettingsFolderUpdateOptions): Promise { - const previous = this.get(key); - - // If null or undefined, return the default value instead - if (rawValue === null || typeof rawValue === 'undefined') { - return { previous, next: context.entry.default, entry: context.entry }; - } - - // If the entry doesn't take an array, all the extra steps should be skipped - if (!context.entry.array) { - const values = await this._updateSchemaEntryValue(rawValue, context, true); - return { previous, next: this._resolveNextValue(values, context), entry: context.entry }; - } - - // If the action is overwrite, resolve values accepting null, afterwards filter them out - if (options.arrayAction === ArrayActions.Overwrite) { - return { previous, next: this._resolveNextValue(await this._resolveValues(rawValue, context, true), context), entry: context.entry }; - } - - // The next value depends on whether arrayIndex was set or not - const next = options.arrayIndex === null ? - this._updateSettingsEntryNotIndexed(previous as unknown[], await this._resolveValues(rawValue, context, false), context, options) : - this._updateSettingsEntryAtIndex(previous as unknown[], await this._resolveValues(rawValue, context, options.arrayAction === ArrayActions.Remove), options.arrayIndex, options.arrayAction); - - return { - previous, - next, - entry: context.entry - }; - } - - private _updateSettingsEntryNotIndexed(previous: readonly unknown[], values: readonly unknown[], context: SerializerUpdateContext, options: InternalSettingsFolderUpdateOptions): unknown[] { - const clone = previous.slice(0); - const serializer = context.entry.serializer as Serializer; - if (options.arrayAction === ArrayActions.Auto) { - // Array action auto must add or remove values, depending on their existence - for (const value of values) { - const index = clone.indexOf(value); - if (index === -1) clone.push(value); - else clone.splice(index, 1); - } - } else if (options.arrayAction === ArrayActions.Add) { - // Array action add must add values, throw on existent - for (const value of values) { - if (clone.includes(value)) throw new Error(context.language.get('SETTING_GATEWAY_DUPLICATE_VALUE', context.entry, serializer.stringify(value, context.guild))); - clone.push(value); - } - } else if (options.arrayAction === ArrayActions.Remove) { - // Array action remove must add values, throw on non-existent - for (const value of values) { - const index = clone.indexOf(value); - if (index === -1) throw new Error(context.language.get('SETTING_GATEWAY_MISSING_VALUE', context.entry, serializer.stringify(value, context.guild))); - clone.splice(index, 1); - } - } else { - throw new TypeError(`The ${options.arrayAction} array action is not a valid array action.`); - } - - return clone; - } - - private _updateSettingsEntryAtIndex(previous: readonly unknown[], values: readonly unknown[], arrayIndex: number, arrayAction: ArrayActions | null): unknown[] { - if (arrayIndex < 0 || arrayIndex > previous.length) { - throw new RangeError(`The index ${arrayIndex} is bigger than the current array. It must be a value in the range of 0..${previous.length}.`); - } - - let clone = previous.slice(); - if (arrayAction === ArrayActions.Add) { - clone.splice(arrayIndex, 0, ...values); - } else if (arrayAction === ArrayActions.Remove || values.every(nv => nv === null)) { - clone.splice(arrayIndex, values.length); - } else { - clone.splice(arrayIndex, values.length, ...values); - clone = clone.filter(nv => nv !== null); - } - - return clone; - } - - private async _resolveValues(value: unknown, context: SerializerUpdateContext, acceptNull: boolean): Promise { - return Array.isArray(value) ? - await Promise.all(value.map(val => this._updateSchemaEntryValue(val, context, acceptNull))) : - [await this._updateSchemaEntryValue(value, context, acceptNull)]; - } - - private _resolveNextValue(value: unknown, context: SerializerUpdateContext): unknown { - if (Array.isArray(value)) { - const filtered = value.filter(nv => nv !== null); - return filtered.length === 0 ? context.entry.default : filtered; - } - - return value === null ? context.entry.default : value; - } - - private async _updateSchemaEntryValue(value: unknown, context: SerializerUpdateContext, acceptNull: boolean): Promise { - if (acceptNull && value === null) return null; - - const { serializer } = context.entry; - - /* istanbul ignore if: Extremely hard to reproduce in coverage testing */ - if (serializer === null) throw new TypeError('The serializer was not available during the update.'); - const parsed = await serializer.validate(value, context); - - if (context.entry.filter !== null && context.entry.filter(this.client, parsed, context)) throw context.language.get('SETTING_GATEWAY_INVALID_FILTERED_VALUE', context.entry, value); - return serializer.serialize(parsed); - } - -} - -/** - * The existence status of this settings entry. They're the possible values for {@link Settings#existenceStatus} and - * represents its status in disk. - * @memberof SettingsFolder - */ -export const enum SettingsExistenceStatus { - /** - * The settings has not been synchronized, in this status, any update operation will error. To prevent this, call - * `settings.sync()` first. - */ - Unsynchronized, - /** - * The settings entry exists in disk, any disk operation will be done through an update. - */ - Exists, - /** - * The settings entry does not exist in disk, the first disk operation will be done through a create. Afterwards it - * sets itself to Exists. - */ - NotExists -} - -/** - * The options for {@link SettingsFolder#reset}. - * @memberof SettingsFolder - */ -export interface SettingsFolderResetOptions { - /** - * Whether or not the update should only update those configured with `configurable` set as `true` in the schema. - */ - onlyConfigurable?: boolean; - /** - * The guild to use as the context. It's not required when the settings' target can be resolved into a Guild, e.g. - * a TextChannel, a Role, a GuildMember, or a Guild instance. - */ - guild?: Guild; - /** - * The extra context to be passed through resolvers and events. - */ - extraContext?: unknown; -} - -/** - * The options for {@link SettingsFolder#update} when specifying `arrayAction` as overwrite. - * @memberof SettingsFolder - */ -export interface SettingsFolderUpdateOptionsOverwrite extends SettingsFolderResetOptions { - /** - * The array action, in this case overwrite and not supporting `arrayIndex`. - * @example - * settings.get('words'); - * // -> ['foo', 'bar'] - * - * await settings.update('words', ['hello', 'world'], { arrayAction: 'overwrite' }); - * settings.get('words'); - * // -> ['hello', 'world'] - */ - arrayAction: ArrayActions.Overwrite | 'overwrite'; -} - -/** - * The options for {@link SettingsFolder#update} when not specifying `arrayAction` as overwrite or leaving it default. - * @memberof SettingsFolder - */ -export interface SettingsFolderUpdateOptionsNonOverwrite extends SettingsFolderResetOptions { - /** - * The array action to take, check {@link ArrayActions} for the available actions. - */ - arrayAction?: Exclude | Exclude; - /** - * The index to do the array updates at. This is option is ignored when `arrayAction` is set to overwrite. - */ - arrayIndex?: number | null; -} - -/** - * The options for {@link SettingsFolder#update}. - * @memberof SettingsFolder - */ -export type SettingsFolderUpdateOptions = SettingsFolderUpdateOptionsOverwrite | SettingsFolderUpdateOptionsNonOverwrite; - -/** - * The update context that is passed to the {@link Client#settingsUpdate} and {@link Client#settingsCreate} events. - * @memberof SettingsFolder - */ -export interface SettingsUpdateContext extends Omit { - /** - * The changes done. - */ - readonly changes: SettingsUpdateResults; -} - -/** - * One of the update results from {@link Settings#update}, containing the previous and next values, and the - * {@link SchemaEntry} instance that controlled the value. - * @memberof SettingsFolder - */ -export interface SettingsUpdateResult { - /** - * The value prior to the update. - */ - readonly previous: unknown; - /** - * The serialized value that has been set. - */ - readonly next: unknown; - /** - * The SchemaEntry instance that contains the metadata for this update result. - */ - readonly entry: SchemaEntry; -} - -/** - * The update results from {@link Settings#update}, it contains an array of {@link SettingsUpdateResults results}. - * @memberof SettingsFolder - */ -export type SettingsUpdateResults = readonly SettingsUpdateResult[]; - -/** - * A plain object keyed by the schema's keys and containing serialized values. Nested folders will appear as an object - * of this type. - * @memberof SettingsFolder - */ -export type SettingsFolderJson = Record; - -/** - * The actions to take on Settings#update calls. - * @memberof SettingsFolder - */ -export const enum ArrayActions { - /** - * Override the insert/remove behaviour by pushing new keys to the array to the end or to the specified position. - * @example - * // Push to the end: - * - * settings.get('words'); - * // -> ['foo', 'bar'] - * - * await settings.update('words', ['hello', 'world'], { arrayAction: 'add' }); - * settings.get('words'); - * // -> ['foo', 'bar', 'hello', 'world'] - * - * @example - * // Push to a position: - * - * settings.get('words'); - * // -> ['foo', 'bar'] - * - * await settings.update('words', ['hello', 'world'], { arrayAction: 'add', arrayPosition: 1 }); - * settings.get('words'); - * // -> ['foo', 'hello', 'world', 'bar'] - */ - Add = 'add', - /** - * Override the insert/remove behaviour by removing keys to the array to the end or to the specified position. - * @throws Throws an error when a value does not exist. - * @example - * // Remove: - * - * settings.get('words'); - * // -> ['foo', 'bar'] - * - * await settings.update('words', ['foo'], { arrayAction: 'remove' }); - * settings.get('words'); - * // -> ['bar'] - * - * @example - * // Remove from position: - * - * settings.get('words'); - * // -> ['foo', 'hello', 'world', 'bar'] - * - * await settings.update('words', [null, null], { arrayAction: 'remove', arrayPosition: 1 }); - * settings.get('words'); - * // -> ['foo', 'bar'] - */ - Remove = 'remove', - /** - * Set insert/remove behaviour, this is the value set by default. - * @example - * settings.get('words'); - * // -> ['foo', 'bar'] - * - * await settings.update('words', ['foo', 'hello']); - * settings.get('words'); - * // -> ['bar', 'hello'] - */ - Auto = 'auto', - /** - * Overwrite the array with newly set values. The `arrayIndex` option is ignored when specifying overwrite. - * @example - * settings.get('words'); - * // -> ['foo', 'bar'] - * - * await settings.update('words', ['hello', 'world'], { arrayAction: 'overwrite' }); - * settings.get('words'); - * // -> ['hello', 'world'] - */ - Overwrite = 'overwrite' -} - -export type KeyedObject = Record; -export type ReadonlyKeyedObject = Readonly>>; - -/** - * The actions as a string, done for retrocompatibility. - * @memberof SettingsFolder - * @internal - */ -export type ArrayActionsString = 'add' | 'remove' | 'auto' | 'overwrite'; - -/** - * The update context for resolving the entry, it is used to retrieve the entry setting. - * @memberof SettingsFolder - * @internal - */ -interface InternalFolderUpdateContext extends Omit { - readonly folder: SchemaFolder; -} - -/** - * The internal sanitized options created by {@link SettingsFolder#update} to avoid mutation of the original options. - * @memberof SettingsFolder - * @internal - */ -interface InternalSettingsFolderUpdateOptions { - readonly onlyConfigurable: boolean; - readonly arrayAction: ArrayActions; - readonly arrayIndex: number | null; -} - -/** - * The values {@link SettingsFolder#reset} and {@link SettingsFolder#update} accept. - * @memberof SettingsFolder - */ -type PathOrEntries = string | [string, unknown][] | ReadonlyKeyedObject; - -/** - * The possible values or the options passed. - */ -type ValueOrOptions = unknown | SettingsFolderUpdateOptions; - -/** - * @memberof SettingsFolder - * @internal - */ -type InternalRawFolderUpdateOptions = SettingsFolderUpdateOptions & SettingsFolderUpdateOptionsNonOverwrite; diff --git a/src/lib/settings/gateway/Gateway.ts b/src/lib/settings/gateway/Gateway.ts index 0391a84f2a..7c3a1b0053 100644 --- a/src/lib/settings/gateway/Gateway.ts +++ b/src/lib/settings/gateway/Gateway.ts @@ -1,10 +1,28 @@ import { RequestHandler, IdKeyed } from '@klasa/request-handler'; -import { GatewayStorage, GatewayStorageOptions } from './GatewayStorage'; -import { Settings } from '../Settings'; import { Cache } from '@klasa/cache'; +import { Settings } from '../Settings'; +import { Schema } from '../schema/Schema'; + import type { Client } from '@klasa/core'; +import type { Provider } from 'klasa'; +import type { SchemaEntryJson } from '../schema/SchemaEntry'; + +export class Gateway { + + /** + * The client this gateway was created with. + */ + public readonly client: Client; + + /** + * The name of this gateway. + */ + public readonly name: string; -export class Gateway extends GatewayStorage { + /** + * The schema for this gateway. + */ + public readonly schema: Schema; /** * The cached entries for this Gateway or the external datastore to get the settings from. @@ -18,8 +36,21 @@ export class Gateway extends GatewayStorage { */ public readonly requestHandler: RequestHandler>; - public constructor(client: Client, name: string, options: GatewayStorageOptions = {}) { - super(client, name, options); + /** + * Whether or not this gateway has been initialized. + */ + public ready = false; + + /** + * The provider's name that manages this gateway. + */ + readonly #provider: string; + + public constructor(client: Client, name: string, options: GatewayOptions = {}) { + this.client = client; + this.name = name; + this.schema = options.schema || new Schema(); + this.#provider = options.provider ?? client.options.providers.default; this.cache = (this.name in this.client) && (this.client[this.name as keyof Client] instanceof Map) ? this.client[this.name as keyof Client] as Map : new Cache(); @@ -89,6 +120,49 @@ export class Gateway extends GatewayStorage { return settings; } + /** + * The provider that manages this gateway's persistent data. + */ + public get provider(): Provider | null { + return this.client.providers.get(this.#provider) ?? null; + } + + /** + * Initializes the gateway. + */ + public async init(): Promise { + // Gateways must not initialize twice. + if (this.ready) throw new Error(`The gateway "${this.name}" has already been initialized.`); + + // Check the provider's existence. + const { provider } = this; + if (provider === null) throw new Error(`The gateway "${this.name}" could not find the provider "${this.#provider}".`); + this.ready = true; + + const errors = [...this._initializeSchemaEntries(this.schema)]; + if (errors.length) throw new Error(`[SCHEMA] There is an error with your schema.\n${errors.join('\n')}`); + + // Initialize the defaults + // eslint-disable-next-line dot-notation + this.schema.defaults['_init'](); + + // Init the table + const hasTable = await provider.hasTable(this.name); + if (!hasTable) await provider.createTable(this.name); + + // Add any missing columns (NoSQL providers return empty array) + const columns = await provider.getColumns(this.name); + if (columns.length) { + const promises = []; + for (const entry of this.schema.values()) { + if (!columns.includes(entry.key)) promises.push(provider.addColumn(this.name, entry)); + } + await Promise.all(promises); + } + + await this.sync(); + } + /** * Runs a synchronization task for the gateway. */ @@ -97,6 +171,48 @@ export class Gateway extends GatewayStorage { return this; } + /** + * Get A JSON object containing the schema and the options. + */ + public toJSON(): GatewayJson { + return { + name: this.name, + provider: this.#provider, + schema: this.schema.toJSON() + }; + } + + private *_initializeSchemaEntries(schema: Schema): IterableIterator { + // Iterate over all the schema's values + for (const value of schema.values()) { + // Set the client and check if it is valid, afterwards freeze, + // otherwise delete it from its parent and yield error message + value.client = this.client; + try { + value._check(); + Object.freeze(value); + } catch (error) { + // If errored, delete the key from its parent + value.parent.delete(value.key); + yield error.message; + } + } + + // Set the schema as ready + schema.ready = true; + } + +} + +export interface GatewayOptions { + schema?: Schema; + provider?: string; +} + +export interface GatewayJson { + name: string; + provider: string; + schema: Record; } export interface ProxyMapEntry { diff --git a/src/lib/settings/gateway/GatewayStorage.ts b/src/lib/settings/gateway/GatewayStorage.ts deleted file mode 100644 index 6512fce21d..0000000000 --- a/src/lib/settings/gateway/GatewayStorage.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { Schema, SchemaJson } from '../schema/Schema'; -import type { Provider } from '../../structures/Provider'; -import type { Client } from '@klasa/core'; - -export class GatewayStorage { - - /** - * The client this gateway was created with. - */ - public readonly client: Client; - - /** - * The name of this gateway. - */ - public readonly name: string; - - /** - * The schema for this gateway. - */ - public readonly schema: Schema; - - /** - * Whether or not this gateway has been initialized. - */ - public ready = false; - - /** - * The provider's name that manages this gateway. - */ - private readonly _provider: string; - - public constructor(client: Client, name: string, options: GatewayStorageOptions = {}) { - this.client = client; - this.name = name; - this.schema = options.schema || new Schema(); - this._provider = options.provider || client.options.providers.default || ''; - } - - /** - * The provider that manages this gateway's persistent data. - */ - public get provider(): Provider | null { - return this.client.providers.get(this._provider) ?? null; - } - - /** - * Initializes the gateway. - */ - public async init(): Promise { - // Gateways must not initialize twice. - if (this.ready) throw new Error(`The gateway "${this.name}" has already been initialized.`); - - // Check the provider's existence. - const { provider } = this; - if (provider === null) throw new Error(`The gateway "${this.name}" could not find the provider "${this._provider}".`); - this.ready = true; - - const errors = [...this._checkSchemaFolder(this.schema)]; - if (errors.length) throw new Error(`[SCHEMA] There is an error with your schema.\n${errors.join('\n')}`); - - // Initialize the defaults - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore 2445 - this.schema.defaults._init(this.schema.defaults, this.schema); - - // Init the table - const hasTable = await provider.hasTable(this.name); - if (!hasTable) await provider.createTable(this.name); - - // Add any missing columns (NoSQL providers return empty array) - const columns = await provider.getColumns(this.name); - if (columns.length) { - const promises = []; - for (const entry of this.schema.values(true)) { - if (!columns.includes(entry.path)) promises.push(provider.addColumn(this.name, entry)); - } - await Promise.all(promises); - } - - await this.sync(); - } - - /** - * Runs a synchronization task for the gateway. - */ - public async sync(): Promise { - return this; - } - - /** - * Get A JSON object containing the schema and the options. - */ - public toJSON(): GatewayStorageJson { - return { - name: this.name, - provider: this._provider, - schema: this.schema.toJSON() - }; - } - - private *_checkSchemaFolder(schema: Schema): IterableIterator { - // Iterate over all the schema's values - for (const value of schema.values()) { - if (value instanceof Schema) { - // Check the child's children values - yield* this._checkSchemaFolder(value); - } else { - // Set the client and check if it is valid, afterwards freeze, - // otherwise delete it from its parent and yield error message - value.client = this.client; - try { - value._check(); - Object.freeze(value); - } catch (error) { - // If errored, delete the key from its parent - value.parent.delete(value.key); - yield error.message; - } - } - } - - // Set the schema as ready - schema.ready = true; - } - -} - -export interface GatewayStorageOptions { - schema?: Schema; - provider?: string; -} - -export interface GatewayStorageJson { - name: string; - provider: string; - schema: SchemaJson; -} diff --git a/src/lib/settings/gateway/GatewayDriver.ts b/src/lib/settings/gateway/GatewayStore.ts similarity index 70% rename from src/lib/settings/gateway/GatewayDriver.ts rename to src/lib/settings/gateway/GatewayStore.ts index db5e00edad..aba8d8a5f1 100644 --- a/src/lib/settings/gateway/GatewayDriver.ts +++ b/src/lib/settings/gateway/GatewayStore.ts @@ -1,9 +1,9 @@ import { Cache } from '@klasa/cache'; -import type { GatewayStorage, GatewayStorageJson } from './GatewayStorage'; import type { Client } from '@klasa/core'; +import type { Gateway, GatewayJson } from './Gateway'; -export class GatewayDriver extends Cache { +export class GatewayStore extends Cache { /** * The client this GatewayDriver was created with. @@ -39,9 +39,9 @@ export class GatewayDriver extends Cache { * // register calls can be chained * client * .register(new Gateway(client, 'channels')) - * .register(new GatewayStorage(client, 'moderations', { provider: 'postgres' })); + * .register(new Gateway(client, 'moderations', { provider: 'postgres' })); */ - public register(gateway: GatewayStorage): this { + public register(gateway: Gateway): this { this.set(gateway.name, gateway); return this; } @@ -50,16 +50,16 @@ export class GatewayDriver extends Cache { * Initializes all gateways. */ public async init(): Promise { - await Promise.all([...this.values()].map(gateway => gateway.init())); + await Promise.all(this.map(gateway => gateway.init())); } /** * The gateway driver with all serialized gateways. */ public toJSON(): GatewayDriverJson { - return Object.fromEntries([...this.entries()].map(([key, value]) => [key, value.toJSON()])); + return Object.fromEntries(this.map((value, key) => [key, value.toJSON()])); } } -export type GatewayDriverJson = Record; +export type GatewayDriverJson = Record; diff --git a/src/lib/settings/schema/Schema.ts b/src/lib/settings/schema/Schema.ts index af8462bb03..e3e973c8e1 100644 --- a/src/lib/settings/schema/Schema.ts +++ b/src/lib/settings/schema/Schema.ts @@ -1,24 +1,15 @@ -import { isFunction } from '@klasa/utils'; -import { SettingsFolder } from '../SettingsFolder'; +import { Cache } from '@klasa/cache'; +import { SchemaEntry, SchemaEntryOptions, SchemaEntryJson } from './SchemaEntry'; +import { Settings } from '../Settings'; /* eslint-disable no-dupe-class-members */ -export class Schema extends Map { - - /** - * The base path for this schema. - */ - public readonly path: string; - - /** - * The type of this schema. - */ - public readonly type: 'Folder'; +export class Schema extends Cache { /** * The defaults for this schema. */ - public readonly defaults: SettingsFolder; + public readonly defaults: Settings; /** * Whether or not this instance is ready. @@ -28,13 +19,13 @@ export class Schema extends Map { /** * Constructs the schema */ - public constructor(basePath = '') { + public constructor() { super(); this.ready = false; - this.path = basePath; - this.type = 'Folder'; - this.defaults = new SettingsFolder(this); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + this.defaults = new Settings({ schema: this }, null, ''); } /** @@ -42,9 +33,9 @@ export class Schema extends Map { * @param key The key of the entry to add * @param value The entry to add */ - public set(key: string, value: SchemaFolder | SchemaEntry): this { + public set(key: string, value: SchemaEntry): this { if (this.ready) throw new Error('Cannot modify the schema after being initialized.'); - this.defaults.set(key, value instanceof Schema ? value.defaults : value.default); + this.defaults.set(key, value.default); return super.set(key, value); } @@ -59,7 +50,7 @@ export class Schema extends Map { } /** - * Add a new entry to this folder. + * Add a new entry to the schema. * @param key The name for the key to add * @param type The datatype, will be lowercased in the instance * @param options The options for the entry @@ -74,184 +65,25 @@ export class Schema extends Map { * .add('experience', 'integer', { minimum: 0 }) * .add('level', 'integer', { minimum: 0 }); */ - public add(key: string, type: string, options?: SchemaEntryOptions): this; - /** - * Add a nested folder to this one. - * @param key The name for the folder to add - * @param callback The callback receiving a SchemaFolder instance as a parameter - * @example - * // Create a schema with a key of experience contained in a folder named social: - * // Later access with `schema.get('social.experience');`. - * new Schema() - * .add('social', social => social - * .add('experience', 'integer', { minimum: 0 })); - */ - public add(key: string, callback: SchemaAddCallback): this; - public add(key: string, typeOrCallback: string | SchemaAddCallback, options?: SchemaEntryOptions): this { - let SchemaCtor: typeof SchemaEntry | typeof SchemaFolder; - let type: string; - let callback: SchemaAddCallback | null = null; - if (isFunction(typeOrCallback)) { - type = 'Folder'; - SchemaCtor = SchemaFolder; - callback = typeOrCallback; - } else { - type = typeOrCallback; - SchemaCtor = SchemaEntry; - callback = null; - } - + public add(key: string, type: string, options?: SchemaEntryOptions): this { const previous = super.get(key); if (typeof previous !== 'undefined') { - if (type === 'Folder') { - if (SchemaFolder.is(previous)) { - // Call the callback with the pre-existent Folder - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - callback!(previous); - return this; - } - - // If the type of the new entry is a Folder, the previous must also be a Folder. - throw new Error(`The type for "${key}" conflicts with the previous value, expected type "Folder", got "${previous.type}".`); - } - - // If the type of the new entry is not a Folder, the previous must also not be a Folder. - if (SchemaFolder.is(previous)) { - throw new Error(`The type for "${key}" conflicts with the previous value, expected a non-Folder, got "${previous.type}".`); - } - // Edit the previous key - const schemaEntry = previous as SchemaEntry; + const schemaEntry = previous; schemaEntry.edit({ type, ...options }); this.defaults.set(key, schemaEntry.default); return this; } - const entry = new SchemaCtor(this, key, type, options); - - // eslint-disable-next-line callback-return - if (callback !== null) callback(entry as SchemaFolder); - this.set(key, entry); + this.set(key, new SchemaEntry(this, key, type, options)); return this; } - /** - * Get a children entry from this schema. - * @param path The key or path to get from this schema - * @example - * // Retrieve a key named experience that exists in this folder: - * schema.get('experience'); - * - * @example - * // Retrieve a key named experience contained in a folder named social: - * schema.get('social.experience'); - */ - public get(path: string): SchemaFolder | SchemaEntry | undefined { - const index = path.indexOf('.'); - if (index === -1) return super.get(path); - - const key = path.substring(0, index); - const value = super.get(key); - - // If the returned value was undefined, return undefined - if (typeof value === 'undefined') return undefined; - - // If the returned value is a SchemaFolder, return its result from SchemaFolder#get using remaining string - if (SchemaFolder.is(value)) return value.get(path.substring(index + 1)); - - // Trying to access to a subkey of an entry, return undefined - return undefined; - } - - /** - * Returns a new Iterator object that contains the keys for each element contained in this folder. - * Identical to [Map.keys()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/keys) - * @param recursive Whether the iteration should be recursive - */ - public *keys(recursive = false): IterableIterator { - if (recursive) { - for (const [key, value] of super.entries()) { - if (SchemaFolder.is(value)) yield* value.keys(true); - else yield key; - } - } else { - yield* super.keys(); - } - } - - /** - * Returns a new Iterator object that contains the values for each element contained in this folder and children folders. - * Identical to [Map.values()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/values) - * @param recursive Whether the iteration should be recursive - */ - public values(recursive: true): IterableIterator; - /** - * Returns a new Iterator object that contains the values for each element contained in this folder. - * Identical to [Map.values()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/values) - * @param recursive Whether the iteration should be recursive - */ - public values(recursive?: false): IterableIterator; - public *values(recursive = false): IterableIterator { - if (recursive) { - for (const value of super.values()) { - if (SchemaFolder.is(value)) yield* value.values(true); - else yield value; - } - } else { - yield* super.values(); - } - } - - /** - * Returns a new Iterator object that contains the `[key, value]` pairs for each element contained in this folder and children folders. - * Identical to [Map.entries()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/entries) - * @param recursive Whether the iteration should be recursive - */ - public entries(recursive: true): IterableIterator<[string, SchemaEntry]>; - /** - * Returns a new Iterator object that contains the `[key, value]` pairs for each element contained in this folder. - * Identical to [Map.entries()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/entries) - * @param recursive Whether the iteration should be recursive - */ - public entries(recursive?: false): IterableIterator<[string, SchemaFolder | SchemaEntry]>; - public *entries(recursive = false): IterableIterator<[string, SchemaFolder | SchemaEntry]> { - if (recursive) { - for (const [key, value] of super.entries()) { - if (SchemaFolder.is(value)) yield* value.entries(true); - else yield [key, value]; - } - } else { - yield* super.entries(); - } - } - /** * Returns an object literal composed of all children serialized recursively. */ - public toJSON(): SchemaJson { - return Object.fromEntries([...this.entries()].map(([key, value]) => [key, value.toJSON()])); - } - - /** - * Check whether or not the value is a SchemaFolder. - * @since 0.6.0 - * @param value The value to check. - */ - public static is(value: Schema | SchemaEntry): value is SchemaFolder { - return value.type === 'Folder'; + public toJSON(): Record { + return Object.fromEntries(this.map((value, key) => [key, value.toJSON()])); } } - -export interface SchemaAddCallback { - (folder: SchemaFolder): unknown; -} - -// Those are interfaces because they reference themselves, resulting on a compiler error. The other is for consistency. -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface SchemaFolderJson extends Record { } -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface SchemaJson extends Record { } - -import { SchemaFolder } from './SchemaFolder'; -import { SchemaEntry, SchemaEntryOptions, SchemaEntryJson } from './SchemaEntry'; diff --git a/src/lib/settings/schema/SchemaEntry.ts b/src/lib/settings/schema/SchemaEntry.ts index 3d8dc184ac..73d6129571 100644 --- a/src/lib/settings/schema/SchemaEntry.ts +++ b/src/lib/settings/schema/SchemaEntry.ts @@ -1,9 +1,8 @@ import { isNumber, isFunction } from '@klasa/utils'; import type { Client } from '@klasa/core'; -import type { Schema } from './Schema'; -import type { SchemaFolder } from './SchemaFolder'; import type { Serializer, SerializerUpdateContext } from '../../structures/Serializer'; +import type { Schema } from './Schema'; export class SchemaEntry { @@ -15,18 +14,13 @@ export class SchemaEntry { /** * The schema that manages this instance. */ - public readonly parent: Schema | SchemaFolder; + public readonly parent: Schema; /** * The key of this entry relative to its parent. */ public readonly key: string; - /** - * The absolute key of this entry. - */ - public readonly path: string; - /** * The type of data this entry manages. */ @@ -57,11 +51,6 @@ export class SchemaEntry { */ public inclusive: boolean; - /** - * Whether or not this entry should be configurable by the configuration command. - */ - public configurable: boolean; - /** * The filter to use for this entry when resolving. */ @@ -72,18 +61,16 @@ export class SchemaEntry { */ public shouldResolve: boolean; - public constructor(parent: Schema | SchemaFolder, key: string, type: string, options: SchemaEntryOptions = {}) { + public constructor(parent: Schema, key: string, type: string, options: SchemaEntryOptions = {}) { this.client = null; this.parent = parent; this.key = key; - this.path = this.parent.path.length === 0 ? this.key : `${this.parent.path}.${this.key}`; this.type = type.toLowerCase(); this.array = typeof options.array === 'undefined' ? typeof options.default === 'undefined' ? false : Array.isArray(options.default) : options.array; this.default = typeof options.default === 'undefined' ? this._generateDefaultValue() : options.default; this.minimum = typeof options.minimum === 'undefined' ? null : options.minimum; this.maximum = typeof options.maximum === 'undefined' ? null : options.maximum; this.inclusive = typeof options.inclusive === 'undefined' ? false : options.inclusive; - this.configurable = typeof options.configurable === 'undefined' ? this.type !== 'any' : options.configurable; this.filter = typeof options.filter === 'undefined' ? null : options.filter; this.shouldResolve = typeof options.resolve === 'undefined' ? true : options.resolve; } @@ -103,7 +90,6 @@ export class SchemaEntry { public edit(options: SchemaEntryEditOptions = {}): this { if (typeof options.type === 'string') this.type = options.type.toLowerCase(); if (typeof options.array !== 'undefined') this.array = options.array; - if (typeof options.configurable !== 'undefined') this.configurable = options.configurable; if (typeof options.default !== 'undefined') this.default = options.default; if (typeof options.filter !== 'undefined') this.filter = options.filter; if (typeof options.inclusive !== 'undefined') this.inclusive = options.inclusive; @@ -125,7 +111,6 @@ export class SchemaEntry { return { type: this.type, array: this.array, - configurable: this.configurable, default: this.default, inclusive: this.inclusive, maximum: this.maximum, @@ -142,30 +127,27 @@ export class SchemaEntry { if (this.client === null) throw new Error('Cannot retrieve serializers from non-initialized SchemaEntry.'); // Check type - if (typeof this.type !== 'string') throw new TypeError(`[KEY] ${this.path} - Parameter 'type' must be a string.`); - if (!this.client.serializers.has(this.type)) throw new TypeError(`[KEY] ${this.path} - '${this.type}' is not a valid type.`); + if (typeof this.type !== 'string') throw new TypeError(`[KEY] ${this.key} - Parameter 'type' must be a string.`); + if (!this.client.serializers.has(this.type)) throw new TypeError(`[KEY] ${this.key} - '${this.type}' is not a valid type.`); // Check array - if (typeof this.array !== 'boolean') throw new TypeError(`[KEY] ${this.path} - Parameter 'array' must be a boolean.`); - - // Check configurable - if (typeof this.configurable !== 'boolean') throw new TypeError(`[KEY] ${this.path} - Parameter 'configurable' must be a boolean.`); + if (typeof this.array !== 'boolean') throw new TypeError(`[KEY] ${this.key} - Parameter 'array' must be a boolean.`); // Check limits - if (this.minimum !== null && !isNumber(this.minimum)) throw new TypeError(`[KEY] ${this.path} - Parameter 'minimum' must be a number or null.`); - if (this.maximum !== null && !isNumber(this.maximum)) throw new TypeError(`[KEY] ${this.path} - Parameter 'maximum' must be a number or null.`); + if (this.minimum !== null && !isNumber(this.minimum)) throw new TypeError(`[KEY] ${this.key} - Parameter 'minimum' must be a number or null.`); + if (this.maximum !== null && !isNumber(this.maximum)) throw new TypeError(`[KEY] ${this.key} - Parameter 'maximum' must be a number or null.`); if (this.minimum !== null && this.maximum !== null && this.minimum > this.maximum) { - throw new TypeError(`[KEY] ${this.path} - Parameter 'minimum' must contain a value lower than the parameter 'maximum'.`); + throw new TypeError(`[KEY] ${this.key} - Parameter 'minimum' must contain a value lower than the parameter 'maximum'.`); } // Check filter - if (this.filter !== null && !isFunction(this.filter)) throw new TypeError(`[KEY] ${this.path} - Parameter 'filter' must be a function`); + if (this.filter !== null && !isFunction(this.filter)) throw new TypeError(`[KEY] ${this.key} - Parameter 'filter' must be a function`); // Check default if (this.array) { - if (!Array.isArray(this.default)) throw new TypeError(`[DEFAULT] ${this.path} - Default key must be an array if the key stores an array.`); + if (!Array.isArray(this.default)) throw new TypeError(`[DEFAULT] ${this.key} - Default key must be an array if the key stores an array.`); } else if (this.default !== null) { - if (['boolean', 'string'].includes(this.type) && typeof this.default !== this.type) throw new TypeError(`[DEFAULT] ${this.path} - Default key must be a ${this.type}.`); + if (['boolean', 'string'].includes(this.type) && typeof this.default !== this.type) throw new TypeError(`[DEFAULT] ${this.key} - Default key must be a ${this.type}.`); } } @@ -178,20 +160,10 @@ export class SchemaEntry { return null; } - /** - * Check whether or not the value is a SchemaEntry. - * @since 0.6.0 - * @param value The value to check. - */ - public static is(value: Schema | SchemaEntry): value is SchemaEntry { - return value.type !== 'Folder'; - } - } export interface SchemaEntryOptions { array?: boolean; - configurable?: boolean; default?: unknown; filter?: SchemaEntryFilterFunction | null; inclusive?: boolean; diff --git a/src/lib/settings/schema/SchemaFolder.ts b/src/lib/settings/schema/SchemaFolder.ts deleted file mode 100644 index 9c7a0d0cbf..0000000000 --- a/src/lib/settings/schema/SchemaFolder.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Schema } from './Schema'; - -export class SchemaFolder extends Schema { - - /** - * The schema that manages this instance - */ - public readonly parent: Schema | SchemaFolder; - - /** - * The key of this entry relative to its parent - */ - public readonly key: string; - - /** - * Constructs a SchemaFolder instance. - * @param parent The schema that manages this instance - * @param key This folder's key name - */ - public constructor(parent: Schema, key: string) { - super(parent.path.length === 0 ? key : `${parent.path}.${key}`); - this.parent = parent; - this.key = key; - } - -} diff --git a/src/lib/structures/Provider.ts b/src/lib/structures/Provider.ts index 0936d6c089..a173bdee85 100644 --- a/src/lib/structures/Provider.ts +++ b/src/lib/structures/Provider.ts @@ -1,9 +1,7 @@ import { Piece } from '@klasa/core'; -import { mergeObjects, makeObject } from '@klasa/utils'; -import type { SettingsUpdateResults } from '../settings/SettingsFolder'; -import type { SchemaFolder } from '../settings/schema/SchemaFolder'; import type { SchemaEntry } from '../settings/schema/SchemaEntry'; +import type { SettingsUpdateResults } from '../settings/Settings'; export abstract class Provider extends Piece { @@ -95,11 +93,11 @@ export abstract class Provider extends Piece { /** * The addColumn method which inserts/creates a new table to the database. * @param table The table to check against - * @param entry The SchemaFolder or SchemaEntry added to the schema + * @param entry The SchemaEntry added to the schema */ /* istanbul ignore next: Implemented in SQLProvider, always unused. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - public async addColumn(_table: string, _entry: SchemaFolder | SchemaEntry): Promise { + public async addColumn(_table: string, _entry: SchemaEntry): Promise { // Reserved for SQL databases return undefined; } @@ -147,9 +145,7 @@ export abstract class Provider extends Piece { protected parseUpdateInput(changes: unknown | SettingsUpdateResults): Record { if (!Array.isArray(changes)) return changes as Record; - const updated: Record = {}; - for (const change of changes) mergeObjects(updated, makeObject(change.entry.path, change.next)); - return updated; + return Object.fromEntries(changes.map(change => [change.entry.key, change.next])); } } diff --git a/src/lib/structures/SQLProvider.ts b/src/lib/structures/SQLProvider.ts index 903bb0c774..10f3d8510b 100644 --- a/src/lib/structures/SQLProvider.ts +++ b/src/lib/structures/SQLProvider.ts @@ -1,10 +1,9 @@ import { objectToTuples } from '@klasa/utils'; import { Provider } from './Provider'; -import type { SchemaFolder } from '../settings/schema/SchemaFolder'; import type { SchemaEntry } from '../settings/schema/SchemaEntry'; -import type { SettingsUpdateResults } from '../settings/SettingsFolder'; import type { QueryBuilder } from '../util/QueryBuilder'; +import type { SettingsUpdateResults } from '../settings/Settings'; export abstract class SQLProvider extends Provider { @@ -93,9 +92,9 @@ export abstract class SQLProvider extends Provider { /** * The addColumn method which inserts/creates a new table to the database. * @param table The table to check against - * @param entry The SchemaFolder or SchemaEntry added to the schema + * @param entry The SchemaEntry added to the schema */ - public abstract addColumn(table: string, entry: SchemaFolder | SchemaEntry): Promise; + public abstract addColumn(table: string, entry: SchemaEntry): Promise; /** * The removeColumn method which inserts/creates a new table to the database. @@ -139,7 +138,7 @@ export abstract class SQLProvider extends Provider { if (Array.isArray(changes)) { for (const change of changes as SettingsUpdateResults) { - keys.push(change.entry.path); + keys.push(change.entry.key); values.push(change.next); } } else { diff --git a/src/lib/util/QueryBuilder.ts b/src/lib/util/QueryBuilder.ts index d5777e7e49..0536d2c533 100644 --- a/src/lib/util/QueryBuilder.ts +++ b/src/lib/util/QueryBuilder.ts @@ -104,7 +104,7 @@ export class QueryBuilder extends Map { const parsedDefault = this.serialize(schemaEntry.default, schemaEntry, datatype); const type = typeof datatype.type === 'function' ? datatype.type(schemaEntry) : datatype.type; const parsedDatatype = schemaEntry.array ? datatype.array(type) : type; - return datatype.formatDatatype(schemaEntry.path, parsedDatatype, parsedDefault); + return datatype.formatDatatype(schemaEntry.key, parsedDatatype, parsedDefault); } /** diff --git a/src/lib/util/constants.ts b/src/lib/util/constants.ts index 9a9d7e14eb..3f334b8bc1 100644 --- a/src/lib/util/constants.ts +++ b/src/lib/util/constants.ts @@ -18,7 +18,6 @@ export const KlasaClientDefaults = mergeDefault(ClientOptionsDefaults, { messageLifetime: 1800, noPrefixDM: false, prefix: null, - regexPrefix: null, slowmode: 0, slowmodeAggressive: false, typing: false, diff --git a/test/Gateway.ts b/test/Gateway.ts index 00bf120efb..a3b7cfed21 100644 --- a/test/Gateway.ts +++ b/test/Gateway.ts @@ -5,29 +5,104 @@ import { UserStore } from '@klasa/core'; import { createClient } from './lib/SettingsClient'; import { Gateway, - GatewayStorage, KlasaClient, Provider, Settings, - SettingsExistenceStatus + SettingsExistenceStatus, + Schema } from '../src'; const ava = unknownTest as TestInterface<{ - client: KlasaClient + client: KlasaClient, + schema: Schema }>; ava.beforeEach(async (test): Promise => { test.context = { - client: createClient() + client: createClient(), + schema: new Schema() }; }); ava('Gateway Properties', (test): void => { - const gateway = new Gateway(test.context.client, 'test'); + test.plan(9); - test.true(gateway instanceof GatewayStorage); + const gateway = new Gateway(test.context.client, 'MockGateway'); + test.is(gateway.client, test.context.client); + test.is(gateway.name, 'MockGateway'); + test.is(gateway.provider, test.context.client.providers.get('json')); + test.is(gateway.ready, false); test.true(gateway.requestHandler instanceof RequestHandler); test.true(gateway.requestHandler.available); + + test.true(gateway.schema instanceof Schema); + test.is(gateway.schema.size, 0); + test.deepEqual(gateway.toJSON(), { + name: 'MockGateway', + provider: 'json', + schema: {} + }); +}); + +ava('Gateway#schema', (test): void => { + const gateway = new Gateway(test.context.client, 'MockGateway', { schema: test.context.schema }); + test.is(gateway.schema, test.context.schema); +}); + +ava('Gateway#init', async (test): Promise => { + test.plan(7); + + const gateway = new Gateway(test.context.client, 'MockGateway', { schema: test.context.schema }); + const provider = gateway.provider as Provider; + + // Uninitialized gateway + test.false(gateway.ready); + test.false(gateway.schema.ready); + test.false(await provider.hasTable(gateway.name)); + + // Initialize gateway + test.is(await gateway.init(), undefined); + + // Initialized gateway + test.true(gateway.ready); + test.true(gateway.schema.ready); + test.true(await provider.hasTable(gateway.name)); +}); + +ava('Gateway#init (No Provider)', async (test): Promise => { + test.plan(2); + + const gateway = new Gateway(test.context.client, 'MockGateway', { schema: test.context.schema }); + test.context.client.providers.clear(); + + test.is(gateway.provider, null); + await test.throwsAsync(() => gateway.init(), { message: 'The gateway "MockGateway" could not find the provider "json".' }); +}); + +ava('Gateway#init (Ready)', async (test): Promise => { + const gateway = new Gateway(test.context.client, 'MockGateway', { schema: test.context.schema }); + await gateway.init(); + await test.throwsAsync(() => gateway.init(), { message: 'The gateway "MockGateway" has already been initialized.' }); +}); + +ava('Gateway#init (Broken Schema)', async (test): Promise => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + test.context.schema.add('key', 'String', { array: null }); + + const gateway = new Gateway(test.context.client, 'MockGateway', { schema: test.context.schema }); + + await test.throwsAsync(() => gateway.init(), { + message: [ + '[SCHEMA] There is an error with your schema.', + "[KEY] key - Parameter 'array' must be a boolean." + ].join('\n') + }); +}); + +ava('Gateway#sync', async (test): Promise => { + const gateway = new Gateway(test.context.client, 'MockGateway', { schema: test.context.schema }); + test.is(await gateway.sync(), gateway); }); ava('Gateway (Reverse Proxy Sync)', (test): void => { diff --git a/test/GatewayStorage.ts b/test/GatewayStorage.ts deleted file mode 100644 index 64e3b83b02..0000000000 --- a/test/GatewayStorage.ts +++ /dev/null @@ -1,101 +0,0 @@ -import unknownTest, { TestInterface } from 'ava'; -import { createClient } from './lib/SettingsClient'; -import { - GatewayStorage, - Schema, - KlasaClient, - Provider -} from '../src'; - -const ava = unknownTest as TestInterface<{ - client: KlasaClient, - schema: Schema -}>; - -ava.beforeEach(async (test): Promise => { - test.context = { - client: createClient(), - schema: new Schema() - }; -}); - -ava('GatewayStorage Properties', (test): void => { - test.plan(9); - - const gateway = new GatewayStorage(test.context.client, 'MockGateway'); - test.is(gateway.client, test.context.client); - test.is(gateway.name, 'MockGateway'); - test.is(gateway.provider, test.context.client.providers.get('json')); - test.is(gateway.ready, false); - - test.true(gateway.schema instanceof Schema); - test.is(gateway.schema.size, 0); - test.is(gateway.schema.path, ''); - test.is(gateway.schema.type, 'Folder'); - test.deepEqual(gateway.toJSON(), { - name: 'MockGateway', - provider: 'json', - schema: {} - }); -}); - -ava('GatewayStorage#schema', (test): void => { - const gateway = new GatewayStorage(test.context.client, 'MockGateway', { schema: test.context.schema }); - test.is(gateway.schema, test.context.schema); -}); - -ava('GatewayStorage#init', async (test): Promise => { - test.plan(7); - - const gateway = new GatewayStorage(test.context.client, 'MockGateway', { schema: test.context.schema }); - const provider = gateway.provider as Provider; - - // Uninitialized gateway - test.false(gateway.ready); - test.false(gateway.schema.ready); - test.false(await provider.hasTable(gateway.name)); - - // Initialize gateway - test.is(await gateway.init(), undefined); - - // Initialized gateway - test.true(gateway.ready); - test.true(gateway.schema.ready); - test.true(await provider.hasTable(gateway.name)); -}); - -ava('GatewayStorage#init (No Provider)', async (test): Promise => { - test.plan(2); - - const gateway = new GatewayStorage(test.context.client, 'MockGateway', { schema: test.context.schema }); - test.context.client.providers.clear(); - - test.is(gateway.provider, null); - await test.throwsAsync(() => gateway.init(), { message: 'The gateway "MockGateway" could not find the provider "json".' }); -}); - -ava('GatewayStorage#init (Ready)', async (test): Promise => { - const gateway = new GatewayStorage(test.context.client, 'MockGateway', { schema: test.context.schema }); - await gateway.init(); - await test.throwsAsync(() => gateway.init(), { message: 'The gateway "MockGateway" has already been initialized.' }); -}); - -ava('GatewayStorage#init (Broken Schema)', async (test): Promise => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - test.context.schema.add('key', 'String', { array: null }); - - const gateway = new GatewayStorage(test.context.client, 'MockGateway', { schema: test.context.schema }); - - await test.throwsAsync(() => gateway.init(), { message: [ - '[SCHEMA] There is an error with your schema.', - "[KEY] key - Parameter 'array' must be a boolean." - ].join('\n') }); -}); - -ava('GatewayStorage#sync', async (test): Promise => { - const gateway = new GatewayStorage(test.context.client, 'MockGateway', { schema: test.context.schema }); - test.is(await gateway.sync(), gateway); -}); - -// TODO(kyranet): Test SQL mode as well diff --git a/test/GatewayDriver.ts b/test/GatewayStore.ts similarity index 97% rename from test/GatewayDriver.ts rename to test/GatewayStore.ts index 6080ddb9c0..8414d1a711 100644 --- a/test/GatewayDriver.ts +++ b/test/GatewayStore.ts @@ -3,7 +3,7 @@ import { Cache } from '@klasa/cache'; import { createClient } from './lib/SettingsClient'; import { Gateway, - GatewayDriver, + GatewayStore, KlasaClient } from '../src'; @@ -21,7 +21,7 @@ ava('GatewayDriver Properties', (test): void => { test.plan(3); const { client } = test.context; - const gatewayDriver = new GatewayDriver(client); + const gatewayDriver = new GatewayStore(client); test.true(gatewayDriver instanceof Cache); test.is(gatewayDriver.client, client); diff --git a/test/Schema.ts b/test/Schema.ts index bf4624da35..b453fdb235 100644 --- a/test/Schema.ts +++ b/test/Schema.ts @@ -1,44 +1,31 @@ import ava from 'ava'; -import { - Schema, - SchemaEntry, - SchemaFolder, - SettingsFolder -} from '../src'; +import { Schema, SchemaEntry, Settings } from '../src'; ava('Schema Properties', (test): void => { - test.plan(13); + test.plan(8); const schema = new Schema(); - test.is(schema.path, ''); - test.is(schema.type, 'Folder'); - test.true(schema instanceof Map); test.is(schema.size, 0); - test.true(schema.defaults instanceof SettingsFolder); + test.true(schema.defaults instanceof Settings); test.is(schema.defaults.size, 0); test.deepEqual(schema.toJSON(), {}); test.deepEqual([...schema.keys()], []); - test.deepEqual([...schema.keys(true)], []); test.deepEqual([...schema.values()], []); - test.deepEqual([...schema.values(true)], []); test.deepEqual([...schema.entries()], []); - test.deepEqual([...schema.entries(true)], []); }); ava('Schema#add', (test): void => { - test.plan(20); + test.plan(14); const schema = new Schema(); test.is(schema.add('test', 'String'), schema); test.true(schema instanceof Schema, '"add" method must be chainable.'); - test.is(schema.path, ''); - test.is(schema.type, 'Folder'); test.is(schema.defaults.size, 1); const settingsEntry = schema.defaults.get('test'); @@ -49,11 +36,9 @@ ava('Schema#add', (test): void => { test.true(schemaEntry instanceof SchemaEntry); test.is(schemaEntry.key, 'test'); test.is(schemaEntry.parent, schema); - test.is(schemaEntry.path, 'test'); test.is(schemaEntry.type, 'string'); test.deepEqual(schemaEntry.toJSON(), { array: false, - configurable: true, default: null, inclusive: false, maximum: null, @@ -65,7 +50,6 @@ ava('Schema#add', (test): void => { test.deepEqual(schema.toJSON(), { test: { array: false, - configurable: true, default: null, inclusive: false, maximum: null, @@ -76,75 +60,32 @@ ava('Schema#add', (test): void => { }); test.deepEqual([...schema.keys()], ['test']); - test.deepEqual([...schema.keys(true)], ['test']); test.deepEqual([...schema.values()], [schemaEntry]); - test.deepEqual([...schema.values(true)], [schemaEntry]); test.deepEqual([...schema.entries()], [['test', schemaEntry]]); - test.deepEqual([...schema.entries(true)], [['test', schemaEntry]]); }); ava('Schema#add (Edit | Entry To Entry)', (test): void => { test.plan(5); - const schema = new Schema().add('subkey', 'String'); - test.is(schema.defaults.get('subkey'), null); - test.is((schema.get('subkey') as SchemaEntry).default, null); - - test.is(schema.add('subkey', 'String', { default: 'Hello' }), schema); - test.is(schema.defaults.get('subkey'), 'Hello'); - test.is((schema.get('subkey') as SchemaEntry).default, 'Hello'); -}); - -ava('Schema#add (Edit | Entry To Folder)', (test): void => { - const schema = new Schema().add('subkey', folder => folder.add('nested', 'String')); - test.throws(() => schema.add('subkey', 'String'), { message: 'The type for "subkey" conflicts with the previous value, expected a non-Folder, got "Folder".' }); -}); - -ava('Schema#add (Edit | Folder To Entry)', (test): void => { - const schema = new Schema().add('subkey', 'String'); - test.throws(() => schema.add('subkey', folder => folder), { message: 'The type for "subkey" conflicts with the previous value, expected type "Folder", got "string".' }); -}); - -ava('Schema#add (Edit | Folder To Folder)', (test): void => { - test.plan(5); - - const schema = new Schema().add('subkey', folder => folder.add('nested', 'String')); - test.is(schema.add('subkey', folder => folder.add('another', 'Number')), schema); - test.is(schema.size, 1); + const schema = new Schema().add('key', 'String'); + test.is(schema.defaults.get('key'), null); + test.is((schema.get('key') as SchemaEntry).default, null); - const inner = schema.get('subkey') as SchemaFolder; - test.is(inner.size, 2); - test.truthy(inner.get('nested')); - test.truthy(inner.get('another')); + test.is(schema.add('key', 'String', { default: 'Hello' }), schema); + test.is(schema.defaults.get('key'), 'Hello'); + test.is((schema.get('key') as SchemaEntry).default, 'Hello'); }); ava('Schema#add (Ready)', (test): void => { const schema = new Schema(); schema.ready = true; - test.throws(() => schema.add('subkey', 'String'), { message: 'Cannot modify the schema after being initialized.' }); + test.throws(() => schema.add('key', 'String'), { message: 'Cannot modify the schema after being initialized.' }); }); ava('Schema#get (Entry)', (test): void => { - const schema = new Schema().add('subkey', 'String'); - test.true(schema.get('subkey') instanceof SchemaEntry); -}); - -ava('Schema#get (Folder)', (test): void => { - const schema = new Schema().add('subkey', folder => folder); - test.true(schema.get('subkey') instanceof SchemaFolder); -}); - -ava('Schema#get (Folder Nested)', (test): void => { - const schema = new Schema().add('subkey', folder => folder.add('nested', 'String')); - test.true(schema.get('subkey.nested') instanceof SchemaEntry); -}); - -ava('Schema#get (Folder Double Nested)', (test): void => { - const schema = new Schema().add('subkey', folder => folder - .add('nested', subFolder => subFolder - .add('double', 'String'))); - test.true(schema.get('subkey.nested.double') instanceof SchemaEntry); + const schema = new Schema().add('key', 'String'); + test.true(schema.get('key') instanceof SchemaEntry); }); ava('Schema#get (Folder From Entry)', (test): void => { @@ -152,134 +93,24 @@ ava('Schema#get (Folder From Entry)', (test): void => { test.is(schema.get('key.non.existent.path'), undefined); }); -ava('SchemaFolder (Empty)', (test): void => { - test.plan(22); - - const schema = new Schema() - .add('test', () => { - // noop - }); - - test.true(schema instanceof Schema, '"add" method must be chainable.'); - test.is(schema.path, ''); - test.is(schema.type, 'Folder'); - - test.is(schema.defaults.size, 1); - const settingsFolder = schema.defaults.get('test') as SettingsFolder; - test.true(settingsFolder instanceof SettingsFolder); - test.is(settingsFolder.size, 0); - - test.is(schema.size, 1); - const schemaFolder = schema.get('test') as SchemaFolder; - test.true(schemaFolder instanceof SchemaFolder); - test.is(schemaFolder.size, 0); - test.is(schemaFolder.key, 'test'); - test.is(schemaFolder.parent, schema); - test.is(schemaFolder.path, 'test'); - test.is(schemaFolder.type, 'Folder'); - test.true(schemaFolder.defaults instanceof SettingsFolder); - test.is(schemaFolder.defaults.size, 0); - - test.deepEqual(schema.toJSON(), { - test: {} - }); - - test.deepEqual([...schema.keys()], ['test']); - test.deepEqual([...schema.keys(true)], []); - test.deepEqual([...schema.values()], [schemaFolder]); - test.deepEqual([...schema.values(true)], []); - test.deepEqual([...schema.entries()], [['test', schemaFolder]]); - test.deepEqual([...schema.entries(true)], []); -}); - -ava('SchemaFolder (Filled)', (test): void => { - test.plan(29); - - const schema = new Schema() - .add('someFolder', folder => folder - .add('someKey', 'TextChannel')); - - test.is(schema.defaults.size, 1); - const settingsFolder = schema.defaults.get('someFolder') as SettingsFolder; - test.true(settingsFolder instanceof SettingsFolder); - test.is(settingsFolder.size, 1); - test.is(settingsFolder.get('someKey'), null); - test.is(schema.defaults.get('someFolder.someKey'), null); - - test.is(schema.size, 1); - const schemaFolder = schema.get('someFolder') as SchemaFolder; - test.true(schemaFolder instanceof SchemaFolder); - test.is(schemaFolder.size, 1); - test.is(schemaFolder.key, 'someFolder'); - test.is(schemaFolder.parent, schema); - test.is(schemaFolder.path, 'someFolder'); - test.is(schemaFolder.type, 'Folder'); - test.true(schemaFolder.defaults instanceof SettingsFolder); - test.is(schemaFolder.defaults.size, 1); - - const innerSettingsFolder = schemaFolder.defaults.get('someKey'); - test.is(innerSettingsFolder, null); - - const schemaEntry = schemaFolder.get('someKey') as SchemaEntry; - test.true(schemaEntry instanceof SchemaEntry); - test.is(schemaEntry.key, 'someKey'); - test.is(schemaEntry.parent, schemaFolder); - test.is(schemaEntry.path, 'someFolder.someKey'); - test.is(schemaEntry.type, 'textchannel'); - test.deepEqual(schemaEntry.toJSON(), { - array: false, - configurable: true, - default: null, - inclusive: false, - maximum: null, - minimum: null, - resolve: true, - type: 'textchannel' - }); - - test.is(schema.get('someFolder.someKey'), schemaFolder.get('someKey')); - - test.deepEqual(schema.toJSON(), { - someFolder: { - someKey: { - array: false, - configurable: true, - default: null, - inclusive: false, - maximum: null, - minimum: null, - resolve: true, - type: 'textchannel' - } - } - }); - - test.deepEqual([...schema.keys()], ['someFolder']); - test.deepEqual([...schema.keys(true)], ['someKey']); - test.deepEqual([...schema.values()], [schemaFolder]); - test.deepEqual([...schema.values(true)], [schemaEntry]); - test.deepEqual([...schema.entries()], [['someFolder', schemaFolder]]); - test.deepEqual([...schema.entries(true)], [['someKey', schemaEntry]]); -}); - ava('Schema#delete', (test): void => { test.plan(3); - const schema = new Schema().add('subkey', 'String'); - test.is(schema.defaults.get('subkey'), null); + const schema = new Schema().add('key', 'String'); + test.is(schema.defaults.get('key'), null); - test.true(schema.delete('subkey')); - test.is(schema.defaults.get('subkey'), undefined); + test.true(schema.delete('key')); + test.is(schema.defaults.get('key'), undefined); }); ava('Schema#delete (Not Exists)', (test): void => { const schema = new Schema(); - test.false(schema.delete('subkey')); + test.false(schema.delete('key')); }); ava('Schema#delete (Ready)', (test): void => { const schema = new Schema(); schema.ready = true; - test.throws(() => schema.delete('subkey'), { message: 'Cannot modify the schema after being initialized.' }); + test.throws(() => schema.delete('key'), { message: 'Cannot modify the schema after being initialized.' }); }); diff --git a/test/SchemaEntry.ts b/test/SchemaEntry.ts index b6565d6813..d354863e46 100644 --- a/test/SchemaEntry.ts +++ b/test/SchemaEntry.ts @@ -7,18 +7,16 @@ import { } from '../src'; ava('SchemaEntry Properties', (test): void => { - test.plan(15); + test.plan(13); const schema = new Schema(); const schemaEntry = new SchemaEntry(schema, 'test', 'textchannel'); test.is(schemaEntry.client, null); test.is(schemaEntry.key, 'test'); - test.is(schemaEntry.path, 'test'); test.is(schemaEntry.type, 'textchannel'); test.is(schemaEntry.parent, schema); test.is(schemaEntry.array, false); - test.is(schemaEntry.configurable, true); test.is(schemaEntry.default, null); test.is(schemaEntry.filter, null); test.is(schemaEntry.inclusive, false); @@ -28,7 +26,6 @@ ava('SchemaEntry Properties', (test): void => { test.throws(() => schemaEntry.serializer, { instanceOf: Error }); test.deepEqual(schemaEntry.toJSON(), { array: false, - configurable: true, default: null, inclusive: false, maximum: null, @@ -39,12 +36,11 @@ ava('SchemaEntry Properties', (test): void => { }); ava('SchemaEntry#edit', (test): void => { - test.plan(8); + test.plan(7); const schema = new Schema(); const schemaEntry = new SchemaEntry(schema, 'test', 'textchannel', { array: false, - configurable: false, default: 1, filter: (): boolean => true, inclusive: false, @@ -56,7 +52,6 @@ ava('SchemaEntry#edit', (test): void => { schemaEntry.edit({ type: 'guild', array: true, - configurable: true, default: [1], filter: null, inclusive: true, @@ -67,7 +62,6 @@ ava('SchemaEntry#edit', (test): void => { test.is(schemaEntry.type, 'guild'); test.is(schemaEntry.array, true); - test.is(schemaEntry.configurable, true); test.is(schemaEntry.filter, null); test.is(schemaEntry.shouldResolve, true); test.is(schemaEntry.maximum, 200); @@ -76,7 +70,7 @@ ava('SchemaEntry#edit', (test): void => { }); ava('SchemaEntry#check', (test): void => { - test.plan(11); + test.plan(10); const client = createClient(); const schema = new Schema(); @@ -108,11 +102,6 @@ ava('SchemaEntry#check', (test): void => { test.throws(throwsCheck, { message: /Parameter 'array' must be a boolean/i }); schemaEntry.array = false; - // @ts-expect-error - schemaEntry.configurable = 'true'; - test.throws(throwsCheck, { message: /Parameter 'configurable' must be a boolean/i }); - schemaEntry.configurable = true; - // @ts-expect-error schemaEntry.minimum = '123'; test.throws(throwsCheck, { message: /Parameter 'minimum' must be a number or null/i }); @@ -150,7 +139,6 @@ ava('SchemaEntry#toJSON', (test): void => { const schema = new Schema(); const schemaEntry = new SchemaEntry(schema, 'test', 'textchannel', { array: true, - configurable: false, default: [], inclusive: true, maximum: 1000, @@ -163,7 +151,6 @@ ava('SchemaEntry#toJSON', (test): void => { test.deepEqual(json, { type: 'textchannel', array: true, - configurable: false, default: [], inclusive: true, maximum: 1000, diff --git a/test/SchemaFolder.ts b/test/SchemaFolder.ts deleted file mode 100644 index 0c44eb4605..0000000000 --- a/test/SchemaFolder.ts +++ /dev/null @@ -1,44 +0,0 @@ -import ava from 'ava'; -import { - Schema, - SchemaEntry, - SchemaFolder, - SettingsFolder -} from '../src'; - -ava('SchemaFolder Properties', (test): void => { - test.plan(5); - - const schema = new Schema(); - const schemaFolder = new SchemaFolder(schema, 'someFolder'); - - test.is(schemaFolder.parent, schema); - test.is(schemaFolder.key, 'someFolder'); - test.true(schemaFolder.defaults instanceof SettingsFolder); - test.is(schemaFolder.defaults.size, 0); - test.deepEqual(schemaFolder.toJSON(), {}); -}); - -ava('SchemaFolder (Child)', (test): void => { - test.plan(4); - - const schema = new Schema(); - const schemaFolder = new SchemaFolder(schema, 'someFolder') - .add('someKey', 'textchannel'); - - test.is(schemaFolder.defaults.size, 1); - test.is(schemaFolder.defaults.get('someKey'), null); - test.is((schemaFolder.get('someKey') as SchemaEntry).parent, schemaFolder); - test.deepEqual(schemaFolder.toJSON(), { - someKey: { - array: false, - configurable: true, - default: null, - inclusive: false, - maximum: null, - minimum: null, - resolve: true, - type: 'textchannel' - } - }); -}); diff --git a/test/Settings.ts b/test/Settings.ts index ed58a43d9d..eb4e90921e 100644 --- a/test/Settings.ts +++ b/test/Settings.ts @@ -6,22 +6,25 @@ import { Provider, Schema, Settings, - SettingsExistenceStatus + SettingsExistenceStatus, + SchemaEntry, + KeyedObject, + SettingsUpdateContext } from '../src'; const ava = unknownTest as TestInterface<{ client: KlasaClient, gateway: Gateway, schema: Schema, + settings: Settings, provider: Provider }>; -ava.before(async (test): Promise => { +ava.beforeEach(async (test): Promise => { const client = createClient(); const schema = new Schema() .add('count', 'number') - .add('messages', folder => folder - .add('hello', 'string')); + .add('messages', 'string', { array: true }); const gateway = new Gateway(client, 'settings-test', { provider: 'json', schema @@ -31,10 +34,14 @@ ava.before(async (test): Promise => { client.gateways.register(gateway); await gateway.init(); + const id = '1'; + const settings = new Settings(gateway, { id }, id); + test.context = { client, gateway, schema, + settings, provider }; }); @@ -51,17 +58,14 @@ ava('Settings Properties', (test): void => { test.is(settings.existenceStatus, SettingsExistenceStatus.Unsynchronized); test.deepEqual(settings.toJSON(), { count: null, - messages: { - hello: null - } + messages: [] }); }); ava('Settings#clone', (test): void => { test.plan(4); - const id = '2'; - const settings = new Settings(test.context.gateway, { id }, id); + const { settings } = test.context; const clone = settings.clone(); test.true(clone instanceof Settings); test.is(settings.id, clone.id); @@ -72,8 +76,7 @@ ava('Settings#clone', (test): void => { ava('Settings#sync (Not Exists)', async (test): Promise => { test.plan(2); - const id = '3'; - const settings = new Settings(test.context.gateway, { id }, id); + const { settings } = test.context; test.is(await settings.sync(), settings); test.is(settings.existenceStatus, SettingsExistenceStatus.NotExists); @@ -82,9 +85,8 @@ ava('Settings#sync (Not Exists)', async (test): Promise => { ava('Settings#sync (Exists)', async (test): Promise => { test.plan(7); - const id = '4'; - await test.context.provider.create(test.context.gateway.name, id, { count: 60 }); - const settings = new Settings(test.context.gateway, { id }, id); + const { settings } = test.context; + await test.context.provider.create(test.context.gateway.name, settings.id, { count: 60 }); settings.client.once('settingsSync', (...args) => { test.is(args.length, 1); @@ -102,8 +104,7 @@ ava('Settings#sync (Exists)', async (test): Promise => { ava('Settings#destroy (Not Exists)', async (test): Promise => { test.plan(2); - const id = '5'; - const settings = new Settings(test.context.gateway, { id }, id); + const { settings } = test.context; test.is(await settings.destroy(), settings); test.is(settings.existenceStatus, SettingsExistenceStatus.NotExists); @@ -112,9 +113,8 @@ ava('Settings#destroy (Not Exists)', async (test): Promise => { ava('Settings#destroy (Exists)', async (test): Promise => { test.plan(9); - const id = '6'; - await test.context.provider.create(test.context.gateway.name, id, { count: 120 }); - const settings = new Settings(test.context.gateway, { id }, id); + const { settings } = test.context; + await test.context.provider.create(test.context.gateway.name, settings.id, { count: 120 }); settings.client.once('settingsDelete', (...args) => { test.is(args.length, 1); @@ -133,3 +133,814 @@ ava('Settings#destroy (Exists)', async (test): Promise => { test.is(settings.existenceStatus, SettingsExistenceStatus.NotExists); test.is(settings.get('count'), null); }); + +ava('Settings#pluck', async (test): Promise => { + test.plan(5); + + const { settings, gateway, provider } = test.context; + + await provider.create(gateway.name, settings.id, { count: 65 }); + await settings.sync(); + + test.deepEqual(settings.pluck('count'), [65]); + test.deepEqual(settings.pluck('messages'), [[]]); + test.deepEqual(settings.pluck('invalid.path'), [undefined]); + test.deepEqual(settings.pluck('count', 'messages'), [65, []]); + test.deepEqual(settings.pluck('count', 'messages', 'invalid.path'), [65, [], undefined]); +}); + +ava('Settings#resolve', async (test): Promise => { + test.plan(4); + + const { settings, gateway, provider } = test.context; + + await provider.create(gateway.name, settings.id, { count: 65 }); + await settings.sync(); + + // Check if single value from root's folder is resolved correctly + test.deepEqual(await settings.resolve('count'), [65]); + + // Check if multiple values are resolved correctly + test.deepEqual(await settings.resolve('count', 'messages'), [65, []]); + + // Update and give it an actual value + await provider.update(gateway.name, settings.id, { messages: ['Hello'] }); + await settings.sync(true); + test.deepEqual(await settings.resolve('messages'), [['Hello']]); + + // Invalid path + test.deepEqual(await settings.resolve('invalid.path'), [undefined]); +}); + +ava('Settings#reset (Single | Not Exists)', async (test): Promise => { + test.plan(3); + + const { settings, gateway, provider } = test.context; + + await settings.sync(); + + test.is(await provider.get(gateway.name, settings.id), null); + test.deepEqual(await settings.reset('count'), []); + test.is(await provider.get(gateway.name, settings.id), null); +}); + +ava('Settings#reset (Single | Exists)', async (test): Promise => { + test.plan(7); + + const { settings, gateway, provider } = test.context; + + await provider.create(gateway.name, settings.id, { count: 64 }); + await settings.sync(); + + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, count: 64 }); + const results = await settings.reset('count'); + test.is(results.length, 1); + test.is(results[0].previous, 64); + test.is(results[0].next, null); + test.is(results[0].entry, gateway.schema.get('count')); + test.is(settings.get('count'), null); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, count: null }); +}); + +ava('Settings#reset (Multiple[Array] | Not Exists)', async (test): Promise => { + test.plan(3); + + const { settings, gateway, provider } = test.context; + await settings.sync(); + + test.is(await provider.get(gateway.name, settings.id), null); + test.deepEqual(await settings.reset(['count', 'messages']), []); + test.is(await provider.get(gateway.name, settings.id), null); +}); + +ava('Settings#reset (Multiple[Array] | Exists)', async (test): Promise => { + test.plan(6); + + const { settings, gateway, provider } = test.context; + await provider.create(gateway.name, settings.id, { messages: ['world'] }); + await settings.sync(); + + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: ['world'] }); + const results = await settings.reset(['count', 'messages']); + test.is(results.length, 1); + test.deepEqual(results[0].previous, ['world']); + test.deepEqual(results[0].next, []); + test.is(results[0].entry, gateway.schema.get('messages')); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: [] }); +}); + +ava('Settings#reset (Multiple[Object] | Not Exists)', async (test): Promise => { + test.plan(3); + + const { settings, gateway, provider } = test.context; + await settings.sync(); + + test.is(await provider.get(gateway.name, settings.id), null); + test.deepEqual(await settings.reset({ count: true, messages: true }), []); + test.is(await provider.get(gateway.name, settings.id), null); +}); + +ava('Settings#reset (Multiple[Object] | Exists)', async (test): Promise => { + test.plan(6); + + const { settings, gateway, provider } = test.context; + await provider.create(gateway.name, settings.id, { messages: ['world'] }); + await settings.sync(); + + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: ['world'] }); + const results = await settings.reset({ count: true, messages: true }); + test.is(results.length, 1); + test.deepEqual(results[0].previous, ['world']); + test.deepEqual(results[0].next, []); + test.is(results[0].entry, gateway.schema.get('messages')); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: [] }); +}); + +ava('Settings#reset (Multiple[Object-Deep] | Not Exists)', async (test): Promise => { + test.plan(3); + + const { settings, gateway, provider } = test.context; + await settings.sync(); + + test.is(await provider.get(gateway.name, settings.id), null); + test.deepEqual(await settings.reset({ count: true, messages: true }), []); + test.is(await provider.get(gateway.name, settings.id), null); +}); + +ava('Settings#reset (Multiple[Object-Deep] | Exists)', async (test): Promise => { + test.plan(6); + + const { settings, gateway, provider } = test.context; + await provider.create(gateway.name, settings.id, { messages: ['world'] }); + await settings.sync(); + + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: ['world'] }); + const results = await settings.reset({ count: true, messages: true }); + test.is(results.length, 1); + test.deepEqual(results[0].previous, ['world']); + test.deepEqual(results[0].next, []); + test.is(results[0].entry, gateway.schema.get('messages')); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: [] }); +}); + +ava('Settings#reset (Root | Not Exists)', async (test): Promise => { + test.plan(3); + + const { settings, gateway, provider } = test.context; + await settings.sync(); + + test.is(await provider.get(gateway.name, settings.id), null); + test.deepEqual(await settings.reset(), []); + test.is(await provider.get(gateway.name, settings.id), null); +}); + +ava('Settings#reset (Root | Exists)', async (test): Promise => { + test.plan(6); + + const { settings, gateway, provider } = test.context; + await provider.create(gateway.name, settings.id, { messages: ['world'] }); + await settings.sync(); + + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: ['world'] }); + const results = await settings.reset(); + test.is(results.length, 1); + test.deepEqual(results[0].previous, ['world']); + test.deepEqual(results[0].next, []); + test.is(results[0].entry, gateway.schema.get('messages')); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: [] }); +}); + +ava('Settings#reset (Array | Empty)', async (test): Promise => { + test.plan(3); + + const { settings, gateway, provider } = test.context; + await provider.create(gateway.name, settings.id, {}); + await settings.sync(); + + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id }); + const results = await settings.reset('messages'); + test.is(results.length, 0); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id }); +}); + +ava('Settings#reset (Array | Filled)', async (test): Promise => { + test.plan(6); + + const { settings, gateway, schema, provider } = test.context; + await provider.create(gateway.name, settings.id, { messages: ['1', '2', '4'] }); + await settings.sync(); + + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: ['1', '2', '4'] }); + const results = await settings.reset('messages'); + test.is(results.length, 1); + test.deepEqual(results[0].previous, ['1', '2', '4']); + test.is(results[0].next, (schema.get('messages') as SchemaEntry).default); + test.is(results[0].entry, schema.get('messages') as SchemaEntry); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: [] }); +}); + +ava('Settings#reset (Events | Not Exists)', async (test): Promise => { + test.plan(1); + + const { client, settings } = test.context; + await settings.sync(); + + client.once('settingsCreate', () => test.fail()); + client.once('settingsUpdate', () => test.fail()); + test.deepEqual(await settings.reset('count'), []); +}); + +ava('Settings#reset (Events | Exists)', async (test): Promise => { + test.plan(9); + + const { client, settings, gateway, provider, schema } = test.context; + await provider.create(gateway.name, settings.id, { count: 64 }); + await settings.sync(); + + const schemaEntry = schema.get('count') as SchemaEntry; + client.once('settingsCreate', () => test.fail()); + client.once('settingsUpdate', (emittedSettings: Settings, changes: KeyedObject, context: SettingsUpdateContext) => { + test.is(emittedSettings, settings); + test.deepEqual(changes, { count: null }); + test.is(context.changes.length, 1); + test.is(context.changes[0].entry, schemaEntry); + test.is(context.changes[0].previous, 64); + test.is(context.changes[0].next, schemaEntry.default); + test.is(context.extraContext, undefined); + test.is(context.guild, null); + test.is(context.language, client.languages.get('en-US')); + }); + await settings.reset('count'); +}); + +ava('Settings#reset (Events + Extra | Exists)', async (test): Promise => { + test.plan(9); + + const { client, settings, gateway, provider, schema } = test.context; + await provider.create(gateway.name, settings.id, { count: 64 }); + await settings.sync(); + + const extraContext = Symbol('Hello!'); + const schemaEntry = schema.get('count') as SchemaEntry; + client.once('settingsCreate', () => test.fail()); + client.once('settingsUpdate', (emittedSettings: Settings, changes: KeyedObject, context: SettingsUpdateContext) => { + test.is(emittedSettings, settings); + test.deepEqual(changes, { count: null }); + test.is(context.changes.length, 1); + test.is(context.changes[0].entry, schemaEntry); + test.is(context.changes[0].previous, 64); + test.is(context.changes[0].next, schemaEntry.default); + test.is(context.extraContext, extraContext); + test.is(context.guild, null); + test.is(context.language, client.languages.get('en-US')); + }); + await settings.reset('count', { extraContext }); +}); + +ava('Settings#reset (Unsynchronized)', async (test): Promise => { + test.plan(1); + + const { settings } = test.context; + await test.throwsAsync(() => settings.reset(), { message: 'Cannot reset keys from an unsynchronized settings instance. Perhaps you want to call `sync()` first.' }); +}); + +ava('Settings#reset (Invalid Key)', async (test): Promise => { + test.plan(1); + + const { settings, gateway, provider } = test.context; + await provider.create(gateway.name, settings.id, { messages: ['world'] }); + await settings.sync(); + + await test.throwsAsync(() => settings.reset('invalid.path'), { message: '[SETTING_GATEWAY_KEY_NOEXT]: invalid.path' }); +}); + +ava('Settings#update (Single)', async (test): Promise => { + test.plan(8); + + const { settings, gateway, schema, provider } = test.context; + await settings.sync(); + + test.is(settings.existenceStatus, SettingsExistenceStatus.NotExists); + const results = await settings.update('count', 2); + test.is(results.length, 1); + test.is(results[0].previous, null); + test.is(results[0].next, 2); + test.is(results[0].entry, schema.get('count') as SchemaEntry); + test.is(settings.get('count'), 2); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, count: 2 }); + test.is(settings.existenceStatus, SettingsExistenceStatus.Exists); +}); + +ava('Settings#update (Multiple)', async (test): Promise => { + test.plan(8); + + const { settings, gateway, schema, provider } = test.context; + await settings.sync(); + + const results = await settings.update([['count', 6], ['messages', [4]]]); + test.is(results.length, 2); + + // count + test.is(results[0].previous, null); + test.is(results[0].next, 6); + test.is(results[0].entry, schema.get('count') as SchemaEntry); + + // messages + test.deepEqual(results[1].previous, []); + test.deepEqual(results[1].next, ['4']); + test.is(results[1].entry, schema.get('messages') as SchemaEntry); + + // persistence + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, count: 6, messages: ['4'] }); +}); + +ava('Settings#update (Multiple | Object)', async (test): Promise => { + test.plan(8); + + const { settings, gateway, schema, provider } = test.context; + await settings.sync(); + + const results = await settings.update({ count: 6, messages: [4] }); + test.is(results.length, 2); + + // count + test.is(results[0].previous, null); + test.is(results[0].next, 6); + test.is(results[0].entry, schema.get('count') as SchemaEntry); + + // messages + test.deepEqual(results[1].previous, []); + test.deepEqual(results[1].next, ['4']); + test.is(results[1].entry, schema.get('messages') as SchemaEntry); + + // persistence + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, count: 6, messages: ['4'] }); +}); + +ava('Settings#update (Not Exists | Default Value)', async (test): Promise => { + test.plan(2); + + const { settings, gateway, provider } = test.context; + await settings.sync(); + + test.is(await provider.get(gateway.name, settings.id), null); + await settings.update('messages', null); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: [] }); +}); + +ava('Settings#update (ArrayAction | Empty | Default)', async (test): Promise => { + test.plan(5); + + const { settings, gateway, provider } = test.context; + await settings.sync(); + + const schemaEntry = gateway.schema.get('messages') as SchemaEntry; + const results = await settings.update('messages', ['1', '2']); + test.is(results.length, 1); + test.is(results[0].previous, schemaEntry.default); + test.deepEqual(results[0].next, ['1', '2']); + test.is(results[0].entry, schemaEntry); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: ['1', '2'] }); +}); + +ava('Settings#update (ArrayAction | Filled | Default)', async (test): Promise => { + test.plan(6); + + const { settings, gateway, schema, provider } = test.context; + await provider.create(gateway.name, settings.id, { messages: ['1', '2', '4'] }); + await settings.sync(); + + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: ['1', '2', '4'] }); + const results = await settings.update('messages', ['1', '2', '4']); + test.is(results.length, 1); + test.deepEqual(results[0].previous, ['1', '2', '4']); + test.deepEqual(results[0].next, []); + test.is(results[0].entry, schema.get('messages') as SchemaEntry); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: [] }); +}); + +ava('Settings#update (ArrayAction | Empty | Auto)', async (test): Promise => { + test.plan(5); + + const { settings, gateway, provider } = test.context; + await settings.sync(); + + const schemaEntry = gateway.schema.get('messages') as SchemaEntry; + const results = await settings.update('messages', ['1', '2'], { arrayAction: 'auto' }); + test.is(results.length, 1); + test.is(results[0].previous, schemaEntry.default); + test.deepEqual(results[0].next, ['1', '2']); + test.is(results[0].entry, schemaEntry); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: ['1', '2'] }); +}); + +ava('Settings#update (ArrayAction | Filled | Auto)', async (test): Promise => { + test.plan(6); + + const { settings, gateway, schema, provider } = test.context; + await provider.create(gateway.name, settings.id, { messages: ['1', '2', '4'] }); + await settings.sync(); + + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: ['1', '2', '4'] }); + const results = await settings.update('messages', ['1', '2', '4'], { arrayAction: 'auto' }); + test.is(results.length, 1); + test.deepEqual(results[0].previous, ['1', '2', '4']); + test.deepEqual(results[0].next, []); + test.is(results[0].entry, schema.get('messages') as SchemaEntry); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: [] }); +}); + +ava('Settings#update (ArrayAction | Empty | Add)', async (test): Promise => { + test.plan(5); + + const { settings, gateway, provider } = test.context; + await settings.sync(); + + const schemaEntry = gateway.schema.get('messages') as SchemaEntry; + const results = await settings.update('messages', ['1', '2'], { arrayAction: 'add' }); + test.is(results.length, 1); + test.is(results[0].previous, schemaEntry.default); + test.deepEqual(results[0].next, ['1', '2']); + test.is(results[0].entry, schemaEntry); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: ['1', '2'] }); +}); + +ava('Settings#update (ArrayAction | Filled | Add)', async (test): Promise => { + test.plan(6); + + const { settings, gateway, schema, provider } = test.context; + await provider.create(gateway.name, settings.id, { messages: ['1', '2', '4'] }); + await settings.sync(); + + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: ['1', '2', '4'] }); + const results = await settings.update('messages', ['3', '5', '6'], { arrayAction: 'add' }); + test.is(results.length, 1); + test.deepEqual(results[0].previous, ['1', '2', '4']); + test.deepEqual(results[0].next, ['1', '2', '4', '3', '5', '6']); + test.is(results[0].entry, schema.get('messages') as SchemaEntry); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: ['1', '2', '4', '3', '5', '6'] }); +}); + +ava('Settings#update (ArrayAction | Empty | Remove)', async (test): Promise => { + test.plan(2); + + const { settings, provider, gateway } = test.context; + await settings.sync(); + await test.throwsAsync(() => settings.update('messages', ['1', '2'], { arrayAction: 'remove' }), { message: '[SETTING_GATEWAY_MISSING_VALUE]: messages 1' }); + test.is(await provider.get(gateway.name, settings.id), null); +}); + +ava('Settings#update (ArrayAction | Filled | Remove)', async (test): Promise => { + test.plan(5); + + const { settings, gateway, provider } = test.context; + await provider.create(gateway.name, settings.id, { messages: ['1', '2', '4'] }); + await settings.sync(); + + const schemaEntry = gateway.schema.get('messages') as SchemaEntry; + const results = await settings.update('messages', ['1', '2'], { arrayAction: 'remove' }); + test.is(results.length, 1); + test.deepEqual(results[0].previous, ['1', '2', '4']); + test.deepEqual(results[0].next, ['4']); + test.is(results[0].entry, schemaEntry); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: ['4'] }); +}); + +ava('Settings#update (ArrayAction | Filled | Remove With Nulls)', async (test): Promise => { + test.plan(5); + + const { settings, gateway, provider } = test.context; + await provider.create(gateway.name, settings.id, { messages: ['1', '2', '3', '4'] }); + await settings.sync(); + + const schemaEntry = gateway.schema.get('messages') as SchemaEntry; + const results = await settings.update('messages', [null, null], { arrayAction: 'remove', arrayIndex: 1 }); + test.is(results.length, 1); + test.deepEqual(results[0].previous, ['1', '2', '3', '4']); + test.deepEqual(results[0].next, ['1', '4']); + test.is(results[0].entry, schemaEntry); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: ['1', '4'] }); +}); + +ava('Settings#update (ArrayAction | Empty | Overwrite)', async (test): Promise => { + test.plan(5); + + const { settings, gateway, provider } = test.context; + await settings.sync(); + + const schemaEntry = gateway.schema.get('messages') as SchemaEntry; + const results = await settings.update('messages', ['1', '2', '4'], { arrayAction: 'overwrite' }); + test.is(results.length, 1); + test.is(results[0].previous, schemaEntry.default); + test.deepEqual(results[0].next, ['1', '2', '4']); + test.is(results[0].entry, schemaEntry); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: ['1', '2', '4'] }); +}); + +ava('Settings#update (ArrayAction | Filled | Overwrite)', async (test): Promise => { + test.plan(6); + + const { settings, gateway, schema, provider } = test.context; + await provider.create(gateway.name, settings.id, { messages: ['1', '2', '4'] }); + await settings.sync(); + + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: ['1', '2', '4'] }); + const results = await settings.update('messages', ['3', '5', '6'], { arrayAction: 'overwrite' }); + test.is(results.length, 1); + test.deepEqual(results[0].previous, ['1', '2', '4']); + test.deepEqual(results[0].next, ['3', '5', '6']); + test.is(results[0].entry, schema.get('messages') as SchemaEntry); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: ['3', '5', '6'] }); +}); + +ava('Settings#update (ArrayIndex | Empty | Auto)', async (test): Promise => { + test.plan(5); + + const { settings, gateway, schema, provider } = test.context; + await settings.sync(); + + const schemaEntry = schema.get('messages') as SchemaEntry; + const results = await settings.update('messages', ['1', '2', '3'], { arrayIndex: 0 }); + test.is(results.length, 1); + test.is(results[0].previous, schemaEntry.default); + test.deepEqual(results[0].next, ['1', '2', '3']); + test.is(results[0].entry, schemaEntry); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: ['1', '2', '3'] }); +}); + +ava('Settings#update (ArrayIndex | Filled | Auto)', async (test): Promise => { + test.plan(5); + + const { settings, gateway, schema, provider } = test.context; + await provider.create(gateway.name, settings.id, { messages: ['1', '2', '4'] }); + await settings.sync(); + + const schemaEntry = schema.get('messages') as SchemaEntry; + const results = await settings.update('messages', ['5', '6'], { arrayIndex: 0 }); + test.is(results.length, 1); + test.deepEqual(results[0].previous, ['1', '2', '4']); + test.deepEqual(results[0].next, ['5', '6', '4']); + test.is(results[0].entry, schemaEntry); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: ['5', '6', '4'] }); +}); + +ava('Settings#update (ArrayIndex | Empty | Add)', async (test): Promise => { + test.plan(5); + + const { settings, gateway, schema, provider } = test.context; + await settings.sync(); + + const schemaEntry = schema.get('messages') as SchemaEntry; + const results = await settings.update('messages', ['1', '2', '3'], { arrayIndex: 0, arrayAction: 'add' }); + test.is(results.length, 1); + test.is(results[0].previous, schemaEntry.default); + test.deepEqual(results[0].next, ['1', '2', '3']); + test.is(results[0].entry, schemaEntry); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: ['1', '2', '3'] }); +}); + +ava('Settings#update (ArrayIndex | Filled | Add)', async (test): Promise => { + test.plan(5); + + const { settings, gateway, schema, provider } = test.context; + await provider.create(gateway.name, settings.id, { messages: ['1', '2', '4'] }); + await settings.sync(); + + const schemaEntry = schema.get('messages') as SchemaEntry; + const results = await settings.update('messages', ['5', '6'], { arrayIndex: 0, arrayAction: 'add' }); + test.is(results.length, 1); + test.deepEqual(results[0].previous, ['1', '2', '4']); + test.deepEqual(results[0].next, ['5', '6', '1', '2', '4']); + test.is(results[0].entry, schemaEntry); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: ['5', '6', '1', '2', '4'] }); +}); + +ava('Settings#update (ArrayIndex | Filled | Add | Error)', async (test): Promise => { + test.plan(2); + + const { settings, gateway, provider } = test.context; + await provider.create(gateway.name, settings.id, { messages: ['1', '2', '4'] }); + await settings.sync(); + + await test.throwsAsync(() => settings.update('messages', '4', { arrayAction: 'add' }), { message: '[SETTING_GATEWAY_DUPLICATE_VALUE]: messages 4' }); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: ['1', '2', '4'] }); +}); + +ava('Settings#update (ArrayIndex | Empty | Remove)', async (test): Promise => { + test.plan(5); + + const { settings, gateway, schema, provider } = test.context; + await settings.sync(); + + const schemaEntry = schema.get('messages') as SchemaEntry; + const results = await settings.update('messages', ['1', '2'], { arrayIndex: 0, arrayAction: 'remove' }); + test.is(results.length, 1); + test.is(results[0].previous, schemaEntry.default); + test.deepEqual(results[0].next, []); + test.is(results[0].entry, schemaEntry); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: [] }); +}); + +ava('Settings#update (ArrayIndex | Filled | Remove)', async (test): Promise => { + test.plan(5); + + const { settings, gateway, schema, provider } = test.context; + await provider.create(gateway.name, settings.id, { messages: ['1', '2', '4'] }); + await settings.sync(); + + const schemaEntry = schema.get('messages') as SchemaEntry; + const results = await settings.update('messages', ['1', '2'], { arrayIndex: 1, arrayAction: 'remove' }); + test.is(results.length, 1); + test.deepEqual(results[0].previous, ['1', '2', '4']); + test.deepEqual(results[0].next, ['1']); + test.is(results[0].entry, schemaEntry); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: ['1'] }); +}); + +ava('Settings#update (ArrayIndex | Filled | Remove | Error)', async (test): Promise => { + test.plan(2); + + const { settings, gateway, provider } = test.context; + await provider.create(gateway.name, settings.id, { messages: ['1', '2', '4'] }); + await settings.sync(); + + await test.throwsAsync(() => settings.update('messages', 3, { arrayAction: 'remove' }), { message: '[SETTING_GATEWAY_MISSING_VALUE]: messages 3' }); + test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: ['1', '2', '4'] }); +}); + +ava('Settings#update (Events | Not Exists)', async (test): Promise => { + test.plan(9); + + const { client, schema, settings } = test.context; + await settings.sync(); + + const schemaEntry = schema.get('count') as SchemaEntry; + client.once('settingsCreate', (emittedSettings: Settings, changes: KeyedObject, context: SettingsUpdateContext) => { + test.is(emittedSettings, settings); + test.deepEqual(changes, { count: 64 }); + test.is(context.changes.length, 1); + test.is(context.changes[0].entry, schemaEntry); + test.is(context.changes[0].previous, schemaEntry.default); + test.is(context.changes[0].next, 64); + test.is(context.extraContext, undefined); + test.is(context.guild, null); + test.is(context.language, client.languages.get('en-US')); + }); + client.once('settingsUpdate', () => test.fail()); + await settings.update('count', 64); +}); + +ava('Settings#update (Events | Exists | Simple)', async (test): Promise => { + test.plan(9); + + const { client, settings, gateway, provider, schema } = test.context; + await provider.create(gateway.name, settings.id, { count: 64 }); + await settings.sync(); + + const schemaEntry = schema.get('count') as SchemaEntry; + client.once('settingsCreate', () => test.fail()); + client.once('settingsUpdate', (emittedSettings: Settings, changes: KeyedObject, context: SettingsUpdateContext) => { + test.is(emittedSettings, settings); + test.deepEqual(changes, { count: 420 }); + test.is(context.changes.length, 1); + test.is(context.changes[0].entry, schemaEntry); + test.is(context.changes[0].previous, 64); + test.is(context.changes[0].next, 420); + test.is(context.extraContext, undefined); + test.is(context.guild, null); + test.is(context.language, client.languages.get('en-US')); + }); + await settings.update('count', 420); +}); + +ava('Settings#update (Events | Exists | Array Overload | Options)', async (test): Promise => { + test.plan(9); + + const { client, settings, gateway, provider, schema } = test.context; + await provider.create(gateway.name, settings.id, { count: 64 }); + await settings.sync(); + + const schemaEntry = schema.get('count') as SchemaEntry; + client.once('settingsCreate', () => test.fail()); + client.once('settingsUpdate', (emittedSettings: Settings, changes: KeyedObject, context: SettingsUpdateContext) => { + test.is(emittedSettings, settings); + test.deepEqual(changes, { count: 420 }); + test.is(context.changes.length, 1); + test.is(context.changes[0].entry, schemaEntry); + test.is(context.changes[0].previous, 64); + test.is(context.changes[0].next, 420); + test.is(context.extraContext, 'Hello!'); + test.is(context.guild, null); + test.is(context.language, client.languages.get('en-US')); + }); + await settings.update([['count', 420]], { extraContext: 'Hello!' }); +}); + +ava('Settings#update (Events | Exists | Object Overload | Options)', async (test): Promise => { + test.plan(9); + + const { client, settings, gateway, provider, schema } = test.context; + await provider.create(gateway.name, settings.id, { count: 64 }); + await settings.sync(); + + const schemaEntry = schema.get('count') as SchemaEntry; + client.once('settingsCreate', () => test.fail()); + client.once('settingsUpdate', (emittedSettings: Settings, changes: KeyedObject, context: SettingsUpdateContext) => { + test.is(emittedSettings, settings); + test.deepEqual(changes, { count: 420 }); + test.is(context.changes.length, 1); + test.is(context.changes[0].entry, schemaEntry); + test.is(context.changes[0].previous, 64); + test.is(context.changes[0].next, 420); + test.is(context.extraContext, 'Hello!'); + test.is(context.guild, null); + test.is(context.language, client.languages.get('en-US')); + }); + await settings.update({ count: 420 }, { extraContext: 'Hello!' }); +}); + +ava('Settings#update (Events + Extra | Not Exists)', async (test): Promise => { + test.plan(9); + + const { client, settings, schema } = test.context; + await settings.sync(); + + const extraContext = Symbol('Hello!'); + const schemaEntry = schema.get('count') as SchemaEntry; + client.once('settingsCreate', (emittedSettings: Settings, changes: KeyedObject, context: SettingsUpdateContext) => { + test.is(emittedSettings, settings); + test.deepEqual(changes, { count: 420 }); + test.is(context.changes.length, 1); + test.is(context.changes[0].entry, schemaEntry); + test.is(context.changes[0].previous, schemaEntry.default); + test.is(context.changes[0].next, 420); + test.is(context.extraContext, extraContext); + test.is(context.guild, null); + test.is(context.language, client.languages.get('en-US')); + }); + client.once('settingsUpdate', () => test.fail()); + await settings.update('count', 420, { extraContext }); +}); + +ava('Settings#update (Events + Extra | Exists)', async (test): Promise => { + test.plan(9); + + const { client, settings, gateway, provider, schema } = test.context; + await provider.create(gateway.name, settings.id, { count: 64 }); + await settings.sync(); + + const extraContext = Symbol('Hello!'); + const schemaEntry = schema.get('count') as SchemaEntry; + client.once('settingsCreate', () => test.fail()); + client.once('settingsUpdate', (emittedSettings: Settings, changes: KeyedObject, context: SettingsUpdateContext) => { + test.is(emittedSettings, settings); + test.deepEqual(changes, { count: 420 }); + test.is(context.changes.length, 1); + test.is(context.changes[0].entry, schemaEntry); + test.is(context.changes[0].previous, 64); + test.is(context.changes[0].next, 420); + test.is(context.extraContext, extraContext); + test.is(context.guild, null); + test.is(context.language, client.languages.get('en-US')); + }); + await settings.update('count', 420, { extraContext }); +}); + +ava('Settings#update (Uninitialized)', async (test): Promise => { + test.plan(1); + + const { settings } = test.context; + await test.throwsAsync(() => settings.update('count', 6), { message: 'Cannot reset keys from an unsynchronized settings instance. Perhaps you want to call `sync()` first.' }); +}); + +ava('Settings#update (Unsynchronized)', async (test): Promise => { + test.plan(1); + + const { settings } = test.context; + await test.throwsAsync(() => settings.update('count', 6), { message: 'Cannot reset keys from an unsynchronized settings instance. Perhaps you want to call `sync()` first.' }); +}); + +ava('Settings#update (Invalid Key)', async (test): Promise => { + test.plan(1); + + const { settings, gateway, provider } = test.context; + await provider.create(gateway.name, settings.id, { messages: ['world'] }); + await settings.sync(); + + await test.throwsAsync(() => settings.update('invalid.path', 420), { message: '[SETTING_GATEWAY_KEY_NOEXT]: invalid.path' }); +}); + +ava('Settings#toJSON', async (test): Promise => { + test.plan(2); + + const { settings, gateway, provider } = test.context; + + // Non-synced entry should have schema defaults + test.deepEqual(settings.toJSON(), { messages: [], count: null }); + + await provider.create(gateway.name, settings.id, { count: 123, messages: [] }); + await settings.sync(); + + // Synced entry should use synced values or schema defaults + test.deepEqual(settings.toJSON(), { messages: [], count: 123 }); +}); diff --git a/test/SettingsFolder.ts b/test/SettingsFolder.ts deleted file mode 100644 index 79d6e715f7..0000000000 --- a/test/SettingsFolder.ts +++ /dev/null @@ -1,1056 +0,0 @@ -import unknownTest, { TestInterface } from 'ava'; -import { createClient } from './lib/SettingsClient'; -import { - Gateway, - KeyedObject, - KlasaClient, - Provider, - Schema, - SchemaEntry, - Settings, - SettingsExistenceStatus, - SettingsFolder, - SettingsUpdateContext -} from '../src'; - -const ava = unknownTest as TestInterface<{ - client: KlasaClient, - gateway: Gateway, - schema: Schema, - provider: Provider, - settings: Settings -}>; - -ava.beforeEach(async (test): Promise => { - const client = createClient(); - const schema = new Schema() - .add('uses', 'number', { array: true }) - .add('count', 'number', { configurable: false }) - .add('messages', messages => messages - .add('ignoring', ignoring => ignoring - .add('amount', 'number')) - .add('hello', 'object')); - - const gateway = new Gateway(client, 'settings-test', { schema }); - client.gateways.register(gateway); - await gateway.init(); - - test.context = { - client, - gateway, - schema, - provider: gateway.provider as Provider, - settings: new Settings(gateway, { id: 'MockTest' }, 'MockTest') - }; -}); - -ava('SettingsFolder (Basic)', (test): void => { - test.plan(4); - - const { schema } = test.context; - const settingsFolder = new SettingsFolder(schema); - - test.is(settingsFolder.base, null); - test.is(settingsFolder.schema, schema); - test.is(settingsFolder.size, 0); - test.throws(() => settingsFolder.client, { message: /Cannot retrieve gateway from a non-ready settings instance/i }); -}); - -ava('SettingsFolder#{base,client}', (test): void => { - test.plan(2); - - const { settings } = test.context; - const settingsFolder = settings.get('messages') as SettingsFolder; - - test.notThrows(() => settingsFolder.client); - test.is(settingsFolder.base, settings); -}); - -ava('SettingsFolder#get', (test): void => { - test.plan(9); - - const { settings, schema } = test.context; - - // Retrieve key from root folder - test.is(settings.size, 3); - test.is(settings.get('uses'), (schema.get('uses') as SchemaEntry).default); - test.is(settings.get('count'), null); - test.is(settings.get('messages.hello'), null); - - // Retrieve nested folder from root folder - const settingsFolder = settings.get('messages') as SettingsFolder; - test.true(settingsFolder instanceof SettingsFolder); - test.is(settingsFolder.size, 2); - test.is(settingsFolder.get('hello'), null); - - // Invalid paths should return undefined - test.is(settings.get('fake.path'), undefined); - // Invalid parameter to get should return undefined - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - test.is(settings.get(null), undefined); -}); - -ava('SettingsFolder#pluck', async (test): Promise => { - test.plan(5); - - const { settings, gateway, provider } = test.context; - - await provider.create(gateway.name, settings.id, { count: 65 }); - await settings.sync(); - - test.deepEqual(settings.pluck('count'), [65]); - test.deepEqual(settings.pluck('messages.hello'), [null]); - test.deepEqual(settings.pluck('invalid.path'), [undefined]); - test.deepEqual(settings.pluck('count', 'messages.hello', 'invalid.path'), [65, null, undefined]); - test.deepEqual(settings.pluck('count', 'messages'), [65, { hello: null, ignoring: { amount: null } }]); -}); - -ava('SettingsFolder#resolve', async (test): Promise => { - test.plan(4); - - const { settings, gateway, provider } = test.context; - - await provider.create(gateway.name, settings.id, { count: 65 }); - await settings.sync(); - - // Check if single value from root's folder is resolved correctly - test.deepEqual(await settings.resolve('count'), [65]); - - // Check if multiple values are resolved correctly - test.deepEqual(await settings.resolve('count', 'messages'), [65, { hello: null, ignoring: { amount: null } }]); - - // Update and give it an actual value - await provider.update(gateway.name, settings.id, { messages: { hello: 'Hello' } }); - await settings.sync(true); - test.deepEqual(await settings.resolve('messages.hello'), [{ data: 'Hello' }]); - - // Invalid path - test.deepEqual(await settings.resolve('invalid.path'), [undefined]); -}); - -ava('SettingsFolder#resolve (Folder)', async (test): Promise => { - const { settings } = test.context; - test.deepEqual(await settings.resolve('messages'), [{ hello: null, ignoring: { amount: null } }]); -}); - -ava('SettingsFolder#resolve (Not Ready)', async (test): Promise => { - test.plan(2); - - const settingsFolder = new SettingsFolder(test.context.schema); - test.is(settingsFolder.base, null); - await test.throwsAsync(() => settingsFolder.resolve('uses'), { message: 'Cannot retrieve guild from a non-ready settings instance.' }); -}); - -ava('SettingsFolder#reset (Single | Not Exists)', async (test): Promise => { - test.plan(3); - - const { settings, gateway, provider } = test.context; - - await settings.sync(); - - test.is(await provider.get(gateway.name, settings.id), null); - test.deepEqual(await settings.reset('count'), []); - test.is(await provider.get(gateway.name, settings.id), null); -}); - -ava('SettingsFolder#reset (Single | Exists)', async (test): Promise => { - test.plan(7); - - const { settings, gateway, provider } = test.context; - - await provider.create(gateway.name, settings.id, { count: 64 }); - await settings.sync(); - - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, count: 64 }); - const results = await settings.reset('count'); - test.is(results.length, 1); - test.is(results[0].previous, 64); - test.is(results[0].next, null); - test.is(results[0].entry, gateway.schema.get('count') as SchemaEntry); - test.is(settings.get('count'), null); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, count: null }); -}); - -ava('SettingsFolder#reset (Multiple[Array] | Not Exists)', async (test): Promise => { - test.plan(3); - - const { settings, gateway, provider } = test.context; - await settings.sync(); - - test.is(await provider.get(gateway.name, settings.id), null); - test.deepEqual(await settings.reset(['count', 'messages.hello']), []); - test.is(await provider.get(gateway.name, settings.id), null); -}); - -ava('SettingsFolder#reset (Multiple[Array] | Exists)', async (test): Promise => { - test.plan(6); - - const { settings, gateway, provider } = test.context; - await provider.create(gateway.name, settings.id, { messages: { hello: 'world' } }); - await settings.sync(); - - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: { hello: 'world' } }); - const results = await settings.reset(['count', 'messages.hello']); - test.is(results.length, 1); - test.is(results[0].previous, 'world'); - test.is(results[0].next, null); - test.is(results[0].entry, gateway.schema.get('messages.hello') as SchemaEntry); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: { hello: null } }); -}); - -ava('SettingsFolder#reset (Multiple[Object] | Not Exists)', async (test): Promise => { - test.plan(3); - - const { settings, gateway, provider } = test.context; - await settings.sync(); - - test.is(await provider.get(gateway.name, settings.id), null); - test.deepEqual(await settings.reset({ count: true, 'messages.hello': true }), []); - test.is(await provider.get(gateway.name, settings.id), null); -}); - -ava('SettingsFolder#reset (Multiple[Object] | Exists)', async (test): Promise => { - test.plan(6); - - const { settings, gateway, provider } = test.context; - await provider.create(gateway.name, settings.id, { messages: { hello: 'world' } }); - await settings.sync(); - - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: { hello: 'world' } }); - const results = await settings.reset({ count: true, 'messages.hello': true }); - test.is(results.length, 1); - test.is(results[0].previous, 'world'); - test.is(results[0].next, null); - test.is(results[0].entry, gateway.schema.get('messages.hello') as SchemaEntry); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: { hello: null } }); -}); - -ava('SettingsFolder#reset (Multiple[Object-Deep] | Not Exists)', async (test): Promise => { - test.plan(3); - - const { settings, gateway, provider } = test.context; - await settings.sync(); - - test.is(await provider.get(gateway.name, settings.id), null); - test.deepEqual(await settings.reset({ count: true, messages: { hello: true } }), []); - test.is(await provider.get(gateway.name, settings.id), null); -}); - -ava('SettingsFolder#reset (Multiple[Object-Deep] | Exists)', async (test): Promise => { - test.plan(6); - - const { settings, gateway, provider } = test.context; - await provider.create(gateway.name, settings.id, { messages: { hello: 'world' } }); - await settings.sync(); - - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: { hello: 'world' } }); - const results = await settings.reset({ count: true, messages: { hello: true } }); - test.is(results.length, 1); - test.is(results[0].previous, 'world'); - test.is(results[0].next, null); - test.is(results[0].entry, gateway.schema.get('messages.hello') as SchemaEntry); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: { hello: null } }); -}); - -ava('SettingsFolder#reset (Root | Not Exists)', async (test): Promise => { - test.plan(3); - - const { settings, gateway, provider } = test.context; - await settings.sync(); - - test.is(await provider.get(gateway.name, settings.id), null); - test.deepEqual(await settings.reset(), []); - test.is(await provider.get(gateway.name, settings.id), null); -}); - -ava('SettingsFolder#reset (Root | Exists)', async (test): Promise => { - test.plan(6); - - const { settings, gateway, provider } = test.context; - await provider.create(gateway.name, settings.id, { messages: { hello: 'world' } }); - await settings.sync(); - - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: { hello: 'world' } }); - const results = await settings.reset(); - test.is(results.length, 1); - test.is(results[0].previous, 'world'); - test.is(results[0].next, null); - test.is(results[0].entry, gateway.schema.get('messages.hello') as SchemaEntry); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: { hello: null } }); -}); - -ava('SettingsFolder#reset (Folder | Not Exists)', async (test): Promise => { - test.plan(3); - - const { settings, gateway, provider } = test.context; - await settings.sync(); - - test.is(await provider.get(gateway.name, settings.id), null); - test.deepEqual(await settings.reset('messages'), []); - test.is(await provider.get(gateway.name, settings.id), null); -}); - -ava('SettingsFolder#reset (Folder | Exists)', async (test): Promise => { - test.plan(6); - - const { settings, gateway, provider } = test.context; - await provider.create(gateway.name, settings.id, { messages: { hello: 'world' } }); - await settings.sync(); - - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: { hello: 'world' } }); - const results = await settings.reset('messages'); - test.is(results.length, 1); - test.is(results[0].previous, 'world'); - test.is(results[0].next, null); - test.is(results[0].entry, gateway.schema.get('messages.hello') as SchemaEntry); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: { hello: null } }); -}); - -ava('SettingsFolder#reset (Inner-Folder | Not Exists)', async (test): Promise => { - test.plan(3); - - const { settings, gateway, provider } = test.context; - await settings.sync(); - - test.is(await provider.get(gateway.name, settings.id), null); - const settingsFolder = settings.get('messages') as SettingsFolder; - test.deepEqual(await settingsFolder.reset(), []); - test.is(await provider.get(gateway.name, settings.id), null); -}); - -ava('SettingsFolder#reset (Inner-Folder | Exists)', async (test): Promise => { - test.plan(6); - - const { settings, gateway, provider } = test.context; - await provider.create(gateway.name, settings.id, { messages: { hello: 'world' } }); - await settings.sync(); - - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: { hello: 'world' } }); - const settingsFolder = settings.get('messages') as SettingsFolder; - const results = await settingsFolder.reset(); - test.is(results.length, 1); - test.is(results[0].previous, 'world'); - test.is(results[0].next, null); - test.is(results[0].entry, gateway.schema.get('messages.hello') as SchemaEntry); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: { hello: null } }); -}); - -ava('SettingsFolder#reset (Array | Empty)', async (test): Promise => { - test.plan(3); - - const { settings, gateway, provider } = test.context; - await provider.create(gateway.name, settings.id, {}); - await settings.sync(); - - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id }); - const results = await settings.reset('uses'); - test.is(results.length, 0); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id }); -}); - -ava('SettingsFolder#reset (Array | Filled)', async (test): Promise => { - test.plan(6); - - const { settings, gateway, schema, provider } = test.context; - await provider.create(gateway.name, settings.id, { uses: [1, 2, 4] }); - await settings.sync(); - - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, uses: [1, 2, 4] }); - const results = await settings.reset('uses'); - test.is(results.length, 1); - test.deepEqual(results[0].previous, [1, 2, 4]); - test.is(results[0].next, (schema.get('uses') as SchemaEntry).default); - test.is(results[0].entry, schema.get('uses') as SchemaEntry); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, uses: [] }); -}); - -ava('SettingsFolder#reset (Events | Not Exists)', async (test): Promise => { - test.plan(1); - - const { client, settings } = test.context; - await settings.sync(); - - client.once('settingsCreate', () => test.fail()); - client.once('settingsUpdate', () => test.fail()); - test.deepEqual(await settings.reset('count'), []); -}); - -ava('SettingsFolder#reset (Events | Exists)', async (test): Promise => { - test.plan(9); - - const { client, settings, gateway, provider, schema } = test.context; - await provider.create(gateway.name, settings.id, { count: 64 }); - await settings.sync(); - - const schemaEntry = schema.get('count') as SchemaEntry; - client.once('settingsCreate', () => test.fail()); - client.once('settingsUpdate', (emittedSettings: Settings, changes: KeyedObject, context: SettingsUpdateContext) => { - test.is(emittedSettings, settings); - test.deepEqual(changes, { count: null }); - test.is(context.changes.length, 1); - test.is(context.changes[0].entry, schemaEntry); - test.is(context.changes[0].previous, 64); - test.is(context.changes[0].next, schemaEntry.default); - test.is(context.extraContext, undefined); - test.is(context.guild, null); - test.is(context.language, client.languages.get('en-US')); - }); - await settings.reset('count'); -}); - -ava('SettingsFolder#reset (Events + Extra | Exists)', async (test): Promise => { - test.plan(9); - - const { client, settings, gateway, provider, schema } = test.context; - await provider.create(gateway.name, settings.id, { count: 64 }); - await settings.sync(); - - const extraContext = Symbol('Hello!'); - const schemaEntry = schema.get('count') as SchemaEntry; - client.once('settingsCreate', () => test.fail()); - client.once('settingsUpdate', (emittedSettings: Settings, changes: KeyedObject, context: SettingsUpdateContext) => { - test.is(emittedSettings, settings); - test.deepEqual(changes, { count: null }); - test.is(context.changes.length, 1); - test.is(context.changes[0].entry, schemaEntry); - test.is(context.changes[0].previous, 64); - test.is(context.changes[0].next, schemaEntry.default); - test.is(context.extraContext, extraContext); - test.is(context.guild, null); - test.is(context.language, client.languages.get('en-US')); - }); - await settings.reset('count', { extraContext }); -}); - -ava('SettingsFolder#reset (Uninitialized)', async (test): Promise => { - test.plan(1); - - const settings = new SettingsFolder(new Schema()); - await test.throwsAsync(() => settings.reset(), { message: 'Cannot reset keys from a non-ready settings instance.' }); -}); - -ava('SettingsFolder#reset (Unsynchronized)', async (test): Promise => { - test.plan(1); - - const { settings } = test.context; - await test.throwsAsync(() => settings.reset(), { message: 'Cannot reset keys from a pending to synchronize settings instance. Perhaps you want to call `sync()` first.' }); -}); - -ava('SettingsFolder#reset (Invalid Key)', async (test): Promise => { - test.plan(1); - - const { settings, gateway, provider } = test.context; - await provider.create(gateway.name, settings.id, { messages: { hello: 'world' } }); - await settings.sync(); - try { - await settings.reset('invalid.path'); - test.fail('This Settings#reset call must error.'); - } catch (error) { - test.is(error, '[SETTING_GATEWAY_KEY_NOEXT]: invalid.path'); - } -}); - -ava('SettingsFolder#reset (Unconfigurable)', async (test): Promise => { - test.plan(1); - - const { settings, gateway, provider } = test.context; - await provider.create(gateway.name, settings.id, { count: 64 }); - await settings.sync(); - try { - await settings.reset('count', { onlyConfigurable: true }); - test.fail('This Settings#reset call must error.'); - } catch (error) { - test.is(error, '[SETTING_GATEWAY_UNCONFIGURABLE_KEY]: count'); - } -}); - -ava('SettingsFolder#update (Single)', async (test): Promise => { - test.plan(8); - - const { settings, gateway, schema, provider } = test.context; - await settings.sync(); - - test.is(settings.existenceStatus, SettingsExistenceStatus.NotExists); - const results = await settings.update('count', 2); - test.is(results.length, 1); - test.is(results[0].previous, null); - test.is(results[0].next, 2); - test.is(results[0].entry, schema.get('count') as SchemaEntry); - test.is(settings.get('count'), 2); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, count: 2 }); - test.is(settings.existenceStatus, SettingsExistenceStatus.Exists); -}); - -ava('SettingsFolder#update (Multiple)', async (test): Promise => { - test.plan(8); - - const { settings, gateway, schema, provider } = test.context; - await settings.sync(); - - const results = await settings.update([['count', 6], ['uses', [4]]]); - test.is(results.length, 2); - - // count - test.is(results[0].previous, null); - test.is(results[0].next, 6); - test.is(results[0].entry, schema.get('count') as SchemaEntry); - - // uses - test.deepEqual(results[1].previous, []); - test.deepEqual(results[1].next, [4]); - test.is(results[1].entry, schema.get('uses') as SchemaEntry); - - // persistence - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, count: 6, uses: [4] }); -}); - -ava('SettingsFolder#update (Multiple | Object)', async (test): Promise => { - test.plan(8); - - const { settings, gateway, schema, provider } = test.context; - await settings.sync(); - - const results = await settings.update({ count: 6, uses: [4] }); - test.is(results.length, 2); - - // count - test.is(results[0].previous, null); - test.is(results[0].next, 6); - test.is(results[0].entry, schema.get('count') as SchemaEntry); - - // uses - test.deepEqual(results[1].previous, []); - test.deepEqual(results[1].next, [4]); - test.is(results[1].entry, schema.get('uses') as SchemaEntry); - - // persistence - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, count: 6, uses: [4] }); -}); - -ava('SettingsFolder#update (Folder)', async (test): Promise => { - test.plan(1); - - const { settings } = test.context; - await settings.sync(); - - try { - await settings.update('messages', 420); - test.fail('This Settings#update call must error.'); - } catch (error) { - test.is(error, '[SETTING_GATEWAY_CHOOSE_KEY]: ignoring hello'); - } -}); - -ava('SettingsFolder#update (Not Exists | Default Value)', async (test): Promise => { - test.plan(2); - - const { settings, gateway, provider } = test.context; - await settings.sync(); - - test.is(await provider.get(gateway.name, settings.id), null); - await settings.update('uses', null); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, uses: [] }); -}); - -ava('SettingsFolder#update (Inner-Folder | Not Exists | Default Value)', async (test): Promise => { - test.plan(2); - - const { settings, gateway, provider } = test.context; - await settings.sync(); - - test.is(await provider.get(gateway.name, settings.id), null); - const settingsFolder = settings.get('messages') as SettingsFolder; - await settingsFolder.update('hello', null); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: { hello: null } }); -}); - -ava('SettingsFolder#update (Inner-Folder | Exists)', async (test): Promise => { - test.plan(5); - - const { settings, gateway, provider } = test.context; - await settings.sync(); - - const settingsFolder = settings.get('messages') as SettingsFolder; - const results = await settingsFolder.update('hello', 'world'); - test.is(results.length, 1); - test.is(results[0].previous, null); - test.is(results[0].next, 'world'); - test.is(results[0].entry, gateway.schema.get('messages.hello') as SchemaEntry); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, messages: { hello: 'world' } }); -}); - -ava('SettingsFolder#update (ArrayAction | Empty | Default)', async (test): Promise => { - test.plan(5); - - const { settings, gateway, provider } = test.context; - await settings.sync(); - - const schemaEntry = gateway.schema.get('uses') as SchemaEntry; - const results = await settings.update('uses', [1, 2]); - test.is(results.length, 1); - test.is(results[0].previous, schemaEntry.default); - test.deepEqual(results[0].next, [1, 2]); - test.is(results[0].entry, schemaEntry); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, uses: [1, 2] }); -}); - -ava('SettingsFolder#update (ArrayAction | Filled | Default)', async (test): Promise => { - test.plan(6); - - const { settings, gateway, schema, provider } = test.context; - await provider.create(gateway.name, settings.id, { uses: [1, 2, 4] }); - await settings.sync(); - - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, uses: [1, 2, 4] }); - const results = await settings.update('uses', [1, 2, 4]); - test.is(results.length, 1); - test.deepEqual(results[0].previous, [1, 2, 4]); - test.deepEqual(results[0].next, []); - test.is(results[0].entry, schema.get('uses') as SchemaEntry); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, uses: [] }); -}); - -ava('SettingsFolder#update (ArrayAction | Empty | Auto)', async (test): Promise => { - test.plan(5); - - const { settings, gateway, provider } = test.context; - await settings.sync(); - - const schemaEntry = gateway.schema.get('uses') as SchemaEntry; - const results = await settings.update('uses', [1, 2], { arrayAction: 'auto' }); - test.is(results.length, 1); - test.is(results[0].previous, schemaEntry.default); - test.deepEqual(results[0].next, [1, 2]); - test.is(results[0].entry, schemaEntry); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, uses: [1, 2] }); -}); - -ava('SettingsFolder#update (ArrayAction | Filled | Auto)', async (test): Promise => { - test.plan(6); - - const { settings, gateway, schema, provider } = test.context; - await provider.create(gateway.name, settings.id, { uses: [1, 2, 4] }); - await settings.sync(); - - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, uses: [1, 2, 4] }); - const results = await settings.update('uses', [1, 2, 4], { arrayAction: 'auto' }); - test.is(results.length, 1); - test.deepEqual(results[0].previous, [1, 2, 4]); - test.deepEqual(results[0].next, []); - test.is(results[0].entry, schema.get('uses') as SchemaEntry); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, uses: [] }); -}); - -ava('SettingsFolder#update (ArrayAction | Empty | Add)', async (test): Promise => { - test.plan(5); - - const { settings, gateway, provider } = test.context; - await settings.sync(); - - const schemaEntry = gateway.schema.get('uses') as SchemaEntry; - const results = await settings.update('uses', [1, 2], { arrayAction: 'add' }); - test.is(results.length, 1); - test.is(results[0].previous, schemaEntry.default); - test.deepEqual(results[0].next, [1, 2]); - test.is(results[0].entry, schemaEntry); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, uses: [1, 2] }); -}); - -ava('SettingsFolder#update (ArrayAction | Filled | Add)', async (test): Promise => { - test.plan(6); - - const { settings, gateway, schema, provider } = test.context; - await provider.create(gateway.name, settings.id, { uses: [1, 2, 4] }); - await settings.sync(); - - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, uses: [1, 2, 4] }); - const results = await settings.update('uses', [3, 5, 6], { arrayAction: 'add' }); - test.is(results.length, 1); - test.deepEqual(results[0].previous, [1, 2, 4]); - test.deepEqual(results[0].next, [1, 2, 4, 3, 5, 6]); - test.is(results[0].entry, schema.get('uses') as SchemaEntry); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, uses: [1, 2, 4, 3, 5, 6] }); -}); - -ava('SettingsFolder#update (ArrayAction | Empty | Remove)', async (test): Promise => { - test.plan(2); - - const { settings, provider, gateway } = test.context; - await settings.sync(); - await test.throwsAsync(() => settings.update('uses', [1, 2], { arrayAction: 'remove' }), { message: '[SETTING_GATEWAY_MISSING_VALUE]: uses 1' }); - test.is(await provider.get(gateway.name, settings.id), null); -}); - -ava('SettingsFolder#update (ArrayAction | Filled | Remove)', async (test): Promise => { - test.plan(5); - - const { settings, gateway, provider } = test.context; - await provider.create(gateway.name, settings.id, { uses: [1, 2, 4] }); - await settings.sync(); - - const schemaEntry = gateway.schema.get('uses') as SchemaEntry; - const results = await settings.update('uses', [1, 2], { arrayAction: 'remove' }); - test.is(results.length, 1); - test.deepEqual(results[0].previous, [1, 2, 4]); - test.deepEqual(results[0].next, [4]); - test.is(results[0].entry, schemaEntry); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, uses: [4] }); -}); - -ava('SettingsFolder#update (ArrayAction | Filled | Remove With Nulls)', async (test): Promise => { - test.plan(5); - - const { settings, gateway, provider } = test.context; - await provider.create(gateway.name, settings.id, { uses: [1, 2, 3, 4] }); - await settings.sync(); - - const schemaEntry = gateway.schema.get('uses') as SchemaEntry; - const results = await settings.update('uses', [null, null], { arrayAction: 'remove', arrayIndex: 1 }); - test.is(results.length, 1); - test.deepEqual(results[0].previous, [1, 2, 3, 4]); - test.deepEqual(results[0].next, [1, 4]); - test.is(results[0].entry, schemaEntry); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, uses: [1, 4] }); -}); - -ava('SettingsFolder#update (ArrayAction | Empty | Overwrite)', async (test): Promise => { - test.plan(5); - - const { settings, gateway, provider } = test.context; - await settings.sync(); - - const schemaEntry = gateway.schema.get('uses') as SchemaEntry; - const results = await settings.update('uses', [1, 2, 4], { arrayAction: 'overwrite' }); - test.is(results.length, 1); - test.is(results[0].previous, schemaEntry.default); - test.deepEqual(results[0].next, [1, 2, 4]); - test.is(results[0].entry, schemaEntry); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, uses: [1, 2, 4] }); -}); - -ava('SettingsFolder#update (ArrayAction | Filled | Overwrite)', async (test): Promise => { - test.plan(6); - - const { settings, gateway, schema, provider } = test.context; - await provider.create(gateway.name, settings.id, { uses: [1, 2, 4] }); - await settings.sync(); - - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, uses: [1, 2, 4] }); - const results = await settings.update('uses', [3, 5, 6], { arrayAction: 'overwrite' }); - test.is(results.length, 1); - test.deepEqual(results[0].previous, [1, 2, 4]); - test.deepEqual(results[0].next, [3, 5, 6]); - test.is(results[0].entry, schema.get('uses') as SchemaEntry); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, uses: [3, 5, 6] }); -}); - -ava('SettingsFolder#update (ArrayIndex | Empty | Auto)', async (test): Promise => { - test.plan(5); - - const { settings, gateway, schema, provider } = test.context; - await settings.sync(); - - const schemaEntry = schema.get('uses') as SchemaEntry; - const results = await settings.update('uses', [1, 2, 3], { arrayIndex: 0 }); - test.is(results.length, 1); - test.is(results[0].previous, schemaEntry.default); - test.deepEqual(results[0].next, [1, 2, 3]); - test.is(results[0].entry, schemaEntry); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, uses: [1, 2, 3] }); -}); - -ava('SettingsFolder#update (ArrayIndex | Filled | Auto)', async (test): Promise => { - test.plan(5); - - const { settings, gateway, schema, provider } = test.context; - await provider.create(gateway.name, settings.id, { uses: [1, 2, 4] }); - await settings.sync(); - - const schemaEntry = schema.get('uses') as SchemaEntry; - const results = await settings.update('uses', [5, 6], { arrayIndex: 0 }); - test.is(results.length, 1); - test.deepEqual(results[0].previous, [1, 2, 4]); - test.deepEqual(results[0].next, [5, 6, 4]); - test.is(results[0].entry, schemaEntry); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, uses: [5, 6, 4] }); -}); - -ava('SettingsFolder#update (ArrayIndex | Empty | Add)', async (test): Promise => { - test.plan(5); - - const { settings, gateway, schema, provider } = test.context; - await settings.sync(); - - const schemaEntry = schema.get('uses') as SchemaEntry; - const results = await settings.update('uses', [1, 2, 3], { arrayIndex: 0, arrayAction: 'add' }); - test.is(results.length, 1); - test.is(results[0].previous, schemaEntry.default); - test.deepEqual(results[0].next, [1, 2, 3]); - test.is(results[0].entry, schemaEntry); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, uses: [1, 2, 3] }); -}); - -ava('SettingsFolder#update (ArrayIndex | Filled | Add)', async (test): Promise => { - test.plan(5); - - const { settings, gateway, schema, provider } = test.context; - await provider.create(gateway.name, settings.id, { uses: [1, 2, 4] }); - await settings.sync(); - - const schemaEntry = schema.get('uses') as SchemaEntry; - const results = await settings.update('uses', [5, 6], { arrayIndex: 0, arrayAction: 'add' }); - test.is(results.length, 1); - test.deepEqual(results[0].previous, [1, 2, 4]); - test.deepEqual(results[0].next, [5, 6, 1, 2, 4]); - test.is(results[0].entry, schemaEntry); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, uses: [5, 6, 1, 2, 4] }); -}); - -ava('SettingsFolder#update (ArrayIndex | Filled | Add | Error)', async (test): Promise => { - test.plan(2); - - const { settings, gateway, provider } = test.context; - await provider.create(gateway.name, settings.id, { uses: [1, 2, 4] }); - await settings.sync(); - - await test.throwsAsync(() => settings.update('uses', 4, { arrayAction: 'add' }), { message: '[SETTING_GATEWAY_DUPLICATE_VALUE]: uses 4' }); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, uses: [1, 2, 4] }); -}); - -ava('SettingsFolder#update (ArrayIndex | Empty | Remove)', async (test): Promise => { - test.plan(5); - - const { settings, gateway, schema, provider } = test.context; - await settings.sync(); - - const schemaEntry = schema.get('uses') as SchemaEntry; - const results = await settings.update('uses', [1, 2], { arrayIndex: 0, arrayAction: 'remove' }); - test.is(results.length, 1); - test.is(results[0].previous, schemaEntry.default); - test.deepEqual(results[0].next, []); - test.is(results[0].entry, schemaEntry); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, uses: [] }); -}); - -ava('SettingsFolder#update (ArrayIndex | Filled | Remove)', async (test): Promise => { - test.plan(5); - - const { settings, gateway, schema, provider } = test.context; - await provider.create(gateway.name, settings.id, { uses: [1, 2, 4] }); - await settings.sync(); - - const schemaEntry = schema.get('uses') as SchemaEntry; - const results = await settings.update('uses', [1, 2], { arrayIndex: 1, arrayAction: 'remove' }); - test.is(results.length, 1); - test.deepEqual(results[0].previous, [1, 2, 4]); - test.deepEqual(results[0].next, [1]); - test.is(results[0].entry, schemaEntry); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, uses: [1] }); -}); - -ava('SettingsFolder#update (ArrayIndex | Filled | Remove | Error)', async (test): Promise => { - test.plan(2); - - const { settings, gateway, provider } = test.context; - await provider.create(gateway.name, settings.id, { uses: [1, 2, 4] }); - await settings.sync(); - - await test.throwsAsync(() => settings.update('uses', 3, { arrayAction: 'remove' }), { message: '[SETTING_GATEWAY_MISSING_VALUE]: uses 3' }); - test.deepEqual(await provider.get(gateway.name, settings.id), { id: settings.id, uses: [1, 2, 4] }); -}); - -ava('SettingsFolder#update (Events | Not Exists)', async (test): Promise => { - test.plan(9); - - const { client, schema, settings } = test.context; - await settings.sync(); - - const schemaEntry = schema.get('count') as SchemaEntry; - client.once('settingsCreate', (emittedSettings: Settings, changes: KeyedObject, context: SettingsUpdateContext) => { - test.is(emittedSettings, settings); - test.deepEqual(changes, { count: 64 }); - test.is(context.changes.length, 1); - test.is(context.changes[0].entry, schemaEntry); - test.is(context.changes[0].previous, schemaEntry.default); - test.is(context.changes[0].next, 64); - test.is(context.extraContext, undefined); - test.is(context.guild, null); - test.is(context.language, client.languages.get('en-US')); - }); - client.once('settingsUpdate', () => test.fail()); - await settings.update('count', 64); -}); - -ava('SettingsFolder#update (Events | Exists | Simple)', async (test): Promise => { - test.plan(9); - - const { client, settings, gateway, provider, schema } = test.context; - await provider.create(gateway.name, settings.id, { count: 64 }); - await settings.sync(); - - const schemaEntry = schema.get('count') as SchemaEntry; - client.once('settingsCreate', () => test.fail()); - client.once('settingsUpdate', (emittedSettings: Settings, changes: KeyedObject, context: SettingsUpdateContext) => { - test.is(emittedSettings, settings); - test.deepEqual(changes, { count: 420 }); - test.is(context.changes.length, 1); - test.is(context.changes[0].entry, schemaEntry); - test.is(context.changes[0].previous, 64); - test.is(context.changes[0].next, 420); - test.is(context.extraContext, undefined); - test.is(context.guild, null); - test.is(context.language, client.languages.get('en-US')); - }); - await settings.update('count', 420); -}); - -ava('SettingsFolder#update (Events | Exists | Array Overload | Options)', async (test): Promise => { - test.plan(9); - - const { client, settings, gateway, provider, schema } = test.context; - await provider.create(gateway.name, settings.id, { count: 64 }); - await settings.sync(); - - const schemaEntry = schema.get('count') as SchemaEntry; - client.once('settingsCreate', () => test.fail()); - client.once('settingsUpdate', (emittedSettings: Settings, changes: KeyedObject, context: SettingsUpdateContext) => { - test.is(emittedSettings, settings); - test.deepEqual(changes, { count: 420 }); - test.is(context.changes.length, 1); - test.is(context.changes[0].entry, schemaEntry); - test.is(context.changes[0].previous, 64); - test.is(context.changes[0].next, 420); - test.is(context.extraContext, 'Hello!'); - test.is(context.guild, null); - test.is(context.language, client.languages.get('en-US')); - }); - await settings.update([['count', 420]], { extraContext: 'Hello!' }); -}); - -ava('SettingsFolder#update (Events | Exists | Object Overload | Options)', async (test): Promise => { - test.plan(9); - - const { client, settings, gateway, provider, schema } = test.context; - await provider.create(gateway.name, settings.id, { count: 64 }); - await settings.sync(); - - const schemaEntry = schema.get('count') as SchemaEntry; - client.once('settingsCreate', () => test.fail()); - client.once('settingsUpdate', (emittedSettings: Settings, changes: KeyedObject, context: SettingsUpdateContext) => { - test.is(emittedSettings, settings); - test.deepEqual(changes, { count: 420 }); - test.is(context.changes.length, 1); - test.is(context.changes[0].entry, schemaEntry); - test.is(context.changes[0].previous, 64); - test.is(context.changes[0].next, 420); - test.is(context.extraContext, 'Hello!'); - test.is(context.guild, null); - test.is(context.language, client.languages.get('en-US')); - }); - await settings.update({ count: 420 }, { extraContext: 'Hello!' }); -}); - -ava('SettingsFolder#update (Events + Extra | Not Exists)', async (test): Promise => { - test.plan(9); - - const { client, settings, schema } = test.context; - await settings.sync(); - - const extraContext = Symbol('Hello!'); - const schemaEntry = schema.get('count') as SchemaEntry; - client.once('settingsCreate', (emittedSettings: Settings, changes: KeyedObject, context: SettingsUpdateContext) => { - test.is(emittedSettings, settings); - test.deepEqual(changes, { count: 420 }); - test.is(context.changes.length, 1); - test.is(context.changes[0].entry, schemaEntry); - test.is(context.changes[0].previous, schemaEntry.default); - test.is(context.changes[0].next, 420); - test.is(context.extraContext, extraContext); - test.is(context.guild, null); - test.is(context.language, client.languages.get('en-US')); - }); - client.once('settingsUpdate', () => test.fail()); - await settings.update('count', 420, { extraContext }); -}); - -ava('SettingsFolder#update (Events + Extra | Exists)', async (test): Promise => { - test.plan(9); - - const { client, settings, gateway, provider, schema } = test.context; - await provider.create(gateway.name, settings.id, { count: 64 }); - await settings.sync(); - - const extraContext = Symbol('Hello!'); - const schemaEntry = schema.get('count') as SchemaEntry; - client.once('settingsCreate', () => test.fail()); - client.once('settingsUpdate', (emittedSettings: Settings, changes: KeyedObject, context: SettingsUpdateContext) => { - test.is(emittedSettings, settings); - test.deepEqual(changes, { count: 420 }); - test.is(context.changes.length, 1); - test.is(context.changes[0].entry, schemaEntry); - test.is(context.changes[0].previous, 64); - test.is(context.changes[0].next, 420); - test.is(context.extraContext, extraContext); - test.is(context.guild, null); - test.is(context.language, client.languages.get('en-US')); - }); - await settings.update('count', 420, { extraContext }); -}); - -ava('SettingsFolder#update (Uninitialized)', async (test): Promise => { - test.plan(1); - - const settings = new SettingsFolder(new Schema()); - await test.throwsAsync(() => settings.update('count', 6), { message: 'Cannot update keys from a non-ready settings instance.' }); -}); - -ava('SettingsFolder#update (Unsynchronized)', async (test): Promise => { - test.plan(1); - - const { settings } = test.context; - await test.throwsAsync(() => settings.update('count', 6), { message: 'Cannot update keys from a pending to synchronize settings instance. Perhaps you want to call `sync()` first.' }); -}); - -ava('SettingsFolder#update (Invalid Key)', async (test): Promise => { - test.plan(1); - - const { settings, gateway, provider } = test.context; - await provider.create(gateway.name, settings.id, { messages: { hello: 'world' } }); - await settings.sync(); - try { - await settings.update('invalid.path', 420); - test.fail('This Settings#update call must error.'); - } catch (error) { - test.is(error, '[SETTING_GATEWAY_KEY_NOEXT]: invalid.path'); - } -}); - -ava('SettingsFolder#update (Unconfigurable)', async (test): Promise => { - test.plan(1); - - const { settings, gateway, provider } = test.context; - await provider.create(gateway.name, settings.id, { count: 64 }); - await settings.sync(); - try { - await settings.update('count', 4, { onlyConfigurable: true }); - test.fail('This Settings#update call must error.'); - } catch (error) { - test.is(error, '[SETTING_GATEWAY_UNCONFIGURABLE_KEY]: count'); - } -}); - -ava('SettingsFolder#toJSON', async (test): Promise => { - test.plan(2); - - const { settings, gateway, provider } = test.context; - - // Non-synced entry should have schema defaults - test.deepEqual(settings.toJSON(), { uses: [], count: null, messages: { hello: null, ignoring: { amount: null } } }); - - await provider.create(gateway.name, settings.id, { count: 123, messages: { ignoring: { amount: 420 } } }); - await settings.sync(); - - // Synced entry should use synced values or schema defaults - test.deepEqual(settings.toJSON(), { uses: [], count: 123, messages: { hello: null, ignoring: { amount: 420 } } }); -}); diff --git a/test/lib/MockLanguage.ts b/test/lib/MockLanguage.ts index 575c227c7b..f83ee282f8 100644 --- a/test/lib/MockLanguage.ts +++ b/test/lib/MockLanguage.ts @@ -6,11 +6,9 @@ export class MockLanguage extends Language { DEFAULT: (key: string, ...args: unknown[]): string => `[DEFAULT]: ${key} ${args.join(' ')}`, SETTING_GATEWAY_KEY_NOEXT: (key: string): string => `[SETTING_GATEWAY_KEY_NOEXT]: ${key}`, SETTING_GATEWAY_CHOOSE_KEY: (keys: string[]): string => `[SETTING_GATEWAY_CHOOSE_KEY]: ${keys.join(' ')}`, - SETTING_GATEWAY_UNCONFIGURABLE_FOLDER: '[SETTING_GATEWAY_UNCONFIGURABLE_FOLDER]', - SETTING_GATEWAY_UNCONFIGURABLE_KEY: (key: string): string => `[SETTING_GATEWAY_UNCONFIGURABLE_KEY]: ${key}`, - SETTING_GATEWAY_MISSING_VALUE: (entry: SchemaEntry, value: string): string => `[SETTING_GATEWAY_MISSING_VALUE]: ${entry.path} ${value}`, - SETTING_GATEWAY_DUPLICATE_VALUE: (entry: SchemaEntry, value: string): string => `[SETTING_GATEWAY_DUPLICATE_VALUE]: ${entry.path} ${value}`, - SETTING_GATEWAY_INVALID_FILTERED_VALUE: (entry: SchemaEntry, value: unknown): string => `[SETTING_GATEWAY_INVALID_FILTERED_VALUE]: ${entry.path} ${value}`, + SETTING_GATEWAY_MISSING_VALUE: (entry: SchemaEntry, value: string): string => `[SETTING_GATEWAY_MISSING_VALUE]: ${entry.key} ${value}`, + SETTING_GATEWAY_DUPLICATE_VALUE: (entry: SchemaEntry, value: string): string => `[SETTING_GATEWAY_DUPLICATE_VALUE]: ${entry.key} ${value}`, + SETTING_GATEWAY_INVALID_FILTERED_VALUE: (entry: SchemaEntry, value: unknown): string => `[SETTING_GATEWAY_INVALID_FILTERED_VALUE]: ${entry.key} ${value}`, RESOLVER_MINMAX_EXACTLY: (key: string, value: number, inclusive: boolean): string => `[RESOLVER_MINMAX_EXACTLY]: ${key} ${value} ${inclusive}`, RESOLVER_MINMAX_BOTH: (key: string, minimum: number, maximum: number, inclusive: boolean): string => `[RESOLVER_MINMAX_BOTH]: ${key} ${minimum} ${maximum} ${inclusive}`, RESOLVER_MINMAX_MIN: (key: string, minimum: number, inclusive: number): string => `[RESOLVER_MINMAX_MIN]: ${key} ${minimum} ${inclusive}`,