Setup and migrate to Prisma (#456)

This commit is contained in:
Peerawas Archavanuntakun 2022-01-06 03:30:32 +07:00 committed by GitHub
parent 129d121364
commit 51d378e4cb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 605 additions and 273 deletions

View file

@ -2,7 +2,7 @@ import {Client, Message, Collection} from 'discord.js';
import {inject, injectable} from 'inversify';
import ora from 'ora';
import {TYPES} from './types.js';
import {Settings, Shortcut} from './models/index.js';
import {prisma} from './utils/db.js';
import container from './inversify.config.js';
import Command from './commands/index.js';
import debug from './utils/debug.js';
@ -21,7 +21,11 @@ export default class {
private readonly token: string;
private readonly commands!: Collection<string, Command>;
constructor(@inject(TYPES.Client) client: Client, @inject(TYPES.Services.NaturalLanguage) naturalLanguage: NaturalLanguage, @inject(TYPES.Config) config: Config) {
constructor(
@inject(TYPES.Client) client: Client,
@inject(TYPES.Services.NaturalLanguage) naturalLanguage: NaturalLanguage,
@inject(TYPES.Config) config: Config,
) {
this.client = client;
this.naturalLanguage = naturalLanguage;
this.token = config.DISCORD_TOKEN;
@ -33,7 +37,9 @@ export default class {
container.getAll<Command>(TYPES.Command).forEach(command => {
const commandNames = [command.name, ...command.aliases];
commandNames.forEach(commandName => this.commands.set(commandName, command));
commandNames.forEach(commandName =>
this.commands.set(commandName, command),
);
});
this.client.on('messageCreate', async (msg: Message) => {
@ -42,7 +48,11 @@ export default class {
return;
}
const settings = await Settings.findByPk(msg.guild.id);
const settings = await prisma.setting.findUnique({
where: {
guildId: msg.guild.id,
},
});
if (!settings) {
// Got into a bad state, send owner welcome message
@ -52,27 +62,42 @@ export default class {
const {prefix, channel} = settings;
if (!msg.content.startsWith(prefix) && !msg.author.bot && msg.channel.id === channel && await this.naturalLanguage.execute(msg)) {
if (
!msg.content.startsWith(prefix)
&& !msg.author.bot
&& msg.channel.id === channel
&& (await this.naturalLanguage.execute(msg))
) {
// Natural language command handled message
return;
}
if (!msg.content.startsWith(prefix) || msg.author.bot || msg.channel.id !== channel) {
if (
!msg.content.startsWith(prefix)
|| msg.author.bot
|| msg.channel.id !== channel
) {
return;
}
let args = msg.content.slice(prefix.length).split(/ +/);
const command = args.shift()!.toLowerCase();
// Get possible shortcut
const shortcut = await Shortcut.findOne({where: {guildId: msg.guild.id, shortcut: command}});
const shortcut = await prisma.shortcut.findFirst({
where: {
guildId: msg.guild.id,
shortcut: command,
},
});
let handler: Command;
if (this.commands.has(command)) {
handler = this.commands.get(command)!;
} else if (shortcut) {
const possibleHandler = this.commands.get(shortcut.command.split(' ')[0]);
const possibleHandler = this.commands.get(
shortcut.command.split(' ')[0],
);
if (possibleHandler) {
handler = possibleHandler;
@ -93,7 +118,9 @@ export default class {
await handler.execute(msg, args);
} catch (error: unknown) {
debug(error);
await msg.channel.send(errorMsg((error as Error).message.toLowerCase()));
await msg.channel.send(
errorMsg((error as Error).message.toLowerCase()),
);
}
});
@ -102,7 +129,10 @@ export default class {
this.client.on('ready', () => {
debug(generateDependencyReport());
spinner.succeed(`Ready! Invite the bot with https://discordapp.com/oauth2/authorize?client_id=${this.client.user?.id ?? ''}&scope=bot&permissions=36752448`);
spinner.succeed(
`Ready! Invite the bot with https://discordapp.com/oauth2/authorize?client_id=${this.client.user?.id ?? ''
}&scope=bot&permissions=36752448`,
);
});
this.client.on('error', console.error);

View file

@ -1,8 +1,8 @@
import {TextChannel, Message, GuildChannel, ThreadChannel} from 'discord.js';
import {injectable} from 'inversify';
import {Settings} from '../models/index.js';
import errorMsg from '../utils/error-msg.js';
import Command from '.';
import {prisma} from '../utils/db.js';
@injectable()
export default class implements Command {
@ -17,9 +17,13 @@ export default class implements Command {
public async execute(msg: Message, args: string []): Promise<void> {
if (args.length === 0) {
// Show current settings
const settings = await Settings.findByPk(msg.guild!.id);
const settings = await prisma.setting.findUnique({
where: {
guildId: msg.guild!.id,
},
});
if (settings) {
if (settings?.channel) {
let response = `prefix: \`${settings.prefix}\`\n`;
// eslint-disable-next-line @typescript-eslint/no-base-to-string
response += `channel: ${msg.guild!.channels.cache.get(settings.channel)!.toString()}\n`;
@ -47,7 +51,14 @@ export default class implements Command {
case 'prefix': {
const newPrefix = args[1];
await Settings.update({prefix: newPrefix}, {where: {guildId: msg.guild!.id}});
await prisma.setting.update({
where: {
guildId: msg.guild!.id,
},
data: {
prefix: newPrefix,
},
});
await msg.channel.send(`👍 prefix updated to \`${newPrefix}\``);
break;
@ -63,7 +74,14 @@ export default class implements Command {
}
if (channel && channel.type === 'GUILD_TEXT') {
await Settings.update({channel: channel.id}, {where: {guildId: msg.guild!.id}});
await prisma.setting.update({
where: {
guildId: msg.guild!.id,
},
data: {
channel: channel.id,
},
});
await Promise.all([
(channel as TextChannel).send('hey apparently I\'m bound to this channel now'),
@ -83,7 +101,15 @@ export default class implements Command {
return;
}
await Settings.update({playlistLimit}, {where: {guildId: msg.guild!.id}});
await prisma.setting.update({
where: {
guildId: msg.guild!.id,
},
data: {
playlistLimit,
},
});
await msg.channel.send(`👍 playlist-limit updated to ${playlistLimit}`);
break;
}

View file

@ -2,8 +2,8 @@ import {Message, Util} from 'discord.js';
import {injectable} from 'inversify';
import Command from '.';
import {TYPES} from '../types.js';
import {Settings} from '../models/index.js';
import container from '../inversify.config.js';
import {prisma} from '../utils/db.js';
@injectable()
export default class implements Command {
@ -21,7 +21,11 @@ export default class implements Command {
this.commands = container.getAll<Command>(TYPES.Command);
}
const settings = await Settings.findOne({where: {guildId: msg.guild!.id}});
const settings = await prisma.setting.findUnique({
where: {
guildId: msg.guild!.id,
},
});
if (!settings) {
return;

View file

@ -10,7 +10,7 @@ import LoadingMessage from '../utils/loading-message.js';
import errorMsg from '../utils/error-msg.js';
import Command from '.';
import GetSongs from '../services/get-songs.js';
import Settings from '../models/settings.js';
import {prisma} from '../utils/db.js';
@injectable()
export default class implements Command {
@ -41,8 +41,15 @@ export default class implements Command {
// eslint-disable-next-line complexity
public async execute(msg: Message, args: string[]): Promise<void> {
const [targetVoiceChannel] = getMemberVoiceChannel(msg.member!) ?? getMostPopularVoiceChannel(msg.guild!);
const settings = await Settings.findByPk(msg.guild!.id);
const {playlistLimit} = settings!;
const setting = await prisma.setting.findUnique({
where: {
guildId: msg.guild!.id,
}});
if (!setting) {
throw new Error(`Couldn't find settings for guild ${msg.guild!.id}`);
}
const {playlistLimit} = setting;
const res = new LoadingMessage(msg.channel as TextChannel);
await res.start();

View file

@ -1,8 +1,8 @@
import {Message} from 'discord.js';
import {injectable} from 'inversify';
import {Shortcut, Settings} from '../models/index.js';
import errorMsg from '../utils/error-msg.js';
import Command from '.';
import {prisma} from '../utils/db.js';
@injectable()
export default class implements Command {
@ -18,7 +18,11 @@ export default class implements Command {
public async execute(msg: Message, args: string []): Promise<void> {
if (args.length === 0) {
// Get shortcuts for guild
const shortcuts = await Shortcut.findAll({where: {guildId: msg.guild!.id}});
const shortcuts = await prisma.shortcut.findMany({
where: {
guildId: msg.guild!.id,
},
});
if (shortcuts.length === 0) {
await msg.channel.send('no shortcuts exist');
@ -26,7 +30,11 @@ export default class implements Command {
}
// Get prefix for guild
const settings = await Settings.findOne({where: {guildId: msg.guild!.id}});
const settings = await prisma.setting.findUnique({
where: {
guildId: msg.guild!.id,
},
});
if (!settings) {
return;
@ -48,7 +56,12 @@ export default class implements Command {
switch (action) {
case 'set': {
const shortcut = await Shortcut.findOne({where: {guildId: msg.guild!.id, shortcut: shortcutName}});
const shortcut = await prisma.shortcut.findFirst({
where: {
guildId: msg.guild!.id,
shortcut: shortcutName,
},
});
const command = args.slice(2).join(' ');
@ -60,10 +73,15 @@ export default class implements Command {
return;
}
await shortcut.update(newShortcut);
await prisma.shortcut.update({
where: {
id: shortcut.id,
},
data: newShortcut,
});
await msg.channel.send('shortcut updated');
} else {
await Shortcut.create(newShortcut);
await prisma.shortcut.create({data: newShortcut});
await msg.channel.send('shortcut created');
}
@ -72,7 +90,12 @@ export default class implements Command {
case 'delete': {
// Check if shortcut exists
const shortcut = await Shortcut.findOne({where: {guildId: msg.guild!.id, shortcut: shortcutName}});
const shortcut = await prisma.shortcut.findFirst({
where: {
guildId: msg.guild!.id,
shortcut: shortcutName,
},
});
if (!shortcut) {
await msg.channel.send(errorMsg('shortcut doesn\'t exist'));
@ -85,7 +108,11 @@ export default class implements Command {
return;
}
await shortcut.destroy();
await prisma.shortcut.delete({
where: {
id: shortcut.id,
},
});
await msg.channel.send('shortcut deleted');

View file

@ -1,13 +1,24 @@
import {Guild, TextChannel, Message, MessageReaction, User} from 'discord.js';
import emoji from 'node-emoji';
import pEvent from 'p-event';
import {Settings} from '../models/index.js';
import {chunk} from '../utils/arrays.js';
import {prisma} from '../utils/db.js';
const DEFAULT_PREFIX = '!';
export default async (guild: Guild): Promise<void> => {
await Settings.upsert({guildId: guild.id, prefix: DEFAULT_PREFIX});
await prisma.setting.upsert({
where: {
guildId: guild.id,
},
create: {
guildId: guild.id,
prefix: DEFAULT_PREFIX,
},
update: {
prefix: DEFAULT_PREFIX,
},
});
const owner = await guild.client.users.fetch(guild.ownerId);
@ -70,7 +81,15 @@ export default async (guild: Guild): Promise<void> => {
const prefixCharacter = prefixResponses.first()!.content;
// Save settings
await Settings.update({prefix: prefixCharacter, channel: chosenChannel.id}, {where: {guildId: guild.id}});
await prisma.setting.update({
where: {
guildId: guild.id,
},
data: {
channel: chosenChannel.id,
prefix: prefixCharacter,
},
});
// Send welcome
const boundChannel = guild.client.channels.cache.get(chosenChannel.id) as TextChannel;

View file

@ -1,28 +1,14 @@
import makeDir from 'make-dir';
import path from 'path';
import {makeLines} from 'nodesplash';
import container from './inversify.config.js';
import {TYPES} from './types.js';
import Bot from './bot.js';
import {sequelize} from './utils/db.js';
import Config from './services/config.js';
import FileCacheProvider from './services/file-cache.js';
import metadata from '../package.json';
const bot = container.get<Bot>(TYPES.Bot);
(async () => {
// Banner
console.log(makeLines({
user: 'codetheweb',
repository: 'muse',
version: metadata.version,
paypalUser: 'codetheweb',
githubSponsor: 'codetheweb',
madeByPrefix: 'Made with 🎶 by ',
}).join('\n'));
console.log('\n');
const startBot = async () => {
// Create data directories if necessary
const config = container.get<Config>(TYPES.Config);
@ -30,9 +16,9 @@ const bot = container.get<Bot>(TYPES.Bot);
await makeDir(config.CACHE_DIR);
await makeDir(path.join(config.CACHE_DIR, 'tmp'));
await sequelize.sync({alter: true});
await container.get<FileCacheProvider>(TYPES.FileCache).cleanup();
await bot.listen();
})();
};
export {startBot};

View file

@ -1,14 +0,0 @@
import {Table, Column, PrimaryKey, Model} from 'sequelize-typescript';
@Table
export default class FileCache extends Model {
@PrimaryKey
@Column
hash!: string;
@Column
bytes!: number;
@Column
accessedAt!: Date;
}

View file

@ -1,11 +0,0 @@
import FileCache from './file-cache.js';
import KeyValueCache from './key-value-cache.js';
import Settings from './settings.js';
import Shortcut from './shortcut.js';
export {
FileCache,
KeyValueCache,
Settings,
Shortcut,
};

View file

@ -1,15 +0,0 @@
import {Table, Column, PrimaryKey, Model} from 'sequelize-typescript';
import sequelize from 'sequelize';
@Table
export default class KeyValueCache extends Model {
@PrimaryKey
@Column
key!: string;
@Column(sequelize.TEXT)
value!: string;
@Column
expiresAt!: Date;
}

View file

@ -1,22 +0,0 @@
import {Table, Column, PrimaryKey, Model, Default} from 'sequelize-typescript';
@Table
export default class Settings extends Model {
@PrimaryKey
@Column
guildId!: string;
@Column
prefix!: string;
@Column
channel!: string;
@Default(false)
@Column
finishedSetup!: boolean;
@Default(50)
@Column
playlistLimit!: number;
}

View file

@ -1,23 +0,0 @@
import {Table, Column, PrimaryKey, Model, AutoIncrement, Index} from 'sequelize-typescript';
@Table
export default class Shortcut extends Model {
@PrimaryKey
@AutoIncrement
@Column
id!: number;
@Column
@Index
guildId!: string;
@Column
authorId!: string;
@Column
@Index
shortcut!: string;
@Column
command!: string;
}

View file

@ -0,0 +1,83 @@
// This script applies Prisma migrations
// and then starts Muse.
import dotenv from 'dotenv';
dotenv.config();
import {execa, ExecaError} from 'execa';
import {promises as fs} from 'fs';
import Prisma from '@prisma/client';
import ora from 'ora';
import {startBot} from '../index.js';
import logBanner from '../utils/log-banner.js';
import {createDatabasePath} from '../utils/create-database-url.js';
import {DATA_DIR} from '../services/config.js';
const client = new Prisma.PrismaClient();
const migrateFromSequelizeToPrisma = async () => {
await execa('prisma', ['migrate', 'resolve', '--applied', '20220101155430_migrate_from_sequelize'], {preferLocal: true});
};
const doesUserHaveExistingDatabase = async () => {
try {
await fs.access(createDatabasePath(DATA_DIR));
return true;
} catch {
return false;
}
};
const hasDatabaseBeenMigratedToPrisma = async () => {
try {
await client.$queryRaw`SELECT COUNT(id) FROM _prisma_migrations`;
} catch (error: unknown) {
if (error instanceof Prisma.Prisma.PrismaClientKnownRequestError && error.code === 'P2010') {
// Table doesn't exist
return false;
}
throw error;
}
return true;
};
(async () => {
// Banner
logBanner();
const spinner = ora('Applying database migrations...').start();
if (await doesUserHaveExistingDatabase()) {
if (!(await hasDatabaseBeenMigratedToPrisma())) {
try {
await migrateFromSequelizeToPrisma();
} catch (error) {
if ((error as ExecaError).stderr) {
spinner.fail('Failed to apply database migrations (going from Sequelize to Prisma):');
console.error((error as ExecaError).stderr);
process.exit(1);
} else {
throw error;
}
}
}
}
try {
await execa('prisma', ['migrate', 'deploy'], {preferLocal: true});
} catch (error: unknown) {
if ((error as ExecaError).stderr) {
spinner.fail('Failed to apply database migrations:');
console.error((error as ExecaError).stderr);
process.exit(1);
} else {
throw error;
}
}
spinner.succeed('Database migrations applied.');
await startBot();
})();

View file

@ -0,0 +1,13 @@
import {DATA_DIR} from '../services/config.js';
import createDatabaseUrl from '../utils/create-database-url.js';
import {execa} from 'execa';
process.env.DATABASE_URL = createDatabaseUrl(DATA_DIR);
(async () => {
await execa(process.argv[2], process.argv.slice(3), {
preferLocal: true,
stderr: process.stderr,
stdout: process.stdout,
});
})();

9
src/scripts/start.ts Normal file
View file

@ -0,0 +1,9 @@
// This script is mainly used during development.
// Starts Muse without applying database migrations.
import {startBot} from '../index.js';
import logBanner from '../utils/log-banner.js';
(async () => {
logBanner();
await startBot();
})();

View file

@ -1,4 +1,5 @@
import dotenv from 'dotenv';
import 'reflect-metadata';
import {injectable} from 'inversify';
import path from 'path';
import xbytes from 'xbytes';

View file

@ -1,12 +1,12 @@
import {promises as fs, createWriteStream} from 'fs';
import path from 'path';
import {inject, injectable} from 'inversify';
import sequelize from 'sequelize';
import {FileCache} from '../models/index.js';
import {TYPES} from '../types.js';
import Config from './config.js';
import PQueue from 'p-queue';
import debug from '../utils/debug.js';
import {prisma} from '../utils/db.js';
import {FileCache} from '@prisma/client';
@injectable()
export default class FileCacheProvider {
@ -23,7 +23,11 @@ export default class FileCacheProvider {
* @param hash lookup key
*/
async getPathFor(hash: string): Promise<string> {
const model = await FileCache.findByPk(hash);
const model = await prisma.fileCache.findUnique({
where: {
hash,
},
});
if (!model) {
throw new Error('File is not cached');
@ -34,12 +38,23 @@ export default class FileCacheProvider {
try {
await fs.access(resolvedPath);
} catch (_: unknown) {
await FileCache.destroy({where: {hash}});
await prisma.fileCache.delete({
where: {
hash,
},
});
throw new Error('File is not cached');
}
await model.update({accessedAt: new Date()});
await prisma.fileCache.update({
where: {
hash,
},
data: {
accessedAt: new Date(),
},
});
return resolvedPath;
}
@ -64,7 +79,13 @@ export default class FileCacheProvider {
try {
await fs.rename(tmpPath, finalPath);
await FileCache.create({hash, bytes: stats.size, accessedAt: new Date()});
await prisma.fileCache.create({
data: {
hash,
accessedAt: new Date(),
bytes: stats.size,
},
});
} catch (error) {
debug('Errored when moving a finished cache file:', error);
}
@ -100,14 +121,19 @@ export default class FileCacheProvider {
// Continue to evict until we're under the limit
/* eslint-disable no-await-in-loop */
while (totalSizeBytes > this.config.CACHE_LIMIT_IN_BYTES) {
const oldest = await FileCache.findOne({
order: [
['accessedAt', 'ASC'],
],
const oldest = await prisma.fileCache.findFirst({
orderBy: {
accessedAt: 'asc',
},
});
if (oldest) {
await oldest.destroy();
await prisma.fileCache.delete({
where: {
hash: oldest.hash,
},
});
await fs.unlink(path.join(this.config.CACHE_DIR, oldest.hash));
debug(`${oldest.hash} has been evicted`);
numOfEvictedFiles++;
@ -128,7 +154,11 @@ export default class FileCacheProvider {
// Check filesystem direction (do files exist on the disk but not in the database?)
for await (const dirent of await fs.opendir(this.config.CACHE_DIR)) {
if (dirent.isFile()) {
const model = await FileCache.findByPk(dirent.name);
const model = await prisma.fileCache.findUnique({
where: {
hash: dirent.name,
},
});
if (!model) {
debug(`${dirent.name} was present on disk but was not in the database. Removing from disk.`);
@ -145,7 +175,11 @@ export default class FileCacheProvider {
await fs.access(filePath);
} catch {
debug(`${model.hash} was present in database but was not on disk. Removing from database.`);
await model.destroy();
await prisma.fileCache.delete({
where: {
hash: model.hash,
},
});
}
}
}
@ -156,11 +190,12 @@ export default class FileCacheProvider {
* @returns the total size of the cache in bytes
*/
private async getDiskUsageInBytes() {
const [{dataValues: {totalSizeBytes}}] = await FileCache.findAll({
attributes: [
[sequelize.fn('sum', sequelize.col('bytes')), 'totalSizeBytes'],
],
}) as unknown as [{dataValues: {totalSizeBytes: number}}];
const data = await prisma.fileCache.aggregate({
_sum: {
bytes: true,
},
});
const totalSizeBytes = data._sum.bytes ?? 0;
return totalSizeBytes;
}
@ -176,24 +211,26 @@ export default class FileCacheProvider {
let models: FileCache[] = [];
const fetchNextBatch = async () => {
let where = {};
let where;
if (previousCreatedAt) {
where = {
createdAt: {
[sequelize.Op.gt]: previousCreatedAt,
gt: previousCreatedAt,
},
};
}
models = await FileCache.findAll({
models = await prisma.fileCache.findMany({
where,
limit,
order: ['createdAt'],
orderBy: {
createdAt: 'asc',
},
take: limit,
});
if (models.length > 0) {
previousCreatedAt = models[models.length - 1].createdAt as Date;
previousCreatedAt = models[models.length - 1].createdAt;
}
};

View file

@ -1,5 +1,5 @@
import {injectable} from 'inversify';
import {KeyValueCache} from '../models/index.js';
import {prisma} from '../utils/db.js';
import debug from '../utils/debug.js';
type Seconds = number;
@ -29,7 +29,11 @@ export default class KeyValueCacheProvider {
throw new Error(`Cache key ${key} is too short.`);
}
const cachedResult = await KeyValueCache.findByPk(key);
const cachedResult = await prisma.keyValueCache.findUnique({
where: {
key,
},
});
if (cachedResult) {
if (new Date() < cachedResult.expiresAt) {
@ -37,7 +41,11 @@ export default class KeyValueCacheProvider {
return JSON.parse(cachedResult.value) as F;
}
await cachedResult.destroy();
await prisma.keyValueCache.delete({
where: {
key,
},
});
}
debug(`Cache miss: ${key}`);
@ -45,10 +53,21 @@ export default class KeyValueCacheProvider {
const result = await func(...options as any[]);
// Save result
await KeyValueCache.upsert({
key,
value: JSON.stringify(result),
expiresAt: futureTimeToDate(expiresIn),
const value = JSON.stringify(result);
const expiresAt = futureTimeToDate(expiresIn);
await prisma.keyValueCache.upsert({
where: {
key,
},
update: {
value,
expiresAt,
},
create: {
key,
value,
expiresAt,
},
});
return result;

View file

@ -0,0 +1,5 @@
export const createDatabasePath = (directory: string) => `${directory}/db.sqlite`;
const createDatabaseUrl = (directory: string) => `file:${createDatabasePath(directory)}`;
export default createDatabaseUrl;

View file

@ -1,12 +1,3 @@
import {Sequelize} from 'sequelize-typescript';
import path from 'path';
import {DATA_DIR} from '../services/config.js';
import {FileCache, KeyValueCache, Settings, Shortcut} from '../models/index.js';
import Prisma from '@prisma/client';
export const sequelize = new Sequelize({
dialect: 'sqlite',
database: 'muse',
storage: path.join(DATA_DIR, 'db.sqlite'),
models: [FileCache, KeyValueCache, Settings, Shortcut],
logging: false,
});
export const prisma = new Prisma.PrismaClient();

16
src/utils/log-banner.ts Normal file
View file

@ -0,0 +1,16 @@
import {makeLines} from 'nodesplash';
import metadata from '../../package.json';
const logBanner = () => {
console.log(makeLines({
user: 'codetheweb',
repository: 'muse',
version: metadata.version,
paypalUser: 'codetheweb',
githubSponsor: 'codetheweb',
madeByPrefix: 'Made with 🎶 by ',
}).join('\n'));
console.log('\n');
};
export default logBanner;