2023-07-13 05:56:45 +00:00
|
|
|
import BetterSQLite3 from 'better-sqlite3';
|
2023-07-12 09:28:14 +00:00
|
|
|
import fs from 'fs-extra';
|
2022-11-07 07:52:43 +00:00
|
|
|
import { DatabaseError } from 'fyo/utils/errors';
|
2022-05-24 11:34:41 +00:00
|
|
|
import path from 'path';
|
2022-04-07 13:38:17 +00:00
|
|
|
import { DatabaseDemuxBase, DatabaseMethod } from 'utils/db/types';
|
2023-07-13 05:56:45 +00:00
|
|
|
import { getMapFromList } from 'utils/index';
|
|
|
|
import { Version } from 'utils/version';
|
2022-03-24 13:13:59 +00:00
|
|
|
import { getSchemas } from '../../schemas';
|
2023-07-13 05:56:45 +00:00
|
|
|
import { databaseMethodSet, unlinkIfExists } from '../helpers';
|
2022-03-24 13:13:59 +00:00
|
|
|
import patches from '../patches';
|
2022-04-11 06:04:55 +00:00
|
|
|
import { BespokeQueries } from './bespoke';
|
2022-03-24 13:13:59 +00:00
|
|
|
import DatabaseCore from './core';
|
|
|
|
import { runPatches } from './runPatch';
|
2023-08-03 07:48:14 +00:00
|
|
|
import { BespokeFunction, Patch, RawCustomField } from './types';
|
2022-03-24 13:13:59 +00:00
|
|
|
|
2022-04-07 13:38:17 +00:00
|
|
|
export class DatabaseManager extends DatabaseDemuxBase {
|
2022-03-25 10:12:39 +00:00
|
|
|
db?: DatabaseCore;
|
2023-08-03 07:48:14 +00:00
|
|
|
rawCustomFields: RawCustomField[] = [];
|
2022-03-24 13:13:59 +00:00
|
|
|
|
2022-03-31 07:18:32 +00:00
|
|
|
get #isInitialized(): boolean {
|
|
|
|
return this.db !== undefined && this.db.knex !== undefined;
|
|
|
|
}
|
|
|
|
|
2022-03-31 12:04:30 +00:00
|
|
|
getSchemaMap() {
|
2023-05-11 04:53:07 +00:00
|
|
|
if (this.#isInitialized) {
|
2023-08-03 07:48:14 +00:00
|
|
|
return this.db?.schemaMap ?? getSchemas('-', this.rawCustomFields);
|
2023-05-11 04:53:07 +00:00
|
|
|
}
|
|
|
|
|
2023-08-03 07:48:14 +00:00
|
|
|
return getSchemas('-', this.rawCustomFields);
|
2022-03-31 12:04:30 +00:00
|
|
|
}
|
|
|
|
|
2022-04-18 06:42:56 +00:00
|
|
|
async createNewDatabase(dbPath: string, countryCode: string) {
|
2022-08-30 12:43:42 +00:00
|
|
|
await unlinkIfExists(dbPath);
|
2022-04-18 11:29:20 +00:00
|
|
|
return await this.connectToDatabase(dbPath, countryCode);
|
2022-03-24 13:13:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
async connectToDatabase(dbPath: string, countryCode?: string) {
|
2022-05-24 11:34:41 +00:00
|
|
|
countryCode = await this._connect(dbPath, countryCode);
|
|
|
|
await this.#migrate();
|
|
|
|
return countryCode;
|
|
|
|
}
|
2022-04-16 07:25:36 +00:00
|
|
|
|
2022-05-24 11:34:41 +00:00
|
|
|
async _connect(dbPath: string, countryCode?: string) {
|
|
|
|
countryCode ??= await DatabaseCore.getCountryCode(dbPath);
|
2022-03-24 13:13:59 +00:00
|
|
|
this.db = new DatabaseCore(dbPath);
|
2022-05-20 11:12:32 +00:00
|
|
|
await this.db.connect();
|
2023-08-03 07:48:14 +00:00
|
|
|
await this.setRawCustomFields();
|
|
|
|
const schemaMap = getSchemas(countryCode, this.rawCustomFields);
|
2022-03-24 13:13:59 +00:00
|
|
|
this.db.setSchemaMap(schemaMap);
|
2022-04-18 06:42:56 +00:00
|
|
|
return countryCode;
|
2022-03-24 13:13:59 +00:00
|
|
|
}
|
|
|
|
|
2023-08-03 07:48:14 +00:00
|
|
|
async setRawCustomFields() {
|
|
|
|
try {
|
|
|
|
this.rawCustomFields = (await this.db?.knex?.(
|
|
|
|
'CustomField'
|
|
|
|
)) as RawCustomField[];
|
|
|
|
} catch {}
|
|
|
|
}
|
|
|
|
|
2022-03-31 07:18:32 +00:00
|
|
|
async #migrate(): Promise<void> {
|
|
|
|
if (!this.#isInitialized) {
|
2022-03-25 10:12:39 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2023-07-13 05:42:43 +00:00
|
|
|
const isFirstRun = await this.#getIsFirstRun();
|
2022-03-31 07:18:32 +00:00
|
|
|
if (isFirstRun) {
|
|
|
|
await this.db!.migrate();
|
|
|
|
}
|
|
|
|
|
2023-07-12 09:28:14 +00:00
|
|
|
await this.#executeMigration();
|
2022-08-30 12:43:42 +00:00
|
|
|
}
|
|
|
|
|
2023-07-12 09:28:14 +00:00
|
|
|
async #executeMigration() {
|
2023-07-13 05:42:43 +00:00
|
|
|
const version = await this.#getAppVersion();
|
2023-07-12 09:28:14 +00:00
|
|
|
const patches = await this.#getPatchesToExecute(version);
|
2022-05-24 19:06:54 +00:00
|
|
|
|
2023-07-12 09:28:14 +00:00
|
|
|
const hasPatches = !!patches.pre.length || !!patches.post.length;
|
|
|
|
if (hasPatches) {
|
|
|
|
await this.#createBackup();
|
|
|
|
}
|
2022-03-24 13:13:59 +00:00
|
|
|
|
2023-07-12 09:28:14 +00:00
|
|
|
await runPatches(patches.pre, this, version);
|
|
|
|
await this.db!.migrate({
|
|
|
|
pre: async () => {
|
|
|
|
if (hasPatches) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
await this.#createBackup();
|
|
|
|
},
|
|
|
|
});
|
|
|
|
await runPatches(patches.post, this, version);
|
2022-03-24 13:13:59 +00:00
|
|
|
}
|
|
|
|
|
2023-07-12 09:28:14 +00:00
|
|
|
async #getPatchesToExecute(
|
|
|
|
version: string
|
|
|
|
): Promise<{ pre: Patch[]; post: Patch[] }> {
|
2022-03-25 10:12:39 +00:00
|
|
|
if (this.db === undefined) {
|
2023-07-12 09:28:14 +00:00
|
|
|
return { pre: [], post: [] };
|
2022-03-25 10:12:39 +00:00
|
|
|
}
|
|
|
|
|
2023-07-12 09:28:14 +00:00
|
|
|
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),
|
|
|
|
};
|
2022-03-24 13:13:59 +00:00
|
|
|
}
|
|
|
|
|
2022-05-24 11:34:41 +00:00
|
|
|
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)) {
|
2022-11-07 07:52:43 +00:00
|
|
|
throw new DatabaseError(`invalid bespoke db function ${method}`);
|
2022-05-24 11:34:41 +00:00
|
|
|
}
|
|
|
|
|
2023-04-04 06:21:00 +00:00
|
|
|
const queryFunction: BespokeFunction =
|
|
|
|
BespokeQueries[method as keyof BespokeFunction];
|
2022-05-24 11:34:41 +00:00
|
|
|
return await queryFunction(this.db!, ...args);
|
|
|
|
}
|
|
|
|
|
2023-07-13 05:42:43 +00:00
|
|
|
async #getIsFirstRun(): Promise<boolean> {
|
|
|
|
const knex = this.db?.knex;
|
|
|
|
if (!knex) {
|
2022-03-31 07:18:32 +00:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2023-07-13 05:42:43 +00:00
|
|
|
const query = await knex('sqlite_master').where({
|
|
|
|
type: 'table',
|
|
|
|
name: 'PatchRun',
|
|
|
|
});
|
|
|
|
return !query.length;
|
2023-07-12 09:28:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
async #createBackup() {
|
|
|
|
const { dbPath } = this.db ?? {};
|
2023-07-13 05:42:43 +00:00
|
|
|
if (!dbPath || process.env.IS_TEST) {
|
2023-07-12 09:28:14 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2023-07-13 05:42:43 +00:00
|
|
|
const backupPath = await this.#getBackupFilePath();
|
2023-07-12 09:28:14 +00:00
|
|
|
if (!backupPath) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const db = this.getDriver();
|
2023-07-13 05:42:43 +00:00
|
|
|
await db?.backup(backupPath).then(() => db.close());
|
2022-03-31 07:18:32 +00:00
|
|
|
}
|
2022-05-24 11:34:41 +00:00
|
|
|
|
2023-07-13 05:42:43 +00:00
|
|
|
async #getBackupFilePath() {
|
2023-07-12 09:28:14 +00:00
|
|
|
const { dbPath } = this.db ?? {};
|
|
|
|
if (dbPath === ':memory:' || !dbPath) {
|
2022-05-31 06:09:45 +00:00
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2023-07-12 09:28:14 +00:00
|
|
|
let fileName = path.parse(dbPath).name;
|
|
|
|
if (fileName.endsWith('.books')) {
|
|
|
|
fileName = fileName.slice(0, -6);
|
|
|
|
}
|
|
|
|
|
|
|
|
const backupFolder = path.join(path.dirname(dbPath), 'backups');
|
2023-07-13 05:56:45 +00:00
|
|
|
const date = new Date().toISOString().split('T')[0];
|
2023-07-13 05:42:43 +00:00
|
|
|
const version = await this.#getAppVersion();
|
2023-07-13 07:18:27 +00:00
|
|
|
const backupFile = `${fileName}_${version}_${date}.books.db`;
|
2023-07-12 09:28:14 +00:00
|
|
|
fs.ensureDirSync(backupFolder);
|
|
|
|
return path.join(backupFolder, backupFile);
|
|
|
|
}
|
|
|
|
|
2023-07-13 05:42:43 +00:00
|
|
|
async #getAppVersion(): Promise<string> {
|
|
|
|
const knex = this.db?.knex;
|
|
|
|
if (!knex) {
|
2023-07-12 09:28:14 +00:00
|
|
|
return '0.0.0';
|
|
|
|
}
|
|
|
|
|
2023-07-13 05:42:43 +00:00
|
|
|
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';
|
2023-07-12 09:28:14 +00:00
|
|
|
}
|
2022-08-30 12:43:42 +00:00
|
|
|
|
2023-07-12 09:28:14 +00:00
|
|
|
getDriver() {
|
|
|
|
const { dbPath } = this.db ?? {};
|
|
|
|
if (!dbPath) {
|
2022-08-30 12:43:42 +00:00
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2023-07-12 09:28:14 +00:00
|
|
|
return BetterSQLite3(dbPath, { readonly: true });
|
2022-05-24 11:34:41 +00:00
|
|
|
}
|
2022-03-24 13:13:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
export default new DatabaseManager();
|