import { log } from "./log"; import { R } from "redbean-node"; import { DockgeServer } from "./dockge-server"; import fs from "fs"; import path from "path"; import knex from "knex"; import Dialect from "knex/lib/dialects/sqlite3/index.js"; import sqlite from "@louislam/sqlite3"; import { sleep } from "./util-common"; interface DBConfig { type?: "sqlite" | "mysql"; } export class Database { /** * SQLite file path (Default: ./data/dockge.db) * @type {string} */ static sqlitePath; static noReject = true; static dbConfig: DBConfig = {}; static knexMigrationsPath = "./backend/migrations"; private static server : DockgeServer; /** * Use for decode the auth object */ jwtSecret? : string; static async init(server : DockgeServer) { this.server = server; log.debug("server", "Connecting to the database"); await Database.connect(); log.info("server", "Connected to the database"); // Patch the database await Database.patch(); } /** * Read the database config * @throws {Error} If the config is invalid * @typedef {string|undefined} envString * @returns {{type: "sqlite"} | {type:envString, hostname:envString, port:envString, database:envString, username:envString, password:envString}} Database config */ static readDBConfig() { const dbConfigString = fs.readFileSync(path.join(this.server.config.dataDir, "db-config.json")).toString("utf-8"); const dbConfig = JSON.parse(dbConfigString); if (typeof dbConfig !== "object") { throw new Error("Invalid db-config.json, it must be an object"); } if (typeof dbConfig.type !== "string") { throw new Error("Invalid db-config.json, type must be a string"); } return dbConfig; } /** * @typedef {string|undefined} envString * @param {{type: "sqlite"} | {type:envString, hostname:envString, port:envString, database:envString, username:envString, password:envString}} dbConfig the database configuration that should be written * @returns {void} */ static writeDBConfig(dbConfig) { fs.writeFileSync(path.join(this.server.config.dataDir, "db-config.json"), JSON.stringify(dbConfig, null, 4)); } /** * Connect to the database * @param {boolean} autoloadModels Should models be automatically loaded? * @param {boolean} noLog Should logs not be output? * @returns {Promise} */ static async connect(autoloadModels = true, noLog = false) { const acquireConnectionTimeout = 120 * 1000; let dbConfig; try { dbConfig = this.readDBConfig(); Database.dbConfig = dbConfig; } catch (err) { log.warn("db", err.message); dbConfig = { type: "sqlite", }; this.writeDBConfig(dbConfig); } let config = {}; log.info("db", `Database Type: ${dbConfig.type}`); if (dbConfig.type === "sqlite") { this.sqlitePath = path.join(this.server.config.dataDir, "dockge.db"); Dialect.prototype._driver = () => sqlite; config = { client: Dialect, connection: { filename: Database.sqlitePath, acquireConnectionTimeout: acquireConnectionTimeout, }, useNullAsDefault: true, pool: { min: 1, max: 1, idleTimeoutMillis: 120 * 1000, propagateCreateError: false, acquireTimeoutMillis: acquireConnectionTimeout, } }; } else { throw new Error("Unknown Database type: " + dbConfig.type); } const knexInstance = knex(config); // @ts-ignore R.setup(knexInstance); if (process.env.SQL_LOG === "1") { R.debug(true); } // Auto map the model to a bean object R.freeze(true); if (autoloadModels) { R.autoloadModels("./backend/models", "ts"); } if (dbConfig.type === "sqlite") { await this.initSQLite(); } } /** @returns {Promise} */ static async initSQLite() { await R.exec("PRAGMA foreign_keys = ON"); // Change to WAL await R.exec("PRAGMA journal_mode = WAL"); await R.exec("PRAGMA cache_size = -12000"); await R.exec("PRAGMA auto_vacuum = INCREMENTAL"); // This ensures that an operating system crash or power failure will not corrupt the database. // FULL synchronous is very safe, but it is also slower. // Read more: https://sqlite.org/pragma.html#pragma_synchronous await R.exec("PRAGMA synchronous = NORMAL"); log.debug("db", "SQLite config:"); log.debug("db", await R.getAll("PRAGMA journal_mode")); log.debug("db", await R.getAll("PRAGMA cache_size")); log.debug("db", "SQLite Version: " + await R.getCell("SELECT sqlite_version()")); } /** * Patch the database * @returns {void} */ static async patch() { // Using knex migrations // https://knexjs.org/guide/migrations.html // https://gist.github.com/NigelEarle/70db130cc040cc2868555b29a0278261 try { await R.knex.migrate.latest({ directory: Database.knexMigrationsPath, }); } catch (e) { // Allow missing patch files for downgrade or testing pr. if (e.message.includes("the following files are missing:")) { log.warn("db", e.message); log.warn("db", "Database migration failed, you may be downgrading Dockge."); } else { log.error("db", "Database migration failed"); throw e; } } } /** * Special handle, because tarn.js throw a promise reject that cannot be caught * @returns {Promise} */ static async close() { const listener = () => { Database.noReject = false; }; process.addListener("unhandledRejection", listener); log.info("db", "Closing the database"); // Flush WAL to main database if (Database.dbConfig.type === "sqlite") { await R.exec("PRAGMA wal_checkpoint(TRUNCATE)"); } while (true) { Database.noReject = true; await R.close(); await sleep(2000); if (Database.noReject) { break; } else { log.info("db", "Waiting to close the database"); } } log.info("db", "Database closed"); process.removeListener("unhandledRejection", listener); } /** * Get the size of the database (SQLite only) * @returns {number} Size of database */ static getSize() { if (Database.dbConfig.type === "sqlite") { log.debug("db", "Database.getSize()"); const stats = fs.statSync(Database.sqlitePath); log.debug("db", stats); return stats.size; } return 0; } /** * Shrink the database * @returns {Promise} */ static async shrink() { if (Database.dbConfig.type === "sqlite") { await R.exec("VACUUM"); } } }