UNPKG

pallas-db

Version:
796 lines (795 loc) 30.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.PallasDB = void 0; const sequelize_1 = require("sequelize"); const node_fs_1 = require("node:fs"); const path = require("path"); const columnsByDatabaseDialect = { sqlite: ["ID", "json"], postgres: ["id", "value"], mysql: ["ID", "json"], memory: ["id", "value"], json: ["id", "value"] }; // Global in-memory storage for all instances const memoryStorage = new Map(); // JSON file storage management class JSONStorage { filePath; data = {}; constructor(filePath) { this.filePath = filePath; this.loadFromFile(); } loadFromFile() { try { if ((0, node_fs_1.existsSync)(this.filePath)) { const fileContent = (0, node_fs_1.readFileSync)(this.filePath, 'utf8'); const rawData = JSON.parse(fileContent); // Handle migration from old format to new format this.data = {}; for (const [tableName, tableData] of Object.entries(rawData)) { if (Array.isArray(tableData)) { // New format: array of { id, value } this.data[tableName] = tableData; } else if (typeof tableData === 'object' && tableData !== null) { // Old format: object with keys as properties - convert to new format this.data[tableName] = Object.entries(tableData).map(([id, value]) => ({ id, value: value })); } else { this.data[tableName] = []; } } } } catch (error) { console.warn('Failed to load JSON file, starting with empty data:', error); this.data = {}; } } saveToFile() { try { const dir = path.dirname(this.filePath); if (!(0, node_fs_1.existsSync)(dir)) { (0, node_fs_1.mkdirSync)(dir, { recursive: true }); } (0, node_fs_1.writeFileSync)(this.filePath, JSON.stringify(this.data, null, 2), 'utf8'); } catch (error) { console.error('Failed to save JSON file:', error); } } getTable(tableName) { if (!this.data[tableName]) { this.data[tableName] = []; } return this.data[tableName]; } findRecord(tableName, id) { const table = this.getTable(tableName); const index = table.findIndex(record => record.id === id); return { record: index !== -1 ? table[index] : null, index }; } getRecord(tableName, id) { const { record } = this.findRecord(tableName, id); return record?.value; } setTableData(tableName, key, value) { const table = this.getTable(tableName); const { record, index } = this.findRecord(tableName, key); if (record) { // Update existing record table[index] = { id: key, value }; } else { // Add new record table.push({ id: key, value }); } this.saveToFile(); } deleteTableKey(tableName, key) { if (this.data[tableName]) { const { index } = this.findRecord(tableName, key); if (index !== -1) { this.data[tableName].splice(index, 1); this.saveToFile(); } } } clearTable(tableName) { this.data[tableName] = []; this.saveToFile(); } hasKey(tableName, key) { const { record } = this.findRecord(tableName, key); return record !== null; } getAllFromTable(tableName) { return this.getTable(tableName); } getStats() { const stats = {}; for (const [tableName, table] of Object.entries(this.data)) { stats[tableName] = table.length; } return stats; } repair(tables, validateKey, validateValue) { const deletedKeys = []; for (const tableName of tables) { if (this.data[tableName]) { const validRecords = []; for (const record of this.data[tableName]) { if (validateKey(record.id) && validateValue(record.value)) { validRecords.push(record); } else { deletedKeys.push(`${tableName}.${record.id}`); } } this.data[tableName] = validRecords; } } if (deletedKeys.length > 0) { this.saveToFile(); } return deletedKeys; } } // Global JSON storage instances const jsonStorageInstances = new Map(); class PallasDB { /** * Validates that a value respects the AnyValue schema (PrimitiveValue, ComplexObject or Array). * Rejects undefined or null values. */ validateValue(value) { // undefined or null are never allowed if (typeof value === 'undefined' || value === null) return false; // string, number, boolean are OK if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return true; // Array: is OK if (Array.isArray(value)) { return true; } // Object: is OK if (typeof value === 'object') { return true; } // Other types = invalid return false; } validateKey(key) { // undefined or null are never allowed if (typeof key === 'undefined' || key === null) return false; // string, number are OK if (typeof key === 'string' || typeof key === 'number') return true; // Arrays and objects are not allowed if (Array.isArray(key) || typeof key === 'object') { return false; } if (typeof key === 'object') { return false; } // Other types = invalid return false; } /** * Repairs the database: removes or corrects abnormal rows. * Logs the corrections made. */ async repair() { if (this.options.dialect === 'memory') { // For memory dialect, clean Maps directly for (const tableName of this.options.tables) { const table = this.getMemoryTable(tableName); const keysToDelete = []; for (const [id, value] of table.entries()) { if (!this.validateKey(id) || !this.validateValue(value)) { keysToDelete.push(id); this.verboseLog(`[REPAIR] Row deleted (table: ${tableName})`, { id, value }); } } keysToDelete.forEach(key => table.delete(key)); } this.verboseLog('[REPAIR] Memory repair completed.'); return; } if (this.options.dialect === 'json') { // For JSON dialect, use JSONStorage repair method const jsonStorage = this.getJSONStorage(); const deletedKeys = jsonStorage.repair(this.options.tables, this.validateKey.bind(this), this.validateValue.bind(this)); deletedKeys.forEach(key => { this.verboseLog(`[REPAIR] Row deleted: ${key}`); }); this.verboseLog('[REPAIR] JSON repair completed.'); return; } await this.ensureModelsInitialized(); for (const tableName of this.options.tables) { const [idColumn, valueColumn] = this.getColumnsName(this.options.dialect); const records = await this.models[tableName].findAll(); for (const record of records) { const id = record.get(idColumn); const value = record.get(valueColumn); let toDelete = false; let toFix = false; // Abnormal cases: undefined id or value, empty value, etc. if (typeof id === 'undefined' || !this.validateValue(value) || !this.validateValue(id)) { toDelete = true; } // Possible correction: value is an empty object → deletion if (toDelete) { await record.destroy(); this.verboseLog(`[REPAIR] Row deleted (table: ${tableName})`, { id, value }); } else if (toFix) { // If we want to correct instead of deleting, we can do it here // await record.update({ [valueColumn]: correctedValue }); } } } this.verboseLog('[REPAIR] Repair completed.'); } models = {}; options; sequelize; currentTable; constructor(options, initialModels, initialTable) { if (!options.tables || options.tables.length === 0) { options.tables = ["json"]; } this.options = options; // Memory dialect specific initialization if (options.dialect === 'memory') { // Initialize memory tables for (const tableName of options.tables) { if (!memoryStorage.has(tableName)) { memoryStorage.set(tableName, new Map()); } } this.currentTable = options.tables[0]; // Skip Sequelize initialization for memory dialect return; } // JSON dialect specific initialization if (options.dialect === 'json') { const filePath = options.filePath || path.join(process.cwd(), 'database.json'); // Create or get JSON storage instance if (!jsonStorageInstances.has(filePath)) { jsonStorageInstances.set(filePath, new JSONStorage(filePath)); } this.currentTable = options.tables[0]; // Skip Sequelize initialization for JSON dialect return; } const sequelizeOptions = { dialect: options.dialect, }; if (options.dialect === "postgres") { options.tables = options.tables.map(tableName => tableName.toLowerCase()); } if (options.dialect === 'sqlite') { sequelizeOptions.storage = options.filePath || path.join(process.cwd(), 'database.sqlite'); const storageDir = path.dirname(sequelizeOptions.storage); if (!(0, node_fs_1.existsSync)(storageDir)) { (0, node_fs_1.mkdirSync)(storageDir, { recursive: true }); } } else { sequelizeOptions.host = options.login?.host || 'localhost'; sequelizeOptions.port = options.login?.port || (options.dialect === 'postgres' ? 5432 : 3306); sequelizeOptions.username = options.login?.username; sequelizeOptions.password = options.login?.password; sequelizeOptions.database = options.login?.database; } this.sequelize = new sequelize_1.Sequelize(sequelizeOptions.database || "database", // database name for postgres/mysql sequelizeOptions.username || '', sequelizeOptions.password || '', { ...sequelizeOptions, logQueryParameters: false, logging: false }); if (initialModels) { this.models = initialModels; this.currentTable = this.secureTableName(initialTable) || this.secureTableName(options.tables[0]); } else { this.initModels() .then(models => { this.models = models; this.currentTable = this.secureTableName(options.tables[0]); }) .catch(error => { this.verboseLog('Error during model initialization:', error); }); this.currentTable = this.secureTableName(options.tables[0]); } } secureTableName(str) { if (this.options.dialect === "postgres") { return str.toLowerCase(); } return str; } getColumnsName(str) { return columnsByDatabaseDialect[str]; } getMemoryTable(tableName) { if (!memoryStorage.has(tableName)) { memoryStorage.set(tableName, new Map()); } return memoryStorage.get(tableName); } getJSONStorage() { const filePath = this.options.filePath || path.join(process.cwd(), 'database.json'); if (!jsonStorageInstances.has(filePath)) { jsonStorageInstances.set(filePath, new JSONStorage(filePath)); } return jsonStorageInstances.get(filePath); } table(tableName) { const newInstance = new PallasDB(this.options, this.models, this.currentTable); newInstance.currentTable = this.secureTableName(tableName); return newInstance; } verboseLog(...args) { if (this.options.enableVerbose === true) { console.log('[PallasDB Verbose]', ...args); } } async initModels() { const models = {}; const [idColumn, valueColumn] = this.getColumnsName(this.options.dialect); // PostgreSQL detection const isPostgres = this.options.dialect === "postgres"; for (const tableName of this.options.tables) { const model = this.sequelize.define(tableName, { [idColumn]: { type: sequelize_1.DataTypes.STRING, primaryKey: true, unique: true }, [valueColumn]: { type: isPostgres ? sequelize_1.DataTypes.TEXT : sequelize_1.DataTypes.JSON, allowNull: false, get() { const rawValue = this.getDataValue(valueColumn); if (isPostgres) { try { return JSON.parse(rawValue); } catch { return rawValue; } } return rawValue; }, set(value) { if (isPostgres) { this.setDataValue(valueColumn, JSON.stringify(value)); } else { this.setDataValue(valueColumn, value); } }, }, }, { tableName: tableName, timestamps: false, // Remove explicit indexes since primaryKey already creates a unique index indexes: [] }); try { await model.sync({ alter: true }); } catch (syncError) { // If sync fails due to existing indexes, try without altering if (syncError.message && syncError.message.includes('already exists')) { this.verboseLog(`[INIT] Index already exists for table ${tableName}, continuing...`); // Table already exists with proper structure, no need to sync } else { throw syncError; } } // @ts-ignore models[tableName] = model; } return models; } async forceSync() { if (this.options.dialect === 'memory') { // For memory dialect, clear all tables for (const tableName of this.options.tables) { const table = this.getMemoryTable(tableName); table.clear(); } console.log('Memory tables cleared!'); return; } if (this.options.dialect === 'json') { // For JSON dialect, clear all tables const jsonStorage = this.getJSONStorage(); for (const tableName of this.options.tables) { jsonStorage.clearTable(tableName); } console.log('JSON tables cleared!'); return; } try { for (const tableName of this.options.tables) { await this.models[tableName].sync({ force: true }); } console.log('Database tables forcefully synchronized!'); } catch (error) { console.error('Unable to force sync tables : ', error); throw error; } } async ensureModelsInitialized() { if (this.options.dialect === 'memory' || this.options.dialect === 'json') { // For memory and JSON dialects, no models to initialize return; } if (Object.keys(this.models).length === 0) { try { this.models = await this.initModels(); this.currentTable = this.secureTableName(this.options.tables[0]); } catch (error) { console.error('Failed to initialize models:', error); throw error; } } } getBaseKey(key) { return key.split('.')[0]; } getNestedKeys(key) { return key.split('.').slice(1); } setNestedValue(obj, keys, value) { if (keys.length === 0) return value; const [firstKey, ...restKeys] = keys; const newObj = { ...obj }; if (restKeys.length === 0) { newObj[firstKey] = value; } else { newObj[firstKey] = this.setNestedValue(newObj[firstKey] || {}, restKeys, value); } return newObj; } getNestedValue(obj, keys) { let current = obj; for (const key of keys) { if (current === undefined || current === null) return undefined; current = current[key]; } return current; } deleteNestedKey(obj, keys) { if (keys.length === 1) { const newObj = { ...obj }; delete newObj[keys[0]]; return newObj; } const [firstKey, ...remainingKeys] = keys; return { ...obj, [firstKey]: this.deleteNestedKey(obj[firstKey] || {}, remainingKeys) }; } async get(key, defaultValue = undefined) { if (this.options.dialect === 'memory') { const table = this.getMemoryTable(this.currentTable); const baseKey = this.getBaseKey(key); const nestedKeys = this.getNestedKeys(key); if (!table.has(baseKey)) return defaultValue; const value = table.get(baseKey); if (nestedKeys.length === 0) return value; return this.getNestedValue(value, nestedKeys) ?? defaultValue; } if (this.options.dialect === 'json') { const jsonStorage = this.getJSONStorage(); const baseKey = this.getBaseKey(key); const nestedKeys = this.getNestedKeys(key); const value = jsonStorage.getRecord(this.currentTable, baseKey); if (value === undefined) return defaultValue; if (nestedKeys.length === 0) return value; return this.getNestedValue(value, nestedKeys) ?? defaultValue; } const [idColumn, valueColumn] = this.getColumnsName(this.options.dialect); await this.ensureModelsInitialized(); const baseKey = this.getBaseKey(key); const nestedKeys = this.getNestedKeys(key); const record = await this.models[this.currentTable].findOne({ where: { [idColumn]: baseKey } }); if (!record) return defaultValue; if (nestedKeys.length === 0) return record.get(valueColumn); return this.getNestedValue(record.get(valueColumn), nestedKeys) ?? defaultValue; } async set(key, value) { if (!this.validateKey(key) || !this.validateValue(value)) { throw new Error(`[PallasDB] Attempted to [store] invalid key/value (${key} / ${value})`); } if (this.options.dialect === 'memory') { const table = this.getMemoryTable(this.currentTable); const baseKey = this.getBaseKey(key); const nestedKeys = this.getNestedKeys(key); if (nestedKeys.length === 0) { table.set(baseKey, value); } else { const existingValue = table.get(baseKey) || {}; const newValue = this.setNestedValue(existingValue, nestedKeys, value); table.set(baseKey, newValue); } return; } if (this.options.dialect === 'json') { const jsonStorage = this.getJSONStorage(); const baseKey = this.getBaseKey(key); const nestedKeys = this.getNestedKeys(key); if (nestedKeys.length === 0) { jsonStorage.setTableData(this.currentTable, baseKey, value); } else { const existingValue = jsonStorage.getRecord(this.currentTable, baseKey) || {}; const newValue = this.setNestedValue(existingValue, nestedKeys, value); jsonStorage.setTableData(this.currentTable, baseKey, newValue); } return; } const [idColumn, valueColumn] = this.getColumnsName(this.options.dialect); await this.ensureModelsInitialized(); const baseKey = this.getBaseKey(key); const nestedKeys = this.getNestedKeys(key); let record = await this.models[this.currentTable].findOne({ where: { [idColumn]: baseKey } }); if (nestedKeys.length === 0) { if (record) { await record.update({ [valueColumn]: value }); } else { await this.models[this.currentTable].create({ [idColumn]: baseKey, [valueColumn]: value }); } } else { const existingValue = record ? record.get(valueColumn) : {}; const newValue = this.setNestedValue(existingValue, nestedKeys, value); if (record) { await record.update({ [valueColumn]: newValue }); } else { await this.models[this.currentTable].create({ [idColumn]: baseKey, [valueColumn]: newValue }); } } } async pull(key, element) { const array = await this.get(key, []); if (!Array.isArray(array)) { throw new Error('The stored value is not an array'); } const newArray = array.filter((item) => item !== element); await this.set(key, newArray); } async add(key, amount) { if (!this.validateKey(key) || !this.validateValue(amount)) { throw new Error(`[PallasDB] Attempted to [add] invalid key/value (${key} / ${amount})`); } await this.ensureModelsInitialized(); const currentValue = await this.get(key) || 0; if (typeof currentValue !== 'number') { throw new TypeError("Cannot add to a non-number value"); } await this.set(key, currentValue + amount); } async sub(key, amount) { if (!this.validateKey(key) || !this.validateValue(amount)) { throw new Error(`[PallasDB] Attempted to [sub] invalid key/value (${key} / ${amount})`); } return this.add(key, -amount); } async delete(key) { if (this.options.dialect === 'memory') { const table = this.getMemoryTable(this.currentTable); const baseKey = this.getBaseKey(key); const nestedKeys = this.getNestedKeys(key); if (nestedKeys.length === 0) { table.delete(baseKey); return; } if (table.has(baseKey)) { const value = table.get(baseKey); const updatedValue = this.deleteNestedKey(value, nestedKeys); if (Object.keys(updatedValue).length === 0) { table.delete(baseKey); } else { table.set(baseKey, updatedValue); } } return; } if (this.options.dialect === 'json') { const jsonStorage = this.getJSONStorage(); const baseKey = this.getBaseKey(key); const nestedKeys = this.getNestedKeys(key); if (nestedKeys.length === 0) { jsonStorage.deleteTableKey(this.currentTable, baseKey); return; } const value = jsonStorage.getRecord(this.currentTable, baseKey); if (value !== undefined) { const updatedValue = this.deleteNestedKey(value, nestedKeys); if (Object.keys(updatedValue).length === 0) { jsonStorage.deleteTableKey(this.currentTable, baseKey); } else { jsonStorage.setTableData(this.currentTable, baseKey, updatedValue); } } return; } const [idColumn, valueColumn] = this.getColumnsName(this.options.dialect); await this.ensureModelsInitialized(); const baseKey = this.getBaseKey(key); const nestedKeys = this.getNestedKeys(key); if (nestedKeys.length === 0) { await this.models[this.currentTable].destroy({ where: { [idColumn]: baseKey } }); return; } const record = await this.models[this.currentTable].findOne({ where: { [idColumn]: baseKey } }); if (record) { const value = record.get(valueColumn); const updatedValue = this.deleteNestedKey(value, nestedKeys); if (Object.keys(updatedValue).length === 0) { await this.models[this.currentTable].destroy({ where: { [idColumn]: baseKey } }); } else { await record.update({ [valueColumn]: updatedValue }); } } } async cache(key, value, time) { await this.ensureModelsInitialized(); await this.set(key, value); setTimeout(async () => { await this.delete(key); }, time); } async push(key, element) { if (!this.validateKey(key) || !this.validateValue(element)) { throw new Error(`[PallasDB] Attempted to [push] invalid key/value (${key} / ${element})`); } await this.ensureModelsInitialized(); const current = await this.get(key) || []; if (!Array.isArray(current)) { throw new Error("Cannot push to a non-array value"); } await this.set(key, [...current, element]); } async deleteAll() { if (this.options.dialect === 'memory') { const table = this.getMemoryTable(this.currentTable); table.clear(); return; } if (this.options.dialect === 'json') { const jsonStorage = this.getJSONStorage(); jsonStorage.clearTable(this.currentTable); return; } await this.ensureModelsInitialized(); await this.models[this.currentTable].destroy({ where: {}, truncate: true }); } async has(key) { if (this.options.dialect === 'memory') { const table = this.getMemoryTable(this.currentTable); const baseKey = this.getBaseKey(key); const nestedKeys = this.getNestedKeys(key); if (!table.has(baseKey)) return false; if (nestedKeys.length === 0) return true; return this.getNestedValue(table.get(baseKey), nestedKeys) !== undefined; } if (this.options.dialect === 'json') { const jsonStorage = this.getJSONStorage(); const baseKey = this.getBaseKey(key); const nestedKeys = this.getNestedKeys(key); if (!jsonStorage.hasKey(this.currentTable, baseKey)) return false; if (nestedKeys.length === 0) return true; const value = jsonStorage.getRecord(this.currentTable, baseKey); return this.getNestedValue(value, nestedKeys) !== undefined; } const [idColumn, valueColumn] = this.getColumnsName(this.options.dialect); await this.ensureModelsInitialized(); const baseKey = this.getBaseKey(key); const nestedKeys = this.getNestedKeys(key); const record = await this.models[this.currentTable].findOne({ where: { [idColumn]: baseKey } }); if (!record) return false; if (nestedKeys.length === 0) return true; return this.getNestedValue(record.get(valueColumn), nestedKeys) !== undefined; } async all() { if (this.options.dialect === 'memory') { const table = this.getMemoryTable(this.currentTable); return Array.from(table.entries()).map(([id, value]) => ({ id, value })); } if (this.options.dialect === 'json') { const jsonStorage = this.getJSONStorage(); return jsonStorage.getAllFromTable(this.currentTable); } await this.ensureModelsInitialized(); const [idColumn, valueColumn] = this.getColumnsName(this.options.dialect); const records = await this.models[this.currentTable].findAll(); return records.map(record => ({ id: record.get(idColumn), value: record.get(valueColumn) })); } /** * Memory and JSON dialect specific method to get usage statistics */ getMemoryStats() { if (this.options.dialect === 'memory') { const stats = {}; for (const tableName of this.options.tables) { const table = this.getMemoryTable(tableName); stats[tableName] = table.size; } return stats; } if (this.options.dialect === 'json') { const jsonStorage = this.getJSONStorage(); return jsonStorage.getStats(); } return null; } } exports.PallasDB = PallasDB;