pallas-db
Version:
All in the name
796 lines (795 loc) • 30.7 kB
JavaScript
"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;