/*
* This file is part of the Companion project
* Copyright (c) 2018 Bitfocus AS
* Authors: William Viker <william@bitfocus.io>, Håkon Nessjøen <haakon@bitfocus.io>
*
* This program is free software.
* You should have received a copy of the MIT licence as well as the Bitfocus
* Individual Contributor License Agreement for companion along with
* this program.
*
* You can be released from the requirements of the license by purchasing
* a commercial license. Buying such a license is mandatory as soon as you
* develop commercial activities involving the Companion software without
* disclosing the source code of your own applications.
*
*/
const fs = require('fs-extra');
const { cloneDeep } = require('lodash');
/**
* Abstract class to be extended by the flat file DB classes.
* See {@link Config} and {@link Database}
*
* @author Håkon Nessjøen <haakon@bitfocus.io>
* @author Keith Rocheck <keith.rocheck@gmail.com>
* @author William Viker <william@bitfocus.io>
* @since 2.2.0
* @abstract
*/
class DataStoreBase {
cfgDir = null;
cfgFile = null;
defaults = null;
dirty = false;
lastsave = Date.now();
name = null;
saveInterval = null;
store = {};
system = null;
/**
* Create a new flat file DB controller
* @param {EventEmitter} system - the application's event emitter
* @param {string} name - the name of the flat file
* @param {string} cfgDir - the directory the flat file will be saved
* @param {number} saveInterval - minimum interval in ms to save to disk
* @param {Object[]} defaults - the default data to use when making a new file
*/
constructor(system, name, cfgDir, saveInterval, defaults) {
this.system = system;
this.name = name;
this.cfgDir = cfgDir;
this.cfgFile = cfgDir + '/' + name;
this.saveInterval = saveInterval;
this.defaults = defaults;
}
/**
* Delete a key/value pair
* @param {string} key - the key to be delete
* @access public
*/
deleteKey(key) {
this.debug(`${this.name}_del (${key})`);
if (key !== undefined) {
delete this.store[key];
this.setDirty();
}
}
/**
* Get the entire database
* @param {boolean} [clone = false] - `true` if a clone is needed instead of a link
* @returns {Object[]} the database
* @access public
*/
getAll(clone = false) {
let out;
this.debug(`${this.name}_get_all`);
if (clone === true) {
out = cloneDeep(this.store);
}
else {
out = this.store;
}
return out;
}
/**
* @returns {string} the directory of the flat file
* @access public
*/
getCfgDir() {
return this.cfgDir
}
/**
* Get a value from the database
* @param {string} key - the key to be retrieved
* @param {?Object[]} defaultValue - the default value to use if the key doesn't exist
* @param {boolean} [clone = false] - `true` if a clone is needed instead of a link
* @access public
*/
getKey(key, defaultValue, clone = false) {
let out;
this.debug(`${this.name}_get(${key})`);
if (this.store[key] === undefined && defaultValue !== undefined) {
this.store[key] = defaultValue;
this.setDirty();
}
if (clone === true) {
out = cloneDeep(this.store[key]);
}
else {
out = this.store[key];
}
return out;
}
/**
* Attempt to load the database from disk
* @access protected
*/
load() {
let cfgBakFile = this.cfgFile + '.bak';
if (fs.existsSync(this.cfgFile)) {
this.debug(this.cfgFile,"exists. trying to read");
let data = fs.readFileSync(this.cfgFile, 'utf8');
try {
if (data.trim().length > 0) {
this.store = JSON.parse(data);
this.debug("parsed JSON");
this.system.emit(`${this.name}_loaded`, this.store);
}
else {
this.system.emit('log', this.name, 'warn', `${this.name} was empty. Attempting to recover the configuration.`);
this.loadBackup(cfgBakFile);
}
} catch(e) {
try {
fs.copyFileSync(this.cfgFile, this.cfgFile + '.corrupt');
this.system.emit('log', this.name, 'error', `${this.name} could not be parsed. A copy has been saved to ${this.cfgFile}.corrupt.`);
fs.rmSync(this.cfgFile);
}
catch(err) {
this.debug(`${this.name}_load`, `Error making or deleting corrupted backup: ${err}`);
}
this.loadBackup(cfgBakFile);
}
}
else if (fs.existsSync(cfgBakFile)) {
this.system.emit('log', this.name, 'warn', `${this.name} is missing. Attempting to recover the configuration.`);
this.loadBackup(cfgBakFile);
}
else {
this.loadDefaults();
}
this.setSaveCycle();
}
/**
* Attempt to load the backup file from disk as a recovery
* @param {string} cfgBakFile - the full file path
* @access protected
*/
loadBackup(cfgBakFile) {
if (fs.existsSync(cfgBakFile)) {
this.debug(cfgBakFile,"exists. trying to read");
let data = fs.readFileSync(cfgBakFile, 'utf8');
try {
if (data.trim().length > 0) {
this.store = JSON.parse(data);
this.debug("parsed JSON");
this.system.emit('log', this.name, 'warn', `${this.name}.bak has been used to recover the configuration.`);
this.system.emit(`${this.name}_loaded`, this.store);
this.save(false);
}
else {
this.loadDefaults();
}
} catch(e) {
this.loadDefaults();
}
}
else {
this.loadDefaults();
}
}
/**
* Save the defaults since a file could not be found/loaded/parses
* @access protected
*/
loadDefaults() {
this.debug(this.cfgFile,"didnt exist. loading defaults", this.defaults);
this.system.emit(`${this.name}_loaded`, this.defaults);
this.store = this.defaults;
this.save();
}
/**
* Save the database to file
* @param {boolean} [withBackup = true] - can be set to `false` if the current file should not be moved to `FILE.bak`
* @access protected
*/
save(withBackup = true) {
if (withBackup === true && fs.existsSync(this.cfgFile) && fs.readFileSync(this.cfgFile, 'utf8').trim().length > 0) {
fs.copy(this.cfgFile, this.cfgFile + '.bak', (err) => {
if (err) {
this.debug(`${this.name}_save`, `Error making backup copy: ${err}`);
}
else {
this.debug(`${this.name}_save`, `backup written`);
}
this.saveMain();
});
}
else {
this.saveMain();
}
}
/**
* Save the database to file making a `FILE.bak` version then moving it into place
* @access protected
*/
saveMain() {
fs.writeFile(this.cfgFile + '.tmp', JSON.stringify(this.store), (err) => {
if (err) {
this.debug(`${this.name}_save`, `Error saving: ${err}`);
return;
}
this.debug(`${this.name}_save`, 'written');
fs.rename(this.cfgFile + '.tmp', this.cfgFile, (err) => {
if (err) {
this.system.emit('log', this.name, 'error', `${this.name}.tmp->${this.name} failed: ${err}`);
}
else {
this.debug(`${this.name}_save`,'renamed');
this.dirty = false;
this.lastsave = Date.now();
}
});
});
}
/**
* Register that there are changes in the database that need to be saved as soon as possible
* @access public
*/
setDirty() {
this.dirty = true;
}
/**
* Save/update a key/value pair to the database
* @param {(number|string)} key - the key to save under
* @param {Object} value - the object to save
* @access public
*/
setKey(key, value) {
this.debug(`${this.name}_set(${key}, ${value})`);
if (key !== undefined) {
this.store[key] = value;
this.setDirty();
}
}
/**
* Save/update multiple key/value pairs to the database
* @param {Array.<(number|string),Object>} keyvalueobj - the key to save under
* @access public
*/
setKeys(keyvalueobj) {
this.debug(`${this.name}_set_multiple:`);
if (keyvalueobj !== undefined && typeof keyvalueobj == 'object' && keyvalueobj.length > 0) {
for (let key in keyvalueobj) {
this.debug(`${this.name}_set(${key}, ${keyvalueobj[key]})`);
this.store[key] = keyvalueobj[key];
}
this.setDirty();
}
}
/**
* Setup the save cycle interval
* @access protected
*/
setSaveCycle() {
this.saveCycle = setInterval(() => {
// See if the database is dirty and needs to be saved
if (Date.now() - this.lastsave > this.saveInterval && this.dirty) {
this.save();
}
}, this.saveInterval);
}
}
exports = module.exports = DataStoreBase;