inibase
Version:
A file-based & memory-efficient, serverless, ACID compliant, relational database management system
648 lines (647 loc) • 27.2 kB
JavaScript
import { access, appendFile, copyFile, constants as fsConstants, open, readFile, stat, unlink, writeFile, } from "node:fs/promises";
import { join, resolve } from "node:path";
import { createInterface } from "node:readline";
import { Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import { createGunzip, createGzip } from "node:zlib";
import Inison from "inison";
import { globalConfig, } from "./index.js";
import { detectFieldType, isArrayOfObjects, isNumber, isObject, isStringified, } from "./utils.js";
import { compare, encodeID, exec, gunzip, gzip } from "./utils.server.js";
export const lock = async (folderPath, prefix) => {
let lockFile = null;
const lockFilePath = join(folderPath, `${prefix ?? ""}.locked`);
try {
lockFile = await open(lockFilePath, "wx");
return;
}
catch ({ message }) {
if (message.split(":")[0] === "EEXIST")
return await new Promise((resolve) => setTimeout(() => resolve(lock(folderPath, prefix)), 13));
}
finally {
await lockFile?.close();
}
};
export const unlock = async (folderPath, prefix) => {
try {
await unlink(join(folderPath, `${prefix ?? ""}.locked`));
}
catch { }
};
export const write = async (filePath, data) => {
await writeFile(filePath, filePath.endsWith(".gz") ? await gzip(data) : data);
};
export const read = async (filePath) => filePath.endsWith(".gz")
? (await gunzip(await readFile(filePath, "utf8"))).toString()
: await readFile(filePath, "utf8");
export function escapeShellPath(filePath) {
// Resolve the path to avoid relative path issues
const resolvedPath = resolve(filePath);
// Escape double quotes and special shell characters
return `"${resolvedPath.replace(/(["\\$`])/g, "\\$1")}"`;
}
const _pipeline = async (filePath, rl, writeStream, transform) => {
if (filePath.endsWith(".gz"))
await pipeline(rl, transform, createGzip(), writeStream);
else
await pipeline(rl, transform, writeStream);
};
/**
* Creates a readline interface for a given file handle.
*
* @param fileHandle - The file handle from which to create a read stream.
* @returns A readline.Interface instance configured with the provided file stream.
*/
const createReadLineInternface = (filePath, fileHandle) => createInterface({
input: filePath.endsWith(".gz")
? fileHandle.createReadStream().pipe(createGunzip())
: fileHandle.createReadStream(),
crlfDelay: Number.POSITIVE_INFINITY,
});
/**
* Checks if a file or directory exists at the specified path.
*
* @param path - The path to the file or directory.
* @returns A Promise that resolves to true if the file/directory exists, false otherwise.
*/
export const isExists = async (path) => {
try {
await access(path, fsConstants.R_OK | fsConstants.W_OK);
return true;
}
catch {
return false;
}
};
/**
* Secures input by encoding/escaping characters.
*
* @param input - String, number, boolean, or null.
* @returns Encoded string for true/false, special characters in strings, or original input.
*/
const secureString = (input) => {
if (["true", "false"].includes(String(input)))
return input ? 1 : 0;
if (typeof input !== "string") {
if (input === null || input === undefined)
return "";
return input;
}
let decodedInput = null;
try {
decodedInput = decodeURIComponent(input);
}
catch (_error) {
decodedInput = decodeURIComponent(input.replace(/%(?![0-9][0-9a-fA-F]+)/g, ""));
}
// Replace characters using a single regular expression.
return decodedInput.replace(/\r\n|\r|\n/g, "\\n");
};
/**
* Encodes the input using 'secureString' and 'Inison.stringify' functions.
* If the input is an array, it is first secured and then joined into a string.
* If the input is a single value, it is directly secured.
*
* @param input - A value or array of values (string, number, boolean, null).
* @returns The secured and/or joined string.
*/
export const encode = (input) => Array.isArray(input)
? input.every((_input) => _input === null ||
_input === undefined ||
_input === "" ||
(typeof _input === "string" && isStringified(_input)))
? `[${input.join(",")}]`
: Inison.stringify(input)
: secureString(input);
/**
* Reverses the encoding done by 'secureString'. Replaces encoded characters with their original symbols.
*
* @param input - Encoded string.
* @returns Decoded string or null if input is empty.
*/
const unSecureString = (input) => {
if (isNumber(input))
return String(input).at(0) === "0" ? input : Number(input);
if (typeof input === "string")
return input.replace(/\\n/g, "\n") || null;
return null;
};
/**
* Decodes a value based on specified field types and optional secret key.
* Handles different data types and structures, including nested arrays.
*
* @param value - The value to be decoded, can be string, number, or array.
* @param field - Field object config.
* @returns Decoded value, transformed according to the specified field type(s).
*/
const decodeHelper = (value, field) => {
if (Array.isArray(value) && field.type !== "array")
return value.map((v) => decodeHelper(v, field));
switch (field.type) {
case "number":
return isNumber(value) ? Number(value) : null;
case "boolean":
return typeof value === "string" ? value === "true" : Boolean(value);
case "array":
if (!Array.isArray(value))
value = [value];
if (field.children && !isArrayOfObjects(field.children))
return value.map((v) => decode(v, {
...field,
type: Array.isArray(field.children)
? detectFieldType(v, field.children)
: field.children,
}));
break;
case "table":
case "id":
return isNumber(value) &&
(!field.table ||
!field.databasePath ||
!globalConfig[field.databasePath].tables?.get(field.table)?.config
.decodeID)
? encodeID(value)
: value;
default:
return value;
}
};
/**
* Decodes the input based on the specified field type(s) and an optional secret key.
* Handles different formats of input, including strings, numbers, and their array representations.
*
* @param input - The input to be decoded, can be a string, number, or null.
* @param field - Field object config.
* @returns Decoded value as a string, number, boolean, or array of these, or null if no fieldType or input is null/empty.
*/
export const decode = (input, field) => {
if (input === null || input === "")
return undefined;
// Detect the fieldType based on the input and the provided array of possible types.
// Decode the input using the decodeHelper function.
return decodeHelper(typeof input === "string"
? isStringified(input)
? Inison.unstringify(input)
: unSecureString(input)
: input, Array.isArray(field.type)
? { ...field, type: detectFieldType(String(input), field.type) }
: field);
};
function _groupIntoRanges(arr, action = "p") {
if (arr.length === 0)
return [];
arr.sort((a, b) => a - b); // Ensure the array is sorted
const ranges = [];
let start = arr[0];
let end = arr[0];
for (let i = 1; i < arr.length; i++) {
if (arr[i] === end + 1) {
// Continue the range
end = arr[i];
}
else {
// End the current range and start a new one
ranges.push(start === end ? `${start}` : `${start},${end}`);
start = arr[i];
end = arr[i];
}
}
// Push the last range
ranges.push(start === end ? `${start}` : `${start},${end}`);
return ranges.map((range) => `${range}${action}`).join(";");
}
export async function get(filePath, lineNumbers, field, readWholeFile = false) {
let fileHandle = null;
try {
fileHandle = await open(filePath, "r");
const rl = createReadLineInternface(filePath, fileHandle);
const lines = {};
let linesCount = 0;
if (!lineNumbers) {
for await (const line of rl) {
linesCount++;
lines[linesCount] = decode(line, field);
}
}
else if (lineNumbers === -1) {
const escapedFilePath = escapeShellPath(filePath);
const command = filePath.endsWith(".gz")
? `gunzip -c ${escapedFilePath} | sed -n '$p'`
: `sed -n '$p' ${escapedFilePath}`;
const foundedLine = (await exec(command)).stdout.trimEnd();
if (foundedLine)
lines[linesCount] = decode(foundedLine, field);
}
else {
lineNumbers = Array.isArray(lineNumbers) ? lineNumbers : [lineNumbers];
if (lineNumbers.some(Number.isNaN))
throw new Error("UNVALID_LINE_NUMBERS");
if (readWholeFile) {
const lineNumbersArray = new Set(lineNumbers);
for await (const line of rl) {
linesCount++;
if (!lineNumbersArray.has(linesCount))
continue;
lines[linesCount] = decode(line, field);
lineNumbersArray.delete(linesCount);
}
return [lines, linesCount];
}
const escapedFilePath = escapeShellPath(filePath);
const command = filePath.endsWith(".gz")
? `gunzip -c ${escapedFilePath} | sed -n '${_groupIntoRanges(lineNumbers)}'`
: `sed -n '${_groupIntoRanges(lineNumbers)}' ${escapedFilePath}`;
const foundedLines = (await exec(command)).stdout.trimEnd().split("\n");
let index = 0;
for (const line of foundedLines) {
lines[lineNumbers[index]] = decode(line, field);
index++;
}
}
return lines;
}
finally {
// Ensure that file handles are closed, even if an error occurred
await fileHandle?.close();
}
}
/**
* Asynchronously replaces specific lines in a file based on the provided replacements map or string.
*
* @param filePath - Path of the file to modify.
* @param replacements - Map of line numbers to replacement values, or a single replacement value for all lines.
* Can be a string, number, boolean, null, array of these types, or a Record/Map of line numbers to these types.
* @returns Promise<string[]>
*
* Note: If the file doesn't exist and replacements is an object, it creates a new file with the specified replacements.
*/
export const replace = async (filePath, replacements, totalItems) => {
const fileTempPath = filePath.replace(/([^/]+)\/?$/, ".tmp/$1");
const isReplacementsObject = isObject(replacements);
const isReplacementsLineNumbered = isReplacementsObject && !Number.isNaN(Number(Object.keys(replacements)[0]));
if (await isExists(filePath)) {
if (isReplacementsLineNumbered) {
let fileHandle = null;
let fileTempHandle = null;
try {
let linesCount = 0;
fileHandle = await open(filePath, "r");
fileTempHandle = await open(fileTempPath, "w");
const writeStream = fileTempHandle.createWriteStream();
const rl = createReadLineInternface(filePath, fileHandle);
await _pipeline(filePath, rl, writeStream, new Transform({
transform(line, _, callback) {
linesCount++;
const replacement = isReplacementsObject
? Object.hasOwn(replacements, linesCount)
? replacements[linesCount]
: line
: replacements;
return callback(null, `${replacement}\n`);
},
flush(callback) {
const remainingReplacementsKeys = Object.keys(replacements)
.map(Number)
.toSorted((a, b) => a - b)
.filter((lineNumber) => lineNumber > linesCount);
if (remainingReplacementsKeys.length)
this.push("\n".repeat(remainingReplacementsKeys[0] - linesCount - 1) +
remainingReplacementsKeys
.map((lineNumber, index) => index === 0 ||
lineNumber -
(remainingReplacementsKeys[index - 1] - 1) ===
0
? replacements[lineNumber]
: "\n".repeat(lineNumber -
remainingReplacementsKeys[index - 1] -
1) + replacements[lineNumber])
.join("\n"));
callback();
},
}));
return [fileTempPath, filePath];
}
catch {
return [fileTempPath, null];
}
finally {
// Ensure that file handles are closed, even if an error occurred
await fileHandle?.close();
await fileTempHandle?.close();
}
}
else {
const escapedFilePath = escapeShellPath(filePath);
const escapedFileTempPath = escapeShellPath(fileTempPath);
const sedCommand = `sed -e s/.*/${replacements}/ -e /^$/s/^/${replacements}/ ${escapedFilePath}`;
const command = filePath.endsWith(".gz")
? `gunzip -c ${escapedFilePath} | ${sedCommand} | gzip > ${escapedFileTempPath}`
: `${sedCommand} > ${escapedFileTempPath}`;
try {
await exec(command);
return [fileTempPath, filePath];
}
catch {
return [fileTempPath, null];
}
}
}
else if (isReplacementsObject) {
try {
if (isReplacementsLineNumbered) {
const replacementsKeys = Object.keys(replacements)
.map(Number)
.toSorted((a, b) => a - b);
await write(fileTempPath, `${"\n".repeat(replacementsKeys[0] - 1) +
replacementsKeys
.map((lineNumber, index) => index === 0 ||
lineNumber - replacementsKeys[index - 1] - 1 === 0
? replacements[lineNumber]
: "\n".repeat(lineNumber - replacementsKeys[index - 1] - 1) +
replacements[lineNumber])
.join("\n")}\n`);
}
else {
if (!totalItems)
throw new Error("INVALID_PARAMETERS");
await write(fileTempPath, `${`${replacements}\n`.repeat(totalItems)}\n`);
}
return [fileTempPath, filePath];
}
catch {
return [fileTempPath, null];
}
}
return [];
};
/**
* Asynchronously appends data to the end of a file.
*
* @param filePath - Path of the file to append to.
* @param data - Data to append. Can be a string, number, or an array of strings/numbers.
* @returns Promise<string[]>. Modifies the file by appending data.
*
*/
export const append = async (filePath, data) => {
const fileTempPath = filePath.replace(/([^/]+)\/?$/, ".tmp/$1");
try {
if (await isExists(filePath)) {
await copyFile(filePath, fileTempPath);
if (!filePath.endsWith(".gz")) {
await appendFile(fileTempPath, `${Array.isArray(data) ? data.join("\n") : data}\n`);
}
else {
const escapedFileTempPath = escapeShellPath(fileTempPath);
await exec(`echo '${(Array.isArray(data) ? data.join("\n") : data)
.toString()
.replace(/'/g, "\\'")}' | gzip - >> ${escapedFileTempPath}`);
}
}
else
await write(fileTempPath, `${Array.isArray(data) ? data.join("\n") : data}\n`);
return [fileTempPath, filePath];
}
catch {
return [fileTempPath, null];
}
};
/**
* Asynchronously prepends data to the beginning of a file.
*
* @param filePath - Path of the file to append to.
* @param data - Data to append. Can be a string, number, or an array of strings/numbers.
* @returns Promise<string[]>. Modifies the file by appending data.
*
*/
export const prepend = async (filePath, data) => {
const fileTempPath = filePath.replace(/([^/]+)\/?$/, ".tmp/$1");
if (await isExists(filePath)) {
if (!filePath.endsWith(".gz")) {
let fileHandle = null;
let fileTempHandle = null;
try {
fileHandle = await open(filePath, "r");
fileTempHandle = await open(fileTempPath, "w");
const rl = createReadLineInternface(filePath, fileHandle);
let isAppended = false;
await _pipeline(filePath, rl, fileTempHandle.createWriteStream(), new Transform({
transform(line, _, callback) {
if (!isAppended) {
isAppended = true;
return callback(null, `${Array.isArray(data) ? data.join("\n") : data}\n${`${line}\n`}`);
}
return callback(null, `${line}\n`);
},
}));
}
catch {
return [fileTempPath, null];
}
finally {
// Ensure that file handles are closed, even if an error occurred
await fileHandle?.close();
await fileTempHandle?.close();
}
}
else {
const fileChildTempPath = filePath.replace(/([^/]+)\/?$/, ".tmp/tmp_$1");
try {
await write(fileChildTempPath, `${Array.isArray(data) ? data.join("\n") : data}\n`);
const escapedFilePath = escapeShellPath(filePath);
const escapedFileTempPath = escapeShellPath(fileTempPath);
const escapedFileChildTempPath = escapeShellPath(fileChildTempPath);
await exec(`cat ${escapedFileChildTempPath} ${escapedFilePath} > ${escapedFileTempPath}`);
}
catch {
return [fileTempPath, null];
}
finally {
await unlink(fileChildTempPath);
}
}
}
else {
try {
await write(fileTempPath, `${Array.isArray(data) ? data.join("\n") : data}\n`);
}
catch {
return [fileTempPath, null];
}
}
return [fileTempPath, filePath];
};
/**
* Asynchronously removes specified lines from a file.
*
* @param filePath - Path of the file from which lines are to be removed.
* @param linesToDelete - A single line number or an array of line numbers to be deleted.
* @returns Promise<string[]>. Modifies the file by removing specified lines.
*
* Note: Creates a temporary file during the process and replaces the original file with it after removing lines.
*/
export const remove = async (filePath, linesToDelete) => {
linesToDelete = Array.isArray(linesToDelete)
? linesToDelete.map(Number)
: [Number(linesToDelete)];
if (linesToDelete.some(Number.isNaN))
throw new Error("UNVALID_LINE_NUMBERS");
const fileTempPath = filePath.replace(/([^/]+)\/?$/, ".tmp/$1");
try {
const escapedFilePath = escapeShellPath(filePath);
const escapedFileTempPath = escapeShellPath(fileTempPath);
const command = filePath.endsWith(".gz")
? `gunzip -c ${escapedFilePath} | sed '${_groupIntoRanges(linesToDelete, "d")}' | gzip > ${escapedFileTempPath}`
: `sed '${_groupIntoRanges(linesToDelete, "d")}' ${escapedFilePath} > ${escapedFileTempPath}`;
await exec(command);
return [fileTempPath, filePath];
}
catch {
return [fileTempPath, null];
}
};
/**
* Asynchronously searches a file for lines matching specified criteria, using comparison and logical operators.
*
* @param filePath - Path of the file to search.
* @param operator - Comparison operator(s) for evaluation (e.g., '=', '!=', '>', '<').
* @param comparedAtValue - Value(s) to compare each line against.
* @param logicalOperator - Optional logical operator ('and' or 'or') for combining multiple comparisons.
* @param field - Field object config.
* @param limit - Optional limit on the number of results to return.
* @param offset - Optional offset to start returning results from.
* @param readWholeFile - Flag to indicate whether to continue reading the file after reaching the limit.
* @returns Promise resolving to a tuple:
* 1. Record of line numbers and their content that match the criteria or null if none.
* 2. The count of found items or processed items based on the 'readWholeFile' flag.
*
* Note: Decodes each line for comparison and can handle complex queries with multiple conditions.
*/
export const search = async (filePath, operator, comparedAtValue, logicalOperator, searchIn, field, limit, offset, readWholeFile) => {
// Initialize a Map to store the matching lines with their line numbers.
const matchingLines = {};
// Initialize counters for line number, found items, and processed items.
let linesCount = 0;
const linesNumbers = new Set();
let fileHandle = null;
const meetsConditions = (value) => (Array.isArray(operator) &&
Array.isArray(comparedAtValue) &&
((logicalOperator === "or" &&
operator.some((single_operator, index) => compare(single_operator, value, comparedAtValue[index], field.type))) ||
operator.every((single_operator, index) => compare(single_operator, value, comparedAtValue[index], field.type)))) ||
(!Array.isArray(operator) &&
compare(operator, value, comparedAtValue, field.type));
try {
// Open the file for reading.
fileHandle = await open(filePath, "r");
// Create a Readline interface to read the file line by line.
const rl = createReadLineInternface(filePath, fileHandle);
// Iterate through each line in the file.
for await (const line of rl) {
// Increment the line count for each line.
linesCount++;
// Search only in provided linesNumbers
if (searchIn?.size &&
(!searchIn.has(linesCount) || searchIn.has(-linesCount)))
continue;
// Decode the line for comparison.
const decodedLine = decode(line, field);
// Check if the line meets the specified conditions based on comparison and logical operators.
const doesMeetCondition = (Array.isArray(decodedLine) &&
operator !== "=" &&
decodedLine.flat().some(meetsConditions)) ||
meetsConditions(decodedLine);
// If the line meets the conditions, process it.
if (doesMeetCondition) {
// Increment the found items counter.
linesNumbers.add(linesCount);
// Check if the line should be skipped based on the offset.
if (offset && linesNumbers.size < offset)
continue;
// Check if the limit has been reached.
if (limit && linesNumbers.size > limit + (offset ? offset - 1 : 0)) {
if (readWholeFile)
continue;
break;
}
// Store the decoded line in the result object.
matchingLines[linesCount] = decodedLine;
}
}
// Convert the Map to an object using Object.fromEntries and return the result.
return linesNumbers.size
? [matchingLines, linesNumbers.size, linesNumbers]
: [null, 0, null];
}
catch {
return [null, 0, null];
}
finally {
// Close the file handle in the finally block to ensure it is closed even if an error occurs.
await fileHandle?.close();
}
};
/**
* Reads the file once and returns either the sum, min or max of the
* (optionally-selected) numeric lines.
*
* @param filePath Absolute path of the column file (may be .gz-compressed).
* @param wanted Metric to compute: "sum" (default), "min" or "max".
* @param lineNumbers Specific line-number(s) to restrict the scan to.
*
* @returns Promise<number> The requested metric, or 0 if no numeric value found.
*/
async function reduceNumbers(filePath, wanted = "sum", lineNumbers) {
/* optional subset */
const filter = lineNumbers
? new Set(Array.isArray(lineNumbers) ? lineNumbers : [lineNumbers])
: null;
/* running aggregators */
let sum = 0;
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
let processed = 0; // count of numeric lines we actually used
let seen = 0; // count of filtered lines we have visited
let line = 0;
const fh = await open(filePath, "r");
const rl = createReadLineInternface(filePath, fh);
try {
for await (const txt of rl) {
line++;
/* skip unwanted lines */
if (filter && !filter.has(line))
continue;
const num = Number(decode(txt, { key: "BLABLA", type: "number" }));
if (Number.isNaN(num))
continue;
processed++;
if (wanted === "sum") {
sum += num;
}
else if (wanted === "min") {
if (num < min)
min = num;
}
else if (wanted === "max") {
if (num > max)
max = num;
}
/* early break when we have consumed all requested lines */
if (filter && ++seen === filter.size)
break;
}
}
finally {
await fh.close();
}
if (processed === 0)
return 0; // nothing numeric found
return wanted === "sum" ? sum : wanted === "min" ? min : max;
}
/* Optional convenience wrappers (signatures unchanged) */
export const sum = (fp, ln) => reduceNumbers(fp, "sum", ln);
export const min = (fp, ln) => reduceNumbers(fp, "min", ln);
export const max = (fp, ln) => reduceNumbers(fp, "max", ln);
export const getFileDate = (path) => stat(path)
.then((s) => s.mtime || s.birthtime)
.catch(() => new Date());