import util from "util"; import fs from "graceful-fs"; import glob from "glob"; import path from "path"; import { config } from "../config"; import Logger from "../logger"; import { Database, open, Statement } from "sqlite"; import * as sqlite3 from "sqlite3"; import type { RunResult, SqlType, TypedDatabase } from "../types"; const pglob = util.promisify(glob); const pReadFile = util.promisify(fs.readFile); /** * Typesafe database wrapper. * * @see Database */ class DatabasePromiseWrapper implements TypedDatabase { private db: Promise; constructor() { this.db = new Promise((resolve, reject) => { open({ filename: config.server.databaseFile, driver: sqlite3.Database, }) .then(resolve) .catch(reject); }); this.db.catch((err) => { Logger.tag("database", "init").error( "Error initializing database: ", err ); process.exit(1); }); } async on(event: string, listener: unknown): Promise { const db = await this.db; db.on(event, listener); } async run(sql: SqlType, ...params: unknown[]): Promise { const db = await this.db; return db.run(sql, ...params); } async get(sql: SqlType, ...params: unknown[]): Promise { const db = await this.db; return await db.get(sql, ...params); } async each( sql: SqlType, callback: (err: unknown, row: T) => void ): Promise; async each( sql: SqlType, param1: unknown, callback: (err: unknown, row: T) => void ): Promise; async each( sql: SqlType, param1: unknown, param2: unknown, callback: (err: unknown, row: T) => void ): Promise; async each( sql: SqlType, param1: unknown, param2: unknown, param3: unknown, callback: (err: unknown, row: T) => void ): Promise; // eslint-disable-next-line @typescript-eslint/no-unused-vars async each(sql: SqlType, ...params: unknown[]): Promise { const db = await this.db; return await db.each(sql, ...params); } async all(sql: SqlType, ...params: unknown[]): Promise { const db = await this.db; return await db.all(sql, ...params); } async exec(sql: SqlType, ...params: unknown[]): Promise { const db = await this.db; return await db.exec(sql, ...params); } async prepare(sql: SqlType, ...params: unknown[]): Promise { const db = await this.db; return await db.prepare(sql, ...params); } } async function applyPatch(db: TypedDatabase, file: string): Promise { Logger.tag("database", "migration").info( "Checking if patch need to be applied: %s", file ); const contents = await pReadFile(file); const version = path.basename(file, ".sql"); const row = await db.get( "SELECT * FROM schema_version WHERE version = ?", version ); if (row) { // patch is already applied. skip! Logger.tag("database", "migration").info( "Patch already applied, skipping: %s", file ); return; } const sql = "BEGIN TRANSACTION;\n" + contents.toString() + "\n" + "INSERT INTO schema_version (version) VALUES ('" + version + "');\n" + "END TRANSACTION;"; await db.exec(sql); Logger.tag("database", "migration").info( "Patch successfully applied: %s", file ); } async function applyMigrations(db: TypedDatabase): Promise { Logger.tag("database", "migration").info("Migrating database..."); const sql = "BEGIN TRANSACTION; CREATE TABLE IF NOT EXISTS schema_version (\n" + " version VARCHAR(255) PRIMARY KEY ASC,\n" + " applied_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL\n" + "); END TRANSACTION;"; await db.exec(sql); const files = await pglob(__dirname + "/patches/*.sql"); for (const file of files) { await applyPatch(db, file); } } export const db: TypedDatabase = new DatabasePromiseWrapper(); export async function init(): Promise { Logger.tag("database").info( "Setting up database: %s", config.server.databaseFile ); await db.on("profile", (sql: string, time: number) => Logger.tag("database").profile("[%sms]\t%s", time, sql) ); try { await applyMigrations(db); } catch (error) { Logger.tag("database").error("Error migrating database:", error); throw error; } }