inibase
Version:
A file-based & memory-efficient, serverless, ACID compliant, relational database management system
993 lines • 89.9 kB
JavaScript
import "dotenv/config";
import { randomBytes, scryptSync } from "node:crypto";
import { appendFileSync, existsSync, readFileSync } from "node:fs";
import { glob, mkdir, readdir, readFile, rename, rm, unlink, writeFile, } from "node:fs/promises";
import { join, parse } from "node:path";
import { inspect } from "node:util";
import Inison from "inison";
import * as File from "./file.js";
import * as Utils from "./utils.js";
import * as UtilsServer from "./utils.server.js";
export const ERROR_CODES = [
"GROUP_UNIQUE",
"FIELD_UNIQUE",
"FIELD_REQUIRED",
"NO_SCHEMA",
"TABLE_EMPTY",
"INVALID_ID",
"INVALID_TYPE",
"INVALID_PARAMETERS",
"NO_ENV",
"TABLE_EXISTS",
"TABLE_NOT_EXISTS",
"INVALID_REGEX_MATCH",
];
// hide ExperimentalWarning glob()
process.removeAllListeners("warning");
export const globalConfig = {};
/**
* @param {string} database - Database name
* @param {string} [mainFolder="."] - Main folder path
* @param {ErrorLang} [language="en"] - Language for error messages
*/
export default class Inibase {
pageInfo;
language;
fileExtension = ".txt";
totalItems;
databasePath;
uniqueMap;
schemaFileExtension = process.env.INIBASE_SCHEMA_EXTENSION ?? "json";
constructor(database, mainFolder = ".", language = "en") {
this.databasePath = join(mainFolder, database);
this.language = language;
this.pageInfo = {};
this.totalItems = new Map();
this.uniqueMap = new Map();
if (!globalConfig[this.databasePath])
globalConfig[this.databasePath] = { tables: new Map() };
if (!process.env.INIBASE_SECRET) {
if (existsSync(".env") &&
readFileSync(".env").includes("INIBASE_SECRET="))
throw this.createError("NO_ENV");
globalConfig.salt = scryptSync(randomBytes(16), randomBytes(16), 32);
appendFileSync(".env", `\nINIBASE_SECRET=${globalConfig.salt.toString("hex")}\n`);
}
else
globalConfig.salt = Buffer.from(process.env.INIBASE_SECRET, "hex");
}
static errorMessages = {
en: {
TABLE_EMPTY: "Table {variable} is empty",
TABLE_EXISTS: "Table {variable} already exists",
TABLE_NOT_EXISTS: "Table {variable} doesn't exist",
NO_SCHEMA: "Table {variable} does't have a schema",
GROUP_UNIQUE: "Group {variable} should be unique, got duplicated content in {variable}",
FIELD_UNIQUE: "Field {variable} should be unique, got {variable} instead",
FIELD_REQUIRED: "Field {variable} is required",
INVALID_ID: "The given ID(s) is/are not valid(s)",
INVALID_TYPE: "Expect {variable} to be {variable}, got {variable} instead",
INVALID_PARAMETERS: "The given parameters are not valid",
INVALID_REGEX_MATCH: "Field {variable} does not match the expected pattern",
NO_ENV: Number(process.versions.node.split(".").reduce((a, b) => a + b)) >= 26
? "please run with '--env-file=.env'"
: "please use dotenv",
},
ar: {
TABLE_EMPTY: "الجدول {variable} فارغ",
TABLE_EXISTS: "الجدول {variable} موجود بالفعل",
TABLE_NOT_EXISTS: "الجدول {variable} غير موجود",
NO_SCHEMA: "الجدول {variable} ليس لديه مخطط",
GROUP_UNIQUE: "المجموعة {variable} يجب أن تكون فريدة، تم العثور على محتوى مكرر في {variable}",
FIELD_UNIQUE: "الحقل {variable} يجب أن يكون فريدًا، تم العثور على {variable} بدلاً من ذلك",
FIELD_REQUIRED: "الحقل {variable} مطلوب",
INVALID_ID: "المعرف أو المعرفات المقدمة غير صالحة",
INVALID_TYPE: "من المتوقع أن يكون {variable} من النوع {variable}، لكن تم العثور على {variable} بدلاً من ذلك",
INVALID_PARAMETERS: "المعلمات المقدمة غير صالحة",
INVALID_REGEX_MATCH: "الحقل {variable} لا يتطابق مع النمط المتوقع",
NO_ENV: Number(process.versions.node.split(".").reduce((a, b) => a + b)) >= 26
? "يرجى التشغيل باستخدام '--env-file=.env'"
: "يرجى استخدام dotenv",
},
fr: {
TABLE_EMPTY: "La table {variable} est vide",
TABLE_EXISTS: "La table {variable} existe déjà",
TABLE_NOT_EXISTS: "La table {variable} n'existe pas",
NO_SCHEMA: "La table {variable} n'a pas de schéma",
GROUP_UNIQUE: "Le groupe {variable} doit être unique, contenu dupliqué trouvé dans {variable}",
FIELD_UNIQUE: "Le champ {variable} doit être unique, trouvé {variable} à la place",
FIELD_REQUIRED: "Le champ {variable} est obligatoire",
INVALID_ID: "Le(s) ID donné(s) n'est/ne sont pas valide(s)",
INVALID_TYPE: "Attendu que {variable} soit de type {variable}, mais trouvé {variable} à la place",
INVALID_PARAMETERS: "Les paramètres donnés ne sont pas valides",
INVALID_REGEX_MATCH: "Le champ {variable} ne correspond pas au modèle attendu",
NO_ENV: Number(process.versions.node.split(".").reduce((a, b) => a + b)) >= 26
? "veuillez exécuter avec '--env-file=.env'"
: "veuillez utiliser dotenv",
},
es: {
TABLE_EMPTY: "La tabla {variable} está vacía",
TABLE_EXISTS: "La tabla {variable} ya existe",
TABLE_NOT_EXISTS: "La tabla {variable} no existe",
NO_SCHEMA: "La tabla {variable} no tiene un esquema",
GROUP_UNIQUE: "El grupo {variable} debe ser único, se encontró contenido duplicado en {variable}",
FIELD_UNIQUE: "El campo {variable} debe ser único, se encontró {variable} en su lugar",
FIELD_REQUIRED: "El campo {variable} es obligatorio",
INVALID_ID: "El/los ID proporcionado(s) no es/son válido(s)",
INVALID_TYPE: "Se espera que {variable} sea {variable}, pero se encontró {variable} en su lugar",
INVALID_PARAMETERS: "Los parámetros proporcionados no son válidos",
INVALID_REGEX_MATCH: "El campo {variable} no coincide con el patrón esperado",
NO_ENV: Number(process.versions.node.split(".").reduce((a, b) => a + b)) >= 26
? "por favor ejecute con '--env-file=.env'"
: "por favor use dotenv",
},
};
createError(name, variable) {
const errorMessage = Inibase.errorMessages[this.language]?.[name];
if (!errorMessage)
return new Error("ERR");
const error = new Error(variable
? Array.isArray(variable)
? errorMessage.replace(/\{variable\}/g, () => variable.shift()?.toString() ?? "")
: errorMessage.replaceAll("{variable}", `'${variable.toString()}'`)
: errorMessage.replaceAll("{variable}", ""));
error.name = name;
return error;
}
getFileExtension(tableName) {
let mainExtension = this.fileExtension;
// TODO: ADD ENCRYPTION
// if(globalConfig[this.databasePath].tables?.get(tableName)?.config.encryption)
// mainExtension += ".enc"
if (globalConfig[this.databasePath].tables?.get(tableName)?.config.compression)
mainExtension += ".gz";
return mainExtension;
}
schemaToIdsPath(tableName, schema, prefix = "") {
const RETURN = {};
for (const field of schema)
if ((field.type === "array" || field.type === "object") &&
field.children &&
Utils.isArrayOfObjects(field.children))
Utils.deepMerge(RETURN, this.schemaToIdsPath(tableName, field.children, `${(prefix ?? "") + field.key}.`));
else if (field.id)
RETURN[field.id] =
`${(prefix ?? "") + field.key}${this.getFileExtension(tableName)}`;
return RETURN;
}
/**
* Create a new table inside database, with predefined schema and config
*
* @param {string} tableName
* @param {Schema} [schema]
* @param {TableConfig} [config]
*/
async createTable(tableName, schema, config) {
const tablePath = join(this.databasePath, tableName);
if (await File.isExists(tablePath))
throw this.createError("TABLE_EXISTS", tableName);
await mkdir(join(tablePath, ".tmp"), { recursive: true });
await mkdir(join(tablePath, ".cache"));
// if config not set => load default global env config
if (!config)
config = {
compression: process.env.INIBASE_COMPRESSION === "true",
cache: process.env.INIBASE_CACHE === "true",
prepend: process.env.INIBASE_PREPEND === "true",
decodeID: process.env.INIBASE_ENCODEID === "true",
};
if (config) {
if (config.compression)
await writeFile(join(tablePath, ".compression.config"), "");
if (config.cache)
await writeFile(join(tablePath, ".cache.config"), "");
if (config.prepend)
await writeFile(join(tablePath, ".prepend.config"), "");
}
if (schema) {
const lastSchemaID = { value: 0 };
await writeFile(join(tablePath, `schema.${this.schemaFileExtension}`), this.schemaFileExtension === "json"
? JSON.stringify(Utils.addIdToSchema(schema, lastSchemaID), null, 2)
: Inison.stringify(Utils.addIdToSchema(schema, lastSchemaID)));
await writeFile(join(tablePath, `${lastSchemaID.value}.schema`), "");
}
else
await writeFile(join(tablePath, "0.schema"), "");
await writeFile(join(tablePath, "0-0.pagination"), "");
}
// Function to replace the string in one schema file
async replaceStringInFile(filePath, targetString, replaceString) {
const data = await readFile(filePath, "utf8");
if (data.includes(targetString)) {
const updatedContent = data.replaceAll(targetString, replaceString);
await writeFile(filePath, updatedContent, "utf8");
}
}
/**
* Update table schema or config
*
* @param {string} tableName
* @param {Schema} [schema]
* @param {(TableConfig&{name?: string})} [config]
*/
async updateTable(tableName, schema, config) {
const table = await this.getTable(tableName);
if (!table)
return;
const tablePath = join(this.databasePath, tableName);
if (schema) {
// remove id from schema
schema = schema.filter(({ key }) => !["id", "createdAt", "updatedAt"].includes(key));
let schemaIdFilePath = "";
for await (const fileName of glob("*.schema", { cwd: tablePath }))
schemaIdFilePath = join(tablePath, fileName);
const lastSchemaID = {
value: schemaIdFilePath ? Number(parse(schemaIdFilePath).name) : 0,
};
schema = Utils.addIdToSchema(schema, lastSchemaID);
// if schema file exists, update columns files names based on field id
if ((await File.isExists(join(tablePath, `schema.${this.schemaFileExtension}`))) &&
table.schema?.length) {
const replaceOldPathes = Utils.findChangedProperties(this.schemaToIdsPath(tableName, table.schema), this.schemaToIdsPath(tableName, schema));
if (replaceOldPathes)
await Promise.allSettled(Object.entries(replaceOldPathes).map(async ([oldPath, newPath]) => {
if (await File.isExists(join(tablePath, oldPath))) {
// if newPath is null, it means the field was removed
if (newPath === null)
await unlink(join(tablePath, oldPath));
else
await rename(join(tablePath, oldPath), join(tablePath, newPath));
}
}));
}
await writeFile(join(tablePath, `schema.${this.schemaFileExtension}`), this.schemaFileExtension === "json"
? JSON.stringify(schema, null, 2)
: Inison.stringify(schema));
if (schemaIdFilePath)
await rename(schemaIdFilePath, join(tablePath, `${lastSchemaID.value}.schema`));
else
await writeFile(join(tablePath, `${lastSchemaID.value}.schema`), "");
}
if (config) {
if (config.compression !== undefined &&
config.compression !== table.config.compression) {
await UtilsServer.execFile("find", [
tableName,
"-type",
"f",
"-name",
`*${this.fileExtension}${config.compression ? "" : ".gz"}`,
"-exec",
config.compression ? "gzip" : "gunzip",
"-f",
"{}",
"+",
], { cwd: this.databasePath });
if (config.compression)
await writeFile(join(tablePath, ".compression.config"), "");
else
await unlink(join(tablePath, ".compression.config"));
}
if (config.cache !== undefined && config.cache !== table.config.cache) {
if (config.cache)
await writeFile(join(tablePath, ".cache.config"), "");
else {
await this.clearCache(tableName);
await unlink(join(tablePath, ".cache.config"));
}
}
if (config.decodeID !== undefined &&
config.decodeID !== table.config.decodeID) {
if (config.decodeID)
await writeFile(join(tablePath, ".decodeID.config"), "");
else
await unlink(join(tablePath, ".decodeID.config"));
}
if (config.prepend !== undefined &&
config.prepend !== table.config.prepend) {
await UtilsServer.execFile("find", [
tableName,
"-type",
"f",
"-name",
`*${this.fileExtension}${config.compression ? ".gz" : ""}`,
"-exec",
"sh",
"-c",
`for file; do ${config.compression
? `zcat "$file" | ${process.platform === "darwin" ? "tail -r" : "tac"} | gzip > "$file.reversed" && mv "$file.reversed" "$file"`
: `${process.platform === "darwin" ? "tail -r" : "tac"} "$file" > "$file.reversed" && mv "$file.reversed" "$file"`}; done`,
"_",
"{}",
"+",
], { cwd: this.databasePath });
if (config.prepend)
await writeFile(join(tablePath, ".prepend.config"), "");
else
await unlink(join(tablePath, ".prepend.config"));
}
if (config.name) {
await rename(tablePath, join(this.databasePath, config.name));
// replace table name in other linked tables (relationship)
for await (const schemaPath of glob(`**/schema.${this.schemaFileExtension}`, {
cwd: this.databasePath,
}))
await this.replaceStringInFile(schemaPath,
// TODO: escape caracters in table name
this.schemaFileExtension === "json"
? `"table": "${tableName}"`
: `table:${tableName}`, this.schemaFileExtension === "json"
? `"table": "${config.name}"`
: `table:${config.name}`);
}
}
globalConfig[this.databasePath].tables?.delete(tableName);
}
/**
* Get table schema and config
*
* @param {string} tableName
* @return {*} {Promise<TableObject | undefined>}
*/
async getTable(tableName) {
const tablePath = join(this.databasePath, tableName);
if (!(await File.isExists(tablePath)))
throw this.createError("TABLE_NOT_EXISTS", tableName);
if (!globalConfig[this.databasePath].tables?.has(tableName) ||
globalConfig[this.databasePath].tables?.get(tableName)?.timestamp !==
(await File.getFileDate(join(tablePath, `schema.${this.schemaFileExtension}`))))
globalConfig[this.databasePath].tables?.set(tableName, {
schema: await this.getTableSchema(tableName),
config: {
compression: await File.isExists(join(tablePath, ".compression.config")),
cache: await File.isExists(join(tablePath, ".cache.config")),
prepend: await File.isExists(join(tablePath, ".prepend.config")),
decodeID: await File.isExists(join(tablePath, ".decodeID.config")),
},
timestamp: await File.getFileDate(join(tablePath, `schema.${this.schemaFileExtension}`)),
});
return globalConfig[this.databasePath].tables?.get(tableName);
}
async getTableSchema(tableName) {
const tablePath = join(this.databasePath, tableName);
let schemaFile;
let schema;
if (!(await File.isExists(join(tablePath, `schema.${this.schemaFileExtension}`)))) {
const otherSchemaFileExtension = this.schemaFileExtension === "json" ? "inis" : "json";
if (!(await File.isExists(join(tablePath, `schema.${otherSchemaFileExtension}`))))
return undefined;
schemaFile = await readFile(join(tablePath, `schema.${otherSchemaFileExtension}`), "utf8");
if (!schemaFile)
return undefined;
schema =
otherSchemaFileExtension === "json"
? JSON.parse(schemaFile)
: Inison.unstringify(schemaFile);
await writeFile(join(tablePath, `schema.${this.schemaFileExtension}`), this.schemaFileExtension === "json"
? JSON.stringify(schema, null, 2)
: Inison.stringify(schema));
await unlink(join(tablePath, `schema.${otherSchemaFileExtension}`));
}
else
schemaFile = await readFile(join(tablePath, `schema.${this.schemaFileExtension}`), "utf8");
if (!schemaFile)
return undefined;
if (!schema)
schema =
this.schemaFileExtension === "json"
? JSON.parse(schemaFile)
: Inison.unstringify(schemaFile);
return [
{
id: 0,
key: "id",
type: "id",
required: true,
},
...(schema || []),
{
id: -1,
key: "createdAt",
type: "date",
required: true,
},
{
id: -2,
key: "updatedAt",
type: "date",
},
];
}
async throwErrorIfTableEmpty(tableName) {
const table = await this.getTable(tableName);
if (!table?.schema)
throw this.createError("NO_SCHEMA", tableName);
if (!(await File.isExists(join(this.databasePath, tableName, `id${this.getFileExtension(tableName)}`))))
throw this.createError("TABLE_EMPTY", tableName);
}
validateData(data, schema, skipRequiredField = false) {
if (Utils.isArrayOfObjects(data)) {
for (const single_data of data)
this.validateData(single_data, schema, skipRequiredField);
return;
}
if (Utils.isObject(data)) {
for (const field of schema) {
if (!Object.hasOwn(data, field.key) ||
data[field.key] === null ||
data[field.key] === undefined ||
data[field.key] === "") {
if (field.required && !skipRequiredField)
throw this.createError("FIELD_REQUIRED", field.key);
continue;
}
if (!Utils.validateFieldType(data[field.key], field))
throw this.createError("INVALID_TYPE", [
field.key,
(Array.isArray(field.type) ? field.type.join(", ") : field.type) +
(field.children
? Array.isArray(field.children)
? Utils.isArrayOfObjects(field.children)
? "[object]"
: `[${field.children.join("|")}]`
: `[${field.children}]`
: ""),
data[field.key],
]);
if ((field.type === "array" || field.type === "object") &&
field.children &&
Utils.isArrayOfObjects(field.children))
this.validateData(data[field.key], field.children, skipRequiredField);
else {
if (field.table &&
Utils.isObject(data[field.key]) &&
Object.hasOwn(data[field.key], "id"))
data[field.key] = data[field.key].id;
if (field.regex) {
const regex = UtilsServer.getCachedRegex(field.regex);
if ((Array.isArray(data[field.key]) &&
data[field.key].some((v) => !regex.test(String(v)))) ||
!regex.test(String(data[field.key])))
throw this.createError("INVALID_REGEX_MATCH", [field.key]);
}
if (field.unique && field.id !== undefined) {
const fieldId = field.id;
const uniqueKey = typeof field.unique === "boolean" ? fieldId : field.unique;
if (!this.uniqueMap.has(uniqueKey))
this.uniqueMap.set(uniqueKey, {
exclude: new Set(),
columnsValues: new Map(),
});
if (!this.uniqueMap.get(uniqueKey)?.columnsValues.has(fieldId))
this.uniqueMap
?.get(uniqueKey)
?.columnsValues.set(fieldId, new Set());
if (data.id)
this.uniqueMap.get(uniqueKey)?.exclude.add(-data.id);
this.uniqueMap
.get(uniqueKey)
?.columnsValues.get(fieldId)
?.add(data[field.key]);
}
}
}
}
}
async validateTableData(tableName, data, skipRequiredField = false) {
const clonedData = structuredClone(data);
// Skip ID and (created|updated)At
this.validateData(clonedData, globalConfig[this.databasePath].tables
?.get(tableName)
?.schema?.slice(1, -2) ?? [], skipRequiredField);
await this.checkUnique(tableName);
}
cleanObject(obj) {
const cleanedObject = Object.entries(obj).reduce((acc, [key, value]) => {
if (value !== undefined && value !== null && value !== "")
acc[key] = value;
return acc;
}, {});
return Object.keys(cleanedObject).length > 0 ? cleanedObject : null;
}
formatField(value, field, _formatOnlyAvailiableKeys) {
if (value === null || value === undefined || value === "")
return value;
if (!field)
return value;
let _fieldType = field.type;
if (Array.isArray(_fieldType))
_fieldType = Utils.detectFieldType(value, _fieldType) ?? _fieldType[0];
if (Array.isArray(value) && !["array", "json"].includes(_fieldType))
value = value[0];
switch (_fieldType) {
case "array":
if (!field.children)
return null;
if (!Array.isArray(value))
value = [value];
if (Utils.isArrayOfObjects(field.children))
return this.formatData(value, field.children, _formatOnlyAvailiableKeys);
if (!value.length)
return null;
return value.map((_value) => this.formatField(_value, {
...field,
type: field.children,
}));
case "object":
if (Utils.isArrayOfObjects(field.children))
return this.formatData(value, field.children, _formatOnlyAvailiableKeys);
break;
case "table":
if (Utils.isObject(value)) {
if (Object.hasOwn(value, "id") &&
(Utils.isValidID(value.id) ||
Utils.isNumber(value.id)))
return Utils.isNumber(value.id)
? Number(value.id)
: UtilsServer.decodeID(value.id);
}
else if (Utils.isValidID(value) || Utils.isNumber(value))
return Utils.isNumber(value)
? Number(value)
: UtilsServer.decodeID(value);
break;
case "password":
return Utils.isPassword(value)
? value
: UtilsServer.hashPassword(String(value));
case "number":
return Utils.isNumber(value)
? typeof value === "number"
? value
: Number(value.trim())
: 0;
case "id":
return Utils.isNumber(value)
? value
: UtilsServer.decodeID(value);
case "json": {
if (typeof value === "string" && Utils.isStringified(value))
return value;
if (Utils.isObject(value)) {
const cleanedObject = this.cleanObject(value);
if (cleanedObject)
return Inison.stringify(cleanedObject);
}
else
return Inison.stringify(value);
return null;
}
default:
return typeof value === "string" ? value.trim() : value;
}
return null;
}
async checkUnique(tableName) {
const tablePath = join(this.databasePath, tableName);
const flattenSchema = Utils.flattenSchema(globalConfig[this.databasePath].tables?.get(tableName)?.schema ?? []);
function hasDuplicates(setA, setB) {
for (const value of setA)
if (setB.has(value))
return true; // Stop and return true if a duplicate is found
return false; // No duplicates found
}
for await (const [_uniqueID, valueObject] of this.uniqueMap) {
let index = 0;
let shouldContinueParent = false; // Flag to manage parent loop continuation
const mergedLineNumbers = new Set();
const fieldsKeys = [];
for await (const [columnID, values] of valueObject.columnsValues) {
index++;
const field = flattenSchema.find(({ id }) => id === columnID);
if (!field)
continue;
fieldsKeys.push(field.key);
const [_, totalLines, lineNumbers] = await File.search(join(tablePath, `${field.key}${this.getFileExtension(tableName)}`), "[]", Array.from(values), undefined, valueObject.exclude, { ...field, databasePath: this.databasePath }, 1, undefined, false);
if (totalLines > 0) {
if (valueObject.columnsValues.size === 1 ||
(valueObject.columnsValues.size === index &&
!!(lineNumbers && hasDuplicates(lineNumbers, mergedLineNumbers)))) {
this.uniqueMap = new Map();
if (valueObject.columnsValues.size > 1)
throw this.createError("GROUP_UNIQUE", [
fieldsKeys.join(" & "),
field.key,
]);
throw this.createError("FIELD_UNIQUE", [
fieldsKeys.join(" & "),
Array.from(values).join(", "),
]);
}
lineNumbers?.forEach(mergedLineNumbers.add, mergedLineNumbers);
}
else {
shouldContinueParent = true; // Flag to skip the rest of this inner loop
break; // Exit the inner loop
}
}
if (shouldContinueParent)
continue;
}
this.uniqueMap = new Map();
}
formatData(data, schema, formatOnlyAvailiableKeys) {
const clonedData = structuredClone(data);
if (Utils.isArrayOfObjects(clonedData))
return clonedData.map((singleData) => this.formatData(singleData, schema, formatOnlyAvailiableKeys));
if (Utils.isObject(clonedData)) {
const RETURN = {};
for (const field of schema) {
if (!Object.hasOwn(clonedData, field.key)) {
if (formatOnlyAvailiableKeys)
RETURN[field.key] = "undefined";
else
RETURN[field.key] = this.getDefaultValue(field);
continue;
}
if (Array.isArray(clonedData[field.key]) &&
!clonedData[field.key].length) {
RETURN[field.key] = this.getDefaultValue(field);
continue;
}
RETURN[field.key] = this.formatField(clonedData[field.key], field, formatOnlyAvailiableKeys);
}
return RETURN;
}
return [];
}
getDefaultValue(field) {
if (Array.isArray(field.type))
return this.getDefaultValue({
...field,
type: field.type.sort((a, b) => Number(b === "array") - Number(a === "array") ||
Number(a === "number") - Number(b === "number") ||
Number(a === "string") - Number(b === "string"))[0],
});
switch (field.type) {
case "array":
case "object": {
if (!field.children || !Utils.isArrayOfObjects(field.children))
return null;
const RETURN = {};
for (const f of field.children)
RETURN[f.key] = this.getDefaultValue(f);
return RETURN;
}
case "number":
return 0;
case "boolean":
return false;
default:
return "";
}
}
_combineObjectsToArray(input) {
return input.reduce((result, current) => {
for (const [key, value] of Object.entries(current))
if (Object.hasOwn(result, key) && Array.isArray(result[key]))
result[key].push(value);
else
result[key] = [value];
return result;
}, {});
}
_CombineData(data, prefix) {
if (Utils.isArrayOfObjects(data))
return this._combineObjectsToArray(data.map((single_data) => this._CombineData(single_data)));
const RETURN = {};
for (const [key, value] of Object.entries(data)) {
if (Utils.isObject(value))
Object.assign(RETURN, this._CombineData(value, `${(prefix ?? "") + key}.`));
else if (Utils.isArrayOfObjects(value)) {
Object.assign(RETURN, this._CombineData(this._combineObjectsToArray(value), `${(prefix ?? "") + key}.`));
}
else if (Utils.isArrayOfArrays(value) &&
value.every(Utils.isArrayOfObjects))
Object.assign(RETURN, this._CombineData(this._combineObjectsToArray(value.map(this._combineObjectsToArray)), `${(prefix ?? "") + key}.`));
else if (value !== "undefined")
RETURN[(prefix ?? "") + key] = File.encode(Array.isArray(value)
? value.map((_value) => typeof _value === "string" && _value === "undefined"
? ""
: _value)
: value);
}
return RETURN;
}
joinPathesContents(tableName, data) {
const tablePath = join(this.databasePath, tableName);
const combinedData = this._CombineData(data);
const newCombinedData = {};
for (const [key, value] of Object.entries(combinedData))
newCombinedData[join(tablePath, `${key}${this.getFileExtension(tableName)}`)] = value;
return newCombinedData;
}
_processSchemaDataHelper(RETURN, item, index, field) {
// If the item is an object, we need to process its children
if (Utils.isObject(item)) {
if (!RETURN[index])
RETURN[index] = {}; // Ensure the index exists
if (!RETURN[index][field.key])
RETURN[index][field.key] = [];
// Process children fields (recursive if needed)
for (const child_field of field.children.filter((children) => children.type === "array" &&
Utils.isArrayOfObjects(children.children))) {
if (Utils.isObject(item[child_field.key])) {
for (const [key, value] of Object.entries(item[child_field.key])) {
for (let _i = 0; _i < value.length; _i++) {
if (value[_i] === null ||
(Array.isArray(value[_i]) && Utils.isArrayOfNulls(value[_i])))
continue;
if (!RETURN[index][field.key][_i])
RETURN[index][field.key][_i] = {};
if (!RETURN[index][field.key][_i][child_field.key])
RETURN[index][field.key][_i][child_field.key] = [];
if (!Array.isArray(value[_i])) {
if (!RETURN[index][field.key][_i][child_field.key][0])
RETURN[index][field.key][_i][child_field.key][0] = {};
RETURN[index][field.key][_i][child_field.key][0][key] =
value[_i];
}
else {
for (let _index = 0; _index < value[_i].length; _index++) {
const element = value[_i][_index];
if (element === null)
continue;
// Recursive call to handle nested structure
this._processSchemaDataHelper(RETURN, element, _index, child_field);
// Perform property assignments
if (!RETURN[index][field.key][_i][child_field.key][_index])
RETURN[index][field.key][_i][child_field.key][_index] = {};
RETURN[index][field.key][_i][child_field.key][_index][key] =
element;
}
}
}
}
}
}
}
}
async processSchemaData(tableName, schema, linesNumber, options, prefix) {
const RETURN = {};
for (const field of schema) {
// If the field is of simple type (non-recursive), process it directly
if (this.isSimpleField(field.type)) {
await this.processSimpleField(tableName, field, RETURN, linesNumber, prefix);
}
else if (this.isArrayField(field.type)) {
// Process array fields (recursive if needed)
await this.processArrayField(tableName, field, RETURN, linesNumber, options, prefix);
}
else if (this.isObjectField(field.type)) {
// Process object fields (recursive if needed)
await this.processObjectField(tableName, field, RETURN, linesNumber, options, prefix);
}
else if (this.isTableField(field.type)) {
// Process table reference fields
await this.processTableField(tableName, field, RETURN, linesNumber, options, prefix);
}
}
return RETURN;
}
// Helper function to determine if a field is simple
isSimpleField(fieldType) {
const complexTypes = ["array", "object", "table"];
if (Array.isArray(fieldType))
return fieldType.every((type) => typeof type === "string" && !complexTypes.includes(type));
return !complexTypes.includes(fieldType);
}
// Process a simple field (non-recursive)
async processSimpleField(tableName, field, RETURN, linesNumber, prefix) {
const fieldPath = join(this.databasePath, tableName, `${prefix ?? ""}${field.key}${this.getFileExtension(tableName)}`);
if (await File.isExists(fieldPath)) {
const items = await File.get(fieldPath, linesNumber, {
...field,
type: field.key === "id" &&
globalConfig[this.databasePath].tables?.get(tableName)?.config
.decodeID
? "number"
: field.type,
databasePath: this.databasePath,
});
if (items) {
for (const [index, item] of Object.entries(items)) {
if (typeof item === "undefined")
continue; // Skip undefined items
if (!RETURN[index])
RETURN[index] = {}; // Ensure the index exists
RETURN[index][field.key] = item; // Assign item to the RETURN object
}
}
}
}
// Helper function to check if the field type is array
isArrayField(fieldType) {
return ((Array.isArray(fieldType) &&
fieldType.every((type) => typeof type === "string") &&
fieldType.includes("array")) ||
fieldType === "array");
}
// Process array fields (recursive if needed)
async processArrayField(tableName, field, RETURN, linesNumber, options, prefix) {
if (Array.isArray(field.children)) {
if (this.isSimpleField(field.children)) {
await this.processSimpleField(tableName, field, RETURN, linesNumber, prefix);
}
else if (this.isTableField(field.children)) {
await this.processTableField(tableName, field, RETURN, linesNumber, options, prefix);
}
else {
let _fieldChildren = field.children;
// Handling array of objects and filtering nested arrays
const nestedArrayFields = _fieldChildren.filter((children) => children.type === "array" &&
Utils.isArrayOfObjects(children.children));
if (nestedArrayFields.length > 0) {
// one of children has array field type and has children array of object = Schema
const childItems = await this.processSchemaData(tableName, nestedArrayFields, linesNumber, options, `${(prefix ?? "") + field.key}.`);
if (childItems)
for (const [index, item] of Object.entries(childItems))
this._processSchemaDataHelper(RETURN, item, index, field);
// Remove nested arrays after processing
_fieldChildren = _fieldChildren.filter((children) => !nestedArrayFields.map(({ key }) => key).includes(children.key));
}
// Process remaining items for the field's children
const items = await this.processSchemaData(tableName, _fieldChildren, linesNumber, options, `${(prefix ?? "") + field.key}.`);
// Process the items after retrieval
if (items) {
for (const [index, item] of Object.entries(items)) {
if (typeof item === "undefined")
continue; // Skip undefined items
if (!RETURN[index])
RETURN[index] = {};
if (Utils.isObject(item)) {
const itemEntries = Object.entries(item);
const itemValues = itemEntries.map(([_key, value]) => value);
if (!Utils.isArrayOfNulls(itemValues)) {
if (RETURN[index][field.key])
for (let _index = 0; _index < itemEntries.length; _index++) {
const [key, value] = itemEntries[_index];
for (let _index = 0; _index < value.length; _index++) {
if (value[_index] === null)
continue;
if (RETURN[index][field.key][_index])
Object.assign(RETURN[index][field.key][_index], {
[key]: value[_index],
});
else
RETURN[index][field.key][_index] = {
[key]: value[_index],
};
}
}
else if (itemValues.every((_i) => Utils.isArrayOfArrays(_i)) &&
prefix)
RETURN[index][field.key] = item;
else {
RETURN[index][field.key] = [];
for (let _index = 0; _index < itemEntries.length; _index++) {
const [key, value] = itemEntries[_index];
if (!Array.isArray(value)) {
RETURN[index][field.key][_index] = {};
RETURN[index][field.key][_index][key] = value;
}
else
for (let _i = 0; _i < value.length; _i++) {
if (value[_i] === null ||
(Array.isArray(value[_i]) &&
Utils.isArrayOfNulls(value[_i])))
continue;
if (!RETURN[index][field.key][_i])
RETURN[index][field.key][_i] = {};
RETURN[index][field.key][_i][key] = Array.isArray(value[_i])
? value[_i].filter(Boolean)
: value[_i];
}
}
}
}
}
else
RETURN[index][field.key] = item;
}
}
}
}
else if (field.children != null && this.isSimpleField(field.children)) {
// If `children` is FieldType, handle it as an array of simple types (no recursion needed here)
await this.processSimpleField(tableName, field, RETURN, linesNumber, prefix);
}
else if (field.children != null && this.isTableField(field.children)) {
await this.processTableField(tableName, field, RETURN, linesNumber, options, prefix);
}
}
// Helper function to check if the field type is object
isObjectField(fieldType) {
return (fieldType === "object" ||
(Array.isArray(fieldType) &&
fieldType.every((type) => typeof type === "string") &&
fieldType.includes("object")));
}
// Process object fields (recursive if needed)
async processObjectField(tableName, field, RETURN, linesNumber, options, prefix) {
if (Array.isArray(field.children)) {
// If `children` is a Schema (array of Field objects), recurse
const items = await this.processSchemaData(tableName, field.children, linesNumber, options, `${prefix ?? ""}${field.key}.`);
for (const [index, item] of Object.entries(items)) {
if (typeof item === "undefined")
continue; // Skip undefined items
if (!RETURN[index])
RETURN[index] = {};
if (Utils.isObject(item)) {
if (!Object.values(item).every((i) => i === null || i === 0))
RETURN[index][field.key] = item;
}
}
}
}
// Helper function to check if the field type is table
isTableField(fieldType) {
return (fieldType === "table" ||
(Array.isArray(fieldType) &&
fieldType.every((type) => typeof type === "string") &&
fieldType.includes("table")));
}
// Process table reference fields
async processTableField(tableName, field, RETURN, linesNumber, options, prefix) {
if (field.table &&
(await File.isExists(join(this.databasePath, field.table)))) {
const fieldPath = join(this.databasePath, tableName, `${prefix ?? ""}${field.key}${this.getFileExtension(tableName)}`);
if (await File.isExists(fieldPath)) {
// add table to globalConfig
await this.getTable(field.table);
const itemsIDs = (await File.get(fieldPath, linesNumber, {
...field,
databasePath: this.databasePath,
}));
if (itemsIDs) {
const searchableIDs = new Map();
for (const [lineNumber, lineContent] of Object.entries(itemsIDs)) {
if (typeof lineContent === "undefined")
continue; // Skip undefined items
if (!RETURN[lineNumber])
RETURN[lineNumber] = {};
if (lineContent !== null && lineContent !== undefined)
searchableIDs.set(lineNumber, lineContent);
}
if (searchableIDs.size) {
const items = await this.get(field.table, Array.from(new Set(Array.from(searchableIDs.values()).flat()))
.flat()
.filter((item) => item), {
...options,
perPage: -1,
columns: options?.columns
?.filter((column) => column.includes(`${field.key}.`))
.map((column) => column.replace(`${field.key}.`, "")),
});
const formatLineContent = (lineContent) => Array.isArray(lineContent)
? lineContent
.map((singleContent) => singleContent
? Array.isArray(singleContent)
? singleContent.map(formatLineContent)
: items?.find(({ id }) => singleContent === id)
: singleContent)
.filter((item) => item !== undefined)
: items?.find(({ id }) => lineContent === id);
for (const [lineNumber, lineContent] of searchableIDs.entries()) {
if (!lineContent)
continue;
const formatedLineContent = formatLineContent(lineContent);
if (formatedLineContent &&
(!Array.isArray(formatedLineContent) ||
formatedLineContent.length > 0))
RETURN[lineNumber][field.key] = formatedLineContent;
}
}
}
}
}
}
_setNestedKey(obj, path, value) {
const keys = path.split(".");
const lastKey = keys.pop();
const target = keys.reduce((acc, key) => {
if (typeof acc[k