UNPKG

umbot

Version:

Мультиплатформенный фреймворк для создания голосовых навыков и чат-ботов с единой бизнес-логикой. Встроенная поддержка ВКонтакте, Telegram, Viber, MAX, Яндекс Алисы, Маруси и Сбера SmartApp. Архитектура на адаптерах позволяет подключать любые другие платф

404 lines (403 loc) 18.8 kB
"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;