UNPKG

inibase

Version:

A file-based & memory-efficient, serverless, ACID compliant, relational database management system

993 lines 89.9 kB
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