2
0
mirror of https://github.com/frappe/books.git synced 2024-11-14 01:14:03 +00:00
books/backend/database/manager.ts
2023-07-13 00:30:14 -07:00

230 lines
5.8 KiB
TypeScript

import BetterSQLite3 from 'better-sqlite3';
import fs from 'fs-extra';
import { DatabaseError } from 'fyo/utils/errors';
import path from 'path';
import { DatabaseDemuxBase, DatabaseMethod } from 'utils/db/types';
import { getMapFromList } from 'utils/index';
import { Version } from 'utils/version';
import { getSchemas } from '../../schemas';
import { databaseMethodSet, unlinkIfExists } from '../helpers';
import patches from '../patches';
import { BespokeQueries } from './bespoke';
import DatabaseCore from './core';
import { runPatches } from './runPatch';
import { BespokeFunction, Patch } from './types';
export class DatabaseManager extends DatabaseDemuxBase {
db?: DatabaseCore;
get #isInitialized(): boolean {
return this.db !== undefined && this.db.knex !== undefined;
}
getSchemaMap() {
if (this.#isInitialized) {
return this.db?.schemaMap ?? getSchemas();
}
return getSchemas();
}
async createNewDatabase(dbPath: string, countryCode: string) {
await unlinkIfExists(dbPath);
return await this.connectToDatabase(dbPath, countryCode);
}
async connectToDatabase(dbPath: string, countryCode?: string) {
countryCode = await this._connect(dbPath, countryCode);
await this.#migrate();
return countryCode;
}
async _connect(dbPath: string, countryCode?: string) {
countryCode ??= await DatabaseCore.getCountryCode(dbPath);
this.db = new DatabaseCore(dbPath);
await this.db.connect();
const schemaMap = getSchemas(countryCode);
this.db.setSchemaMap(schemaMap);
return countryCode;
}
async #migrate(): Promise<void> {
if (!this.#isInitialized) {
return;
}
const isFirstRun = await this.#getIsFirstRun();
if (isFirstRun) {
await this.db!.migrate();
}
await this.#executeMigration();
}
async #executeMigration() {
const version = await this.#getAppVersion();
const patches = await this.#getPatchesToExecute(version);
const hasPatches = !!patches.pre.length || !!patches.post.length;
if (hasPatches) {
await this.#createBackup();
}
await runPatches(patches.pre, this, version);
await this.db!.migrate({
pre: async () => {
if (hasPatches) {
return;
}
await this.#createBackup();
},
});
await runPatches(patches.post, this, version);
}
async #getPatchesToExecute(
version: string
): Promise<{ pre: Patch[]; post: Patch[] }> {
if (this.db === undefined) {
return { pre: [], post: [] };
}
const query = (await this.db.knex!('PatchRun').select()) as {
name: string;
version?: string;
failed?: boolean;
}[];
const runPatchesMap = getMapFromList(query, 'name');
/**
* A patch is run only if:
* - it hasn't run and was added in a future version
* i.e. app version is before patch added version
* - it ran but failed in some other version (i.e fixed)
*/
const filtered = patches
.filter((p) => {
const exec = runPatchesMap[p.name];
if (!exec && Version.lte(version, p.version)) {
return true;
}
if (exec?.failed && exec?.version !== version) {
return true;
}
return false;
})
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
return {
pre: filtered.filter((p) => p.patch.beforeMigrate),
post: filtered.filter((p) => !p.patch.beforeMigrate),
};
}
async call(method: DatabaseMethod, ...args: unknown[]) {
if (!this.#isInitialized) {
return;
}
if (!databaseMethodSet.has(method)) {
return;
}
// @ts-ignore
const response = await this.db[method](...args);
if (method === 'close') {
delete this.db;
}
return response;
}
async callBespoke(method: string, ...args: unknown[]): Promise<unknown> {
if (!this.#isInitialized) {
return;
}
if (!BespokeQueries.hasOwnProperty(method)) {
throw new DatabaseError(`invalid bespoke db function ${method}`);
}
const queryFunction: BespokeFunction =
BespokeQueries[method as keyof BespokeFunction];
return await queryFunction(this.db!, ...args);
}
async #getIsFirstRun(): Promise<boolean> {
const knex = this.db?.knex;
if (!knex) {
return true;
}
const query = await knex('sqlite_master').where({
type: 'table',
name: 'PatchRun',
});
return !query.length;
}
async #createBackup() {
const { dbPath } = this.db ?? {};
if (!dbPath || process.env.IS_TEST) {
return;
}
const backupPath = await this.#getBackupFilePath();
if (!backupPath) {
return;
}
const db = this.getDriver();
await db?.backup(backupPath).then(() => db.close());
}
async #getBackupFilePath() {
const { dbPath } = this.db ?? {};
if (dbPath === ':memory:' || !dbPath) {
return null;
}
let fileName = path.parse(dbPath).name;
if (fileName.endsWith('.books')) {
fileName = fileName.slice(0, -6);
}
const backupFolder = path.join(path.dirname(dbPath), 'backups');
const date = new Date().toISOString().split('T')[0];
const version = await this.#getAppVersion();
const backupFile = `${fileName}_${version}_${date}.books.db`;
fs.ensureDirSync(backupFolder);
return path.join(backupFolder, backupFile);
}
async #getAppVersion(): Promise<string> {
const knex = this.db?.knex;
if (!knex) {
return '0.0.0';
}
const query = await knex('SingleValue')
.select('value')
.where({ fieldname: 'version', parent: 'SystemSettings' });
const value = (query[0] as undefined | { value: string })?.value;
return value || '0.0.0';
}
getDriver() {
const { dbPath } = this.db ?? {};
if (!dbPath) {
return null;
}
return BetterSQLite3(dbPath, { readonly: true });
}
}
export default new DatabaseManager();