Bladeren bron

Server: Refactoring ControllerPool; Implemented DB migrations;

Christian Kahlau 3 jaren geleden
bovenliggende
commit
5a60b9047a

+ 1 - 1
server/package.json

@@ -5,7 +5,7 @@
   "main": "dist/server/src/index.js",
   "scripts": {
     "start": "npm run build && node .",
-    "build": "tsc -b"
+    "build": "tsc -b && rm -rfv dist/server/src/migrations && cp -rv src/migrations dist/server/src/"
   },
   "author": "Christian Kahlau, HostBBQ ©2022",
   "license": "ISC",

+ 7 - 0
server/src/ctrl/controller-pool.interface.ts

@@ -0,0 +1,7 @@
+import { Database } from './database.class';
+import { ServerConnector } from './server-connector.class';
+
+export interface ControllerPool {
+  db: Database;
+  serverConnector: ServerConnector;
+}

+ 12 - 39
server/src/database.class.ts → server/src/ctrl/database.class.ts

@@ -2,14 +2,14 @@ import fs from 'fs';
 import fsp from 'fs/promises';
 import moment from 'moment';
 import path from 'path';
-import { Database as SQLiteDB, OPEN_CREATE, OPEN_READWRITE, RunResult } from 'sqlite3';
+import { Database as SQLiteDB, OPEN_CREATE, OPEN_READWRITE } from 'sqlite3';
 
-import { Logger } from '../../common/util/logger.class';
+import { Logger } from '../../../common/util/logger.class';
+import { DBMigration } from './db-migration.class';
+import { SQLiteController } from './sqlite-controller.base';
 
-export class Database {
-  private db?: SQLiteDB;
-
-  public async open() {
+export class Database extends SQLiteController {
+  public async open(migrate = false) {
     try {
       const DATA_DIR = process.env.DATA_DIR || 'data';
 
@@ -75,6 +75,12 @@ export class Database {
         Logger.debug(`[DEBUG] Created Server #${serverID}`);
         result = await this.run(`INSERT INTO ServerConfig(ServerID, Key, Value) VALUES(?, ?, ?);`, [serverID, 'syncInterval', 300]);
       }
+
+      if (migrate) {
+        // RUN DB MIGRATIONS
+        const mig = new DBMigration(this.db);
+        await mig.update();
+      }
     } catch (err) {
       Logger.error('[FATAL] Initializing Database failed:', err);
       Logger.error('[EXITING]');
@@ -215,37 +221,4 @@ export class Database {
 
     return result.rows.map(r => ({ time: new Date(r.Timegroup), avg: r.avg, peak: r.peak, max: r.max }));
   }
-
-  public async run(sql: string, params: any): Promise<RunResult> {
-    return new Promise<RunResult>((res, rej) => {
-      if (!this.db) return rej(new Error('Database not opened.'));
-      this.db.run(sql, params, function (err) {
-        if (err) return rej(err);
-        res(this);
-      });
-    });
-  }
-
-  public async stmt(sql: string, params: any[]) {
-    return new Promise<{ result: RunResult; rows: any[] }>((res, rej) => {
-      if (!this.db) return rej(new Error('Database not opened.'));
-      const stmt = this.db.prepare(sql);
-      stmt.all(params, function (err, rows) {
-        if (err) return rej(err);
-        res({ result: this, rows });
-      });
-    });
-  }
-
-  public async beginTransaction() {
-    await this.run('BEGIN TRANSACTION;', []);
-  }
-
-  public async commit() {
-    await this.run('COMMIT;', []);
-  }
-
-  public async rollback() {
-    await this.run('ROLLBACK;', []);
-  }
 }

+ 71 - 0
server/src/ctrl/db-migration.class.ts

@@ -0,0 +1,71 @@
+import fs from 'fs';
+import fsp from 'fs/promises';
+import path from 'path';
+import { Database } from 'sqlite3';
+import { Logger } from '../../../common/util/logger.class';
+
+import { MigrationException } from '../lib/migration-exception.class';
+import { SQLiteController } from './sqlite-controller.base';
+
+export class DBMigration extends SQLiteController {
+  constructor(protected db: Database) {
+    super();
+  }
+
+  public async update() {
+    const migrationsDir = path.resolve(__dirname, '../migrations');
+    if (!fs.existsSync(migrationsDir)) return;
+
+    const files = await fsp.readdir(migrationsDir);
+    if (files.length) {
+      files.sort((a, b) => Number(a.substring(0, 12)) - Number(b.substring(0, 12)));
+
+      await this.createMigrationsTable();
+      const lastID = await this.getLastID();
+
+      for (const file of files) {
+        const m = /^(\d{12})_(.*)\.sql$/.exec(file);
+
+        if (!m) {
+          throw new MigrationException(`File ${file} does not match migration file pattern. Aborted processing migrations.`);
+        }
+
+        const id = Number(m[1]);
+        if (id < lastID) continue;
+
+        const migFilepath = path.join(migrationsDir, file);
+        const migration = await fsp.readFile(migFilepath, { encoding: 'utf-8' });
+
+        Logger.info('[INFO] Applying DB migration', file);
+        await this.beginTransaction();
+        try {
+          await this.exec(migration);
+          await this.run('INSERT INTO db_migrations(id, title, migrated) VALUES(?, ?, ?);', [id, m[2], new Date().getTime()]);
+          await this.commit();
+          Logger.info('[INFO] DB migration', file, 'succeeded.');
+        } catch (error) {
+          Logger.error('[ERROR] DB migration failed at', file, '- Rolling back...');
+          await this.rollback();
+
+          throw error;
+        }
+      }
+    }
+  }
+
+  private async createMigrationsTable() {
+    await this.run(
+      `CREATE TABLE IF NOT EXISTS db_migrations (
+        id INTEGER PRIMARY KEY,
+        title TEXT NOT NULL,
+        migrated INTEGER NOT NULL
+      );`,
+      []
+    );
+  }
+
+  private async getLastID() {
+    const results = await this.stmt(`SELECT id FROM db_migrations ORDER BY id LIMIT 0, 1;`, []);
+    return Number(results.rows[0]?.['id'] ?? '0');
+  }
+}

+ 3 - 2
server/src/server-connector.class.ts → server/src/ctrl/server-connector.class.ts

@@ -1,8 +1,8 @@
 import axios from 'axios';
 
-import { Logger } from '../../common/util/logger.class';
+import { Logger } from '../../../common/util/logger.class';
 import { Database } from './database.class';
-import { Timer } from './timer.class';
+import { Timer } from '../timer.class';
 
 export class ServerConnector {
   private subscriptions: Array<{ id: number; interval: number; server: Server }> = [];
@@ -37,6 +37,7 @@ export class ServerConnector {
 
   private async timerTick(server: Server, db: Database) {
     Logger.debug('[DEBUG] TICK', new Date(), JSON.stringify(server));
+    if (process.env.DEV_MODE) return Logger.warn('[WARN] DEV_MODE active - sync inactive.');
 
     let trxHdl: number | undefined = undefined;
     try {

+ 45 - 0
server/src/ctrl/sqlite-controller.base.ts

@@ -0,0 +1,45 @@
+import { Database, RunResult } from 'sqlite3';
+
+export abstract class SQLiteController {
+  protected db!: Database;
+
+  public async run(sql: string, params: any): Promise<RunResult> {
+    return new Promise<RunResult>((res, rej) => {
+      if (!this.db) return rej(new Error('Database not opened.'));
+      this.db.run(sql, params, function (err) {
+        if (err) return rej(err);
+        res(this);
+      });
+    });
+  }
+
+  public async stmt(sql: string, params: any[]) {
+    return new Promise<{ result: RunResult; rows: any[] }>((res, rej) => {
+      if (!this.db) return rej(new Error('Database not opened.'));
+      const stmt = this.db.prepare(sql);
+      stmt.all(params, function (err, rows) {
+        if (err) return rej(err);
+        res({ result: this, rows });
+      });
+    });
+  }
+
+  public async exec(script: string) {
+    return new Promise<void>((res, rej) => {
+      if (!this.db) return rej(new Error('Database not opened.'));
+      this.db.exec(script, err => (err ? rej(err) : res()));
+    });
+  }
+
+  public async beginTransaction() {
+    await this.run('BEGIN TRANSACTION;', []);
+  }
+
+  public async commit() {
+    await this.run('COMMIT;', []);
+  }
+
+  public async rollback() {
+    await this.run('ROLLBACK;', []);
+  }
+}

+ 12 - 5
server/src/index.ts

@@ -1,9 +1,11 @@
 import dotenv from 'dotenv';
 
 import { Logger, LogLevel } from '../../common/util/logger.class';
-import { Database } from './database.class';
+
+import { ControllerPool } from './ctrl/controller-pool.interface';
+import { Database } from './ctrl/database.class';
+import { ServerConnector } from './ctrl/server-connector.class';
 import { Webserver } from './webserver.class';
-import { ServerConnector } from './server-connector.class';
 import { Timer } from './timer.class';
 
 dotenv.config();
@@ -13,8 +15,13 @@ Logger.logLevel = LOG_LEVEL;
 
 (async () => {
   const db = new Database();
-  await db.open();
+  await db.open(true);
+
+  const pool: ControllerPool = {
+    db,
+    serverConnector: new ServerConnector(db)
+  };
+
   Timer.instance.start();
-  new Webserver(Number(process.env.WEB_PORT ?? '80'), db);
-  new ServerConnector(db);
+  new Webserver(Number(process.env.WEB_PORT ?? '80'), pool);
 })();

+ 5 - 0
server/src/lib/migration-exception.class.ts

@@ -0,0 +1,5 @@
+export class MigrationException extends Error {
+  constructor(msg: string) {
+    super(msg);
+  }
+}

+ 6 - 6
server/src/webhdl/server-api-handler.class.ts

@@ -2,18 +2,18 @@ import { RouterOptions } from 'express';
 
 import { HttpStatusException } from '../../../common/lib/http-status.exception';
 
-import { Database } from '../database.class';
+import { ControllerPool } from '../ctrl/controller-pool.interface';
 import { WebHandler } from './web-handler.base';
 
 export class ServerAPIHandler extends WebHandler {
-  constructor(private db: Database, options?: RouterOptions) {
-    super(options);
+  constructor(protected ctrlPool: ControllerPool, options?: RouterOptions) {
+    super(ctrlPool, options);
 
     this.router.use(this.avoidCache);
 
     this.router.get('/', async (req, res, next) => {
       try {
-        const serverConfigs = await this.db.getAllServerConfigs();
+        const serverConfigs = await this.ctrlPool.db.getAllServerConfigs();
         res.send(serverConfigs);
       } catch (err) {
         next(err);
@@ -28,7 +28,7 @@ export class ServerAPIHandler extends WebHandler {
           throw new HttpStatusException(`Not a valid server id: ${req.params.serverID}`, 400);
         }
 
-        const serverDataTypes = await this.db.getServerDataTypes(serverID);
+        const serverDataTypes = await this.ctrlPool.db.getServerDataTypes(serverID);
         res.send(serverDataTypes);
       } catch (err) {
         next(err);
@@ -55,7 +55,7 @@ export class ServerAPIHandler extends WebHandler {
           throw new HttpStatusException("QueryParams 'start' and 'end' must be parseable dates or unix epoch timestamps (ms).", 400);
         }
 
-        const data = await this.db.queryServerData(serverID, qType, start, end);
+        const data = await this.ctrlPool.db.queryServerData(serverID, qType, start, end);
         res.send({
           start,
           end,

+ 3 - 1
server/src/webhdl/web-handler.base.ts

@@ -1,10 +1,12 @@
 import { NextFunction, Request, Response, Router, RouterOptions } from 'express';
 import moment from 'moment';
 
+import { ControllerPool } from '../ctrl/controller-pool.interface';
+
 export abstract class WebHandler {
   private _router: Router;
 
-  constructor(options?: RouterOptions) {
+  constructor(protected ctrlPool: ControllerPool, options?: RouterOptions) {
     this._router = Router(options);
   }
 

+ 3 - 3
server/src/webserver.class.ts

@@ -4,16 +4,16 @@ import path from 'path';
 import { HttpStatusException } from '../../common/lib/http-status.exception';
 import { Logger } from '../../common/util/logger.class';
 
-import { Database } from './database.class';
+import { ControllerPool } from './ctrl/controller-pool.interface';
 import { ServerAPIHandler } from './webhdl/server-api-handler.class';
 
 export class Webserver {
   private app: Express;
 
-  constructor(private port: number, private db: Database) {
+  constructor(private port: number, ctrlPool: ControllerPool) {
     this.app = express();
 
-    const serverApi = new ServerAPIHandler(db);
+    const serverApi = new ServerAPIHandler(ctrlPool);
     this.app.use('/server', serverApi.router);
 
     this.app.use('/', express.static(process.env.STATIC_DIR || 'public'));