diff --git a/databases/schemas/chat-plugins.sql b/databases/schemas/chat-plugins.sql new file mode 100644 index 0000000000..404dcb8f01 --- /dev/null +++ b/databases/schemas/chat-plugins.sql @@ -0,0 +1,11 @@ +-- Database schema for chat plugins + +-- As per the design outlined at https://gist.github.com/AnnikaCodes/afa36fc8b17791be812eebbb22182426, +-- each table should be prefixed by the plugin name. + +CREATE TABLE db_info ( + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (key) +); +INSERT INTO db_info VALUES ('version', '1'); diff --git a/server/chat.ts b/server/chat.ts index 6ef3f4d0a6..3c8d19a276 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -24,9 +24,11 @@ To reload chat commands: */ import type {RoomPermission, GlobalPermission} from './user-groups'; -import {FriendsDatabase, PM} from './friends'; import type {Punishment} from './punishments'; import type {PartialModlogEntry} from './modlog'; +import {FriendsDatabase, PM} from './friends'; +import {SQL, SQLDatabaseManager} from '../lib/sql'; +import {resolve} from 'path'; export type PageHandler = (this: PageContext, query: string[], user: User, connection: Connection) => Promise | string | null | void; @@ -134,6 +136,8 @@ const MAX_PARSE_RECURSION = 10; const VALID_COMMAND_TOKENS = '/!'; const BROADCAST_TOKEN = '!'; +const PLUGIN_DATABASE_PATH = './databases/chat-plugins.db'; + import {FS, Utils} from '../lib'; import {formatText, linkRegex, stripFormatting} from './chat-formatter'; @@ -1414,6 +1418,7 @@ export const Chat = new class { void this.loadTranslations().then(() => { Chat.translationsLoaded = true; }); + this.databaseReadyPromise = this.prepareDatabase(); } translationsLoaded = false; /** @@ -1678,6 +1683,51 @@ export const Chat = new class { return translated; } + /** + * SQL handler + * + * All chat plugins share one database. + * Chat.databaseReadyPromise will be truthy if the database is not yet ready. + */ + database: SQLDatabaseManager | null = null; + databaseReadyPromise: Promise | null = null; + + async prepareDatabase() { + if (process.send) return; // We don't need a database in a subprocess that requires Chat. + if (!Config.usesqlite) return; + this.database = SQL(module, {file: ('Config' in global && Config.nofswriting) ? ':memory:' : PLUGIN_DATABASE_PATH}); + // check if we have the db_info table, which will always be present unless the schema needs to be initialized + let statement = await this.database.prepare( + `SELECT count(*) AS hasDBInfo FROM sqlite_master WHERE type = 'table' AND name = 'db_info'` + ); + if (!statement) return; // I was told this is a best practice for the SQL library + const {hasDBInfo} = await this.database.get(statement); + if (!hasDBInfo) await this.database.runFile('./databases/schemas/chat-plugins.sql'); + + statement = await this.database.prepare( + `SELECT value as curVersion FROM db_info WHERE key = 'version'` + ); + if (!statement) return; + const result = await this.database.get(statement); + const curVersion = parseInt(result.curVersion); + if (!curVersion) throw new Error(`db_info table is present, but schema version could not be parsed`); + + // automatically run migrations of the form "v{number}.sql" in the migrations/chat-plugins folder + const migrationsFolder = './databases/migrations/chat-plugins'; + const migrationsToRun = []; + for (const migrationFile of (await FS(migrationsFolder).readdir())) { + const migrationVersion = parseInt(/v(\d+)\.sql$/.exec(migrationFile)?.[1] || ''); + if (!migrationVersion) continue; + if (migrationVersion > curVersion) migrationsToRun.push({version: migrationVersion, file: migrationFile}); + } + Utils.sortBy(migrationsToRun, ({version}) => version); + for (const {file} of migrationsToRun) { + await this.database.runFile(resolve(migrationsFolder, file)); + } + + Chat.destroyHandlers.push(() => Chat.database?.destroy()); + } + readonly MessageContext = MessageContext; readonly CommandContext = CommandContext; readonly PageContext = PageContext;