umbot
Version:
Мультиплатформенный фреймворк для создания голосовых навыков и чат-ботов с единой бизнес-логикой. Встроенная поддержка ВКонтакте, Telegram, Viber, MAX, Яндекс Алисы, Маруси и Сбера SmartApp. Архитектура на адаптерах позволяет подключать любые другие платф
404 lines (403 loc) • 18.8 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.FileAdapter = void 0;
const Base_1 = require("../Base/Base");
const index_1 = require("../../../index");
// Сохраняем данные не чаще 1 раза в 500мс
const LAZY_DELAY_SAVE_TIME = 500;
// Принудительно сохраняем данные каждые 3 минуты. Актуально для случаев, когда идет постоянная нагрузка на сервер.
const FORCE_DELAY_SAVE_TIME = 60 * 1000 * 3;
/**
* Адаптер для файловой базы данных.
* Стоит использовать только для быстрого старта, либо для приложений, у которых объем базы не будет превышать 250мб.
*/
class FileAdapter extends Base_1.Base {
/**
* Формат базы данных
*/
dbFormat = 'file';
/**
* Для локального сохранения, на случай если AppContext не доступен
* @private
*/
#cachedFileData = {};
constructor() {
super();
}
connect() {
// TODO костыль, но нужно как-то предварительно загрузить содержимое файлов, иначе под нагрузкой может 1 файл прочитаться несколько раз сразу, либо прочитаться во время выполнения.
// И если проблема в самом файле, то лучше пусть будет так, чем приложение упадет при запросе.
this.getFileData('UsersData');
this.getFileData('SoundTokens');
this.getFileData('ImageTokens');
return true;
}
/**
* Сохранение всех данные в базу
* @param tableName Имя таблицы
* @param data Сохраняемые данные
*/
setCachedFileData(tableName, data) {
if (this._appContext.database.databaseInfo) {
if (data === undefined) {
this._appContext.database.databaseInfo[tableName] = undefined;
}
else {
const timeOutId = this._appContext.database.databaseInfo[tableName]?.timeOutId;
this._appContext.database.databaseInfo[tableName] = data;
// из-за асинхронности может выйти так, что кто-то записывает новые данные, которые перетирают ранее установленный timeout
if (data.timeOutId === undefined && timeOutId !== undefined) {
this._appContext.database.databaseInfo[tableName].timeOutId = timeOutId;
}
}
}
else if (data === undefined) {
this.#cachedFileData[tableName] = undefined;
}
else {
this.#cachedFileData[tableName] = data;
}
}
/**
* Получение всех данных из базы
* @param tableName Имя таблицы
*/
getCachedFileData(tableName) {
if (this._appContext?.database.databaseInfo) {
if (!this._appContext.database.databaseInfo[tableName]) {
this._appContext.database.databaseInfo[tableName] = {
version: 0,
data: {},
isFileRead: false,
};
}
return this._appContext.database.databaseInfo[tableName];
}
this.#cachedFileData ??= {};
return this.#cachedFileData[tableName];
}
/**
* Сохранение данные в базу
* @param tableName Имя таблицы
* @param field Поле, в которое происходит сохранение
* @param data Данные для сохранения
* @private
*/
#setCachedFileData(tableName, field, data) {
const cachedData = this.getCachedFileData(tableName);
cachedData[field] = data;
this.setCachedFileData(tableName, cachedData);
}
#isForbiddenKey(key) {
const forbidden = ['__proto__', 'constructor', 'prototype'];
return forbidden.includes(key);
}
/**
* Сохраняет данные
* @param tableName Название таблицы
* @param force Флаг принудительного сохранения
* @private
*/
#update(tableName, force = false) {
// data не нужен, так как все данные редактируются в объекте по ссылке
const cb = () => {
const data = this.getCachedFileData(tableName).data;
if (data) {
this._appContext?.saveFileData(`${tableName}.json`, data)?.catch((e) => {
this._appContext.logError(`Произошла ошибка при записи в файл: ${e.message}`, {
error: e,
});
});
}
this.#setCachedFileData(tableName, 'timeOutId', null);
const forceTimeOutId = this.getCachedFileData(tableName).forceTimeOutId;
if (forceTimeOutId) {
clearTimeout(forceTimeOutId);
this.#setCachedFileData(tableName, 'forceTimeOutId', null);
}
};
const timeOutId = this.getCachedFileData(tableName).timeOutId;
if (timeOutId) {
clearTimeout(timeOutId);
this.#setCachedFileData(tableName, 'timeOutId', null);
}
const forceTimeOutId = this.getCachedFileData(tableName).forceTimeOutId;
if (force) {
cb();
}
else {
this.#setCachedFileData(tableName, 'timeOutId', setTimeout(cb, LAZY_DELAY_SAVE_TIME).unref());
if (!forceTimeOutId) {
this.#setCachedFileData(tableName, 'forceTimeOutId', setTimeout(cb, FORCE_DELAY_SAVE_TIME).unref());
}
}
}
/**
* Выполняет UPDATE-запрос.
* @param updateData Дополнительная информация для запроса. Содержит сам запроса, а также название таблицы и прочие данные.
*/
_update(updateData) {
const update = updateData.data;
const select = updateData.query;
const data = this.getFileData(updateData.tableName);
if (select) {
const idVal = select[updateData.primaryKeyName];
if (idVal !== undefined) {
if (this.#isForbiddenKey(idVal)) {
this._appContext?.logError(`Попытка использовать запрещённый ключ: ${idVal}`);
return false;
}
if (data[idVal] !== undefined) {
data[idVal] = { ...data[idVal], ...update };
this.#update(updateData.tableName);
}
return true;
}
}
return false;
}
/**
* Выполняет INSERT-запрос.
* @param insertData Дополнительная информация для запроса. Содержит сам запроса, а также название таблицы и прочие данные.
*/
_insert(insertData) {
const insert = insertData.data;
const data = this.getFileData(insertData.tableName);
if (insert) {
const idVal = insert[insertData.primaryKeyName];
if (idVal) {
if (this.#isForbiddenKey(idVal)) {
this._appContext?.logError(`Попытка использовать запрещённый ключ: ${idVal}`);
return false;
}
data[idVal] = insert;
this.#update(insertData.tableName);
return true;
}
}
return false;
}
/**
* Выполняет DELETE-запрос.
* @param removeData Дополнительная информация для запроса. Содержит сам запроса, а также название таблицы и прочие данные.
*/
_remove(removeData) {
const remove = removeData.query;
const data = this.getFileData(removeData.tableName);
if (remove) {
const idVal = remove[removeData.primaryKeyName];
if (idVal !== undefined) {
if (this.#isForbiddenKey(idVal)) {
this._appContext?.logError(`Попытка использовать запрещённый ключ: ${idVal}`);
return false;
}
if (data[idVal] !== undefined) {
delete data[idVal];
this.#update(removeData.tableName);
}
return true;
}
}
return false;
}
/**
* Получение данных по уникальному ключу
* @param selectData
* @param where
* @param isOne
* @param content
* @private
*/
#selectInPrimaryKey(selectData, where, isOne = false, content = {}) {
const whereKey = where[selectData.primaryKeyName];
if ((typeof whereKey === 'string' || typeof whereKey === 'number') && content[whereKey]) {
if ((0, index_1.keysCount)(where) === 1) {
return {
status: true,
data: isOne ? content[whereKey] : [content[whereKey]],
};
}
for (const data in where) {
if (!Object.hasOwn(content[whereKey], data) ||
content[whereKey][data] !== where[data]) {
return {
status: false,
};
}
}
return {
status: true,
data: isOne ? content[whereKey] : [content[whereKey]],
};
}
else {
return {
status: false,
};
}
}
/**
* Выполняет SELECT-запрос.
*
* ⚠️ Файловый адаптер поддерживает только точное совпадение значений. Условия с операторами (например, $gt, $in) игнорируются.
*
* @param selectData Дополнительная информация для запроса. Содержит информацию о таблице и структуре.
* @param where Сам запрос
* @param isOne Определяет нужно ли вернуть только 1 найденную запись, либо отдать все доступные данные.
*/
_select(selectData, where, isOne = false) {
let result = null;
const content = this.getFileData(selectData.tableName);
if (where) {
const whereKey = where[selectData.primaryKeyName];
if (whereKey) {
return this.#selectInPrimaryKey(selectData, where, isOne, content);
}
for (const key in content) {
let allMatch = true;
for (const data in where) {
if (!Object.hasOwn(content[key], data) || content[key][data] !== where[data]) {
allMatch = false;
break;
}
}
if (allMatch) {
if (isOne) {
result = content[key];
return {
status: true,
data: result,
};
}
result ??= [];
result.push(content[key]);
}
}
}
else {
result = isOne ? content[Object.keys(content)[0]] : content;
}
if (result) {
return {
status: true,
data: result,
};
}
return {
status: false,
error: 'Не удалось получить данные',
};
}
/**
* Загружает данные таблицы из JSON-файла и кэширует их в памяти.
*
* - При первом обращении читает файл `${tableName}.json` из директории `appConfig.json`.
* - Данные кэшируются с привязкой к времени последнего изменения файла (mtimeMs).
* - Если файл не изменился, возвращаются кэшированные данные.
* - В случае ошибки парсинга делается повторная попытка (защита от гонок записи/чтения).
*
* Формат данных в файле:
* ```json
* {
* "user123": { "name": "Alice", "age": 30 },
* "user456": { "name": "Bob", "age": 25 }
* }
* ```
* где ключ — значение первичного ключа.
* В случае если файла нет, вернутся пустые данные
* @param tableName Имя таблицы (используется как имя файла)
*/
getFileData(tableName) {
const path = this._appContext?.appConfig.json;
const file = `${path}/${tableName}.json`;
// Так как именно модель управляет файлом, то не нужно постоянно обращаться к файлу, так как извне никто не может поменять содержимое. По крайней мере не должен!
const fileInfo = this.getCachedFileData(tableName).isFileRead
? null
: (0, index_1.getFileInfoSync)(file).data;
if (fileInfo?.isFile()) {
// При размере базы более 400мб, может произойти падение приложения.
if (fileInfo.size > 3.6e8) {
this._appContext.logError('Размер файловой Базы данных приближается к 400мб! Велика вероятность падения приложения! Рекомендуется перейти на другой адаптер(Например MongoAdapter, или какое-то свое решение) для работы с базой данных!', { fileInfo });
}
else if (fileInfo.size > 2.7e8) {
this._appContext.logWarn('Размер файловой Базы данных приближается к 300мб, при дальнейшем увеличении размера базы данных, приложение может упасть. Рекомендуется перейти на другой адаптер для работы с базой данных.', { fileInfo });
}
const getFileData = (isForce = false) => {
const cachedFileData = this.getCachedFileData(tableName);
let fileData;
if (cachedFileData && cachedFileData.version >= fileInfo.mtimeMs && !isForce) {
fileData = cachedFileData.data;
}
else {
const readResult = (0, index_1.freadSync)(file);
if (!readResult.success) {
this._appContext?.logError(`Не удалось прочитать файл "${file}". Ошибка: ${readResult.error}`);
return {};
}
fileData = readResult.data;
}
const data = typeof fileData === 'string' ? JSON.parse(fileData) : fileData;
this.setCachedFileData(tableName, {
data,
version: fileInfo.mtimeMs,
isFileRead: true,
});
return data;
};
try {
return getFileData() || {};
}
catch {
try {
// Может возникнуть ситуация когда файл прочитался во время записи, из-за чего не получится его распарсить.
// Поэтому считаем что произошла ошибка при чтении, и пробуем прочитать повторно.
return getFileData(true) || {};
}
catch (e) {
this._appContext?.logError(`Произошла ошибка при получении данных из файла ${file}. Возможно файл был поврежден.`, {
error: e.message,
});
return {};
}
}
}
else {
this.#setCachedFileData(tableName, 'isFileRead', true);
return this.getCachedFileData(tableName).data;
}
}
/**
* Проверяет, установлено ли соединение с БД.
*/
isConnected() {
return true;
}
/**
* Закрывает все подключения к файловой БД.
* Все процессы завершаются, и происходит сохранение данных в файл.
*/
destroy() {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
super.destroy();
if (this._appContext.database.databaseInfo) {
Object.keys(this._appContext.database.databaseInfo).forEach((tableName) => {
this.close(tableName);
});
}
}
/**
* Закрывает подключение к определенной таблице.
* При закрытии, все хранящиеся в памяти данные сохраняются в файл.
* @param tableName
*/
close(tableName) {
const timeOutId = this.getCachedFileData(tableName).timeOutId;
if (timeOutId) {
clearTimeout(timeOutId);
this.#setCachedFileData(tableName, 'timeOutId', null);
this.#update(tableName, true);
}
this.setCachedFileData(tableName, undefined);
}
}
exports.FileAdapter = FileAdapter;