database.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. import { log } from "./log";
  2. import { R } from "redbean-node";
  3. import { DockgeServer } from "./dockge-server";
  4. import fs from "fs";
  5. import path from "path";
  6. import knex from "knex";
  7. // @ts-ignore
  8. import Dialect from "knex/lib/dialects/sqlite3/index.js";
  9. import sqlite from "@louislam/sqlite3";
  10. import { sleep } from "./util-common";
  11. interface DBConfig {
  12. type?: "sqlite" | "mysql";
  13. hostname?: string;
  14. port?: string;
  15. database?: string;
  16. username?: string;
  17. password?: string;
  18. }
  19. export class Database {
  20. /**
  21. * SQLite file path (Default: ./data/dockge.db)
  22. * @type {string}
  23. */
  24. static sqlitePath : string;
  25. static noReject = true;
  26. static dbConfig: DBConfig = {};
  27. static knexMigrationsPath = "./backend/migrations";
  28. private static server : DockgeServer;
  29. /**
  30. * Use for decode the auth object
  31. */
  32. jwtSecret? : string;
  33. static async init(server : DockgeServer) {
  34. this.server = server;
  35. log.debug("server", "Connecting to the database");
  36. await Database.connect();
  37. log.info("server", "Connected to the database");
  38. // Patch the database
  39. await Database.patch();
  40. }
  41. /**
  42. * Read the database config
  43. * @throws {Error} If the config is invalid
  44. * @typedef {string|undefined} envString
  45. * @returns {{type: "sqlite"} | {type:envString, hostname:envString, port:envString, database:envString, username:envString, password:envString}} Database config
  46. */
  47. static readDBConfig() : DBConfig {
  48. const dbConfigString = fs.readFileSync(path.join(this.server.config.dataDir, "db-config.json")).toString("utf-8");
  49. const dbConfig = JSON.parse(dbConfigString);
  50. if (typeof dbConfig !== "object") {
  51. throw new Error("Invalid db-config.json, it must be an object");
  52. }
  53. if (typeof dbConfig.type !== "string") {
  54. throw new Error("Invalid db-config.json, type must be a string");
  55. }
  56. return dbConfig;
  57. }
  58. /**
  59. * @typedef {string|undefined} envString
  60. * @param dbConfig the database configuration that should be written
  61. * @returns {void}
  62. */
  63. static writeDBConfig(dbConfig : DBConfig) {
  64. fs.writeFileSync(path.join(this.server.config.dataDir, "db-config.json"), JSON.stringify(dbConfig, null, 4));
  65. }
  66. /**
  67. * Connect to the database
  68. * @param {boolean} autoloadModels Should models be automatically loaded?
  69. * @param {boolean} noLog Should logs not be output?
  70. * @returns {Promise<void>}
  71. */
  72. static async connect(autoloadModels = true) {
  73. const acquireConnectionTimeout = 120 * 1000;
  74. let dbConfig : DBConfig;
  75. try {
  76. dbConfig = this.readDBConfig();
  77. Database.dbConfig = dbConfig;
  78. } catch (err) {
  79. if (err instanceof Error) {
  80. log.warn("db", err.message);
  81. }
  82. dbConfig = {
  83. type: "sqlite",
  84. };
  85. this.writeDBConfig(dbConfig);
  86. }
  87. let config = {};
  88. log.info("db", `Database Type: ${dbConfig.type}`);
  89. if (dbConfig.type === "sqlite") {
  90. this.sqlitePath = path.join(this.server.config.dataDir, "dockge.db");
  91. Dialect.prototype._driver = () => sqlite;
  92. config = {
  93. client: Dialect,
  94. connection: {
  95. filename: Database.sqlitePath,
  96. acquireConnectionTimeout: acquireConnectionTimeout,
  97. },
  98. useNullAsDefault: true,
  99. pool: {
  100. min: 1,
  101. max: 1,
  102. idleTimeoutMillis: 120 * 1000,
  103. propagateCreateError: false,
  104. acquireTimeoutMillis: acquireConnectionTimeout,
  105. }
  106. };
  107. } else {
  108. throw new Error("Unknown Database type: " + dbConfig.type);
  109. }
  110. const knexInstance = knex(config);
  111. // @ts-ignore
  112. R.setup(knexInstance);
  113. if (process.env.SQL_LOG === "1") {
  114. R.debug(true);
  115. }
  116. // Auto map the model to a bean object
  117. R.freeze(true);
  118. if (autoloadModels) {
  119. R.autoloadModels("./backend/models", "ts");
  120. }
  121. if (dbConfig.type === "sqlite") {
  122. await this.initSQLite();
  123. }
  124. }
  125. /**
  126. @returns {Promise<void>}
  127. */
  128. static async initSQLite() {
  129. await R.exec("PRAGMA foreign_keys = ON");
  130. // Change to WAL
  131. await R.exec("PRAGMA journal_mode = WAL");
  132. await R.exec("PRAGMA cache_size = -12000");
  133. await R.exec("PRAGMA auto_vacuum = INCREMENTAL");
  134. // This ensures that an operating system crash or power failure will not corrupt the database.
  135. // FULL synchronous is very safe, but it is also slower.
  136. // Read more: https://sqlite.org/pragma.html#pragma_synchronous
  137. await R.exec("PRAGMA synchronous = NORMAL");
  138. log.debug("db", "SQLite config:");
  139. log.debug("db", await R.getAll("PRAGMA journal_mode"));
  140. log.debug("db", await R.getAll("PRAGMA cache_size"));
  141. log.debug("db", "SQLite Version: " + await R.getCell("SELECT sqlite_version()"));
  142. }
  143. /**
  144. * Patch the database
  145. * @returns {void}
  146. */
  147. static async patch() {
  148. // Using knex migrations
  149. // https://knexjs.org/guide/migrations.html
  150. // https://gist.github.com/NigelEarle/70db130cc040cc2868555b29a0278261
  151. try {
  152. await R.knex.migrate.latest({
  153. directory: Database.knexMigrationsPath,
  154. });
  155. } catch (e) {
  156. if (e instanceof Error) {
  157. // Allow missing patch files for downgrade or testing pr.
  158. if (e.message.includes("the following files are missing:")) {
  159. log.warn("db", e.message);
  160. log.warn("db", "Database migration failed, you may be downgrading Dockge.");
  161. } else {
  162. log.error("db", "Database migration failed");
  163. throw e;
  164. }
  165. }
  166. }
  167. }
  168. /**
  169. * Special handle, because tarn.js throw a promise reject that cannot be caught
  170. * @returns {Promise<void>}
  171. */
  172. static async close() {
  173. const listener = () => {
  174. Database.noReject = false;
  175. };
  176. process.addListener("unhandledRejection", listener);
  177. log.info("db", "Closing the database");
  178. // Flush WAL to main database
  179. if (Database.dbConfig.type === "sqlite") {
  180. await R.exec("PRAGMA wal_checkpoint(TRUNCATE)");
  181. }
  182. while (true) {
  183. Database.noReject = true;
  184. await R.close();
  185. await sleep(2000);
  186. if (Database.noReject) {
  187. break;
  188. } else {
  189. log.info("db", "Waiting to close the database");
  190. }
  191. }
  192. log.info("db", "Database closed");
  193. process.removeListener("unhandledRejection", listener);
  194. }
  195. /**
  196. * Get the size of the database (SQLite only)
  197. * @returns {number} Size of database
  198. */
  199. static getSize() {
  200. if (Database.dbConfig.type === "sqlite") {
  201. log.debug("db", "Database.getSize()");
  202. const stats = fs.statSync(Database.sqlitePath);
  203. log.debug("db", stats);
  204. return stats.size;
  205. }
  206. return 0;
  207. }
  208. /**
  209. * Shrink the database
  210. * @returns {Promise<void>}
  211. */
  212. static async shrink() {
  213. if (Database.dbConfig.type === "sqlite") {
  214. await R.exec("VACUUM");
  215. }
  216. }
  217. }