inibase 1.8.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -791,6 +791,7 @@ await db.get("user", undefined, { sort: {age: -1, username: "asc"} });
791
791
  | DELETE | 118 ms (0.59 mb) | 113 ms (0.51 mb) | 103 ms (3.14 mb) |
792
792
 
793
793
  > Default testing uses a table with username, email, and password fields, ensuring password encryption is included in the process<br>
794
+ > Results are measured on a default table plus dedicated tables with `prepend`, `compression`, and `decodeID` configs enabled<br>
794
795
  > To run benchmarks, install _typescript_ & _[tsx](https://github.com/privatenumber/tsx)_ globally and run `benchmark` by default bulk, for single use `benchmark --single|-s`
795
796
 
796
797
  ## Roadmap
package/dist/file.d.ts CHANGED
@@ -30,7 +30,8 @@ export declare const encode: (input: string | number | boolean | null | (string
30
30
  */
31
31
  export declare const decode: (input: string | null | number, field: Field & {
32
32
  databasePath?: string;
33
- }) => string | number | boolean | null | (string | number | null | boolean)[];
33
+ }) => string | number | boolean | null | undefined | (string | number | null | boolean)[];
34
+ export declare function _groupIntoRanges(arr: number[], action?: "p" | "d"): string | any[];
34
35
  /**
35
36
  * Asynchronously reads and decodes data from a file at specified line numbers.
36
37
  * Decodes each line based on specified field types and an optional secret key.
@@ -45,11 +46,11 @@ export declare const decode: (input: string | null | number, field: Field & {
45
46
  */
46
47
  export declare function get(filePath: string, lineNumbers?: number | number[], field?: Field & {
47
48
  databasePath?: string;
48
- }, readWholeFile?: false): Promise<Record<number, string | number | boolean | null | (string | number | boolean | (string | number | boolean)[] | null)[]> | null>;
49
+ }, readWholeFile?: false): Promise<Record<number, string | number | boolean | null | undefined | (string | number | boolean | (string | number | boolean)[] | null)[]> | null>;
49
50
  export declare function get(filePath: string, lineNumbers: undefined | number | number[], field: undefined | (Field & {
50
51
  databasePath?: string;
51
52
  }), readWholeFile: true): Promise<[
52
- Record<number, string | number | boolean | null | (string | number | boolean | (string | number | boolean)[] | null)[]> | null,
53
+ Record<number, string | number | boolean | null | undefined | (string | number | boolean | (string | number | boolean)[] | null)[]> | null,
53
54
  number
54
55
  ]>;
55
56
  /**
@@ -62,7 +63,7 @@ export declare function get(filePath: string, lineNumbers: undefined | number |
62
63
  *
63
64
  * Note: If the file doesn't exist and replacements is an object, it creates a new file with the specified replacements.
64
65
  */
65
- export declare const replace: (filePath: string, replacements: string | number | boolean | null | (string | number | boolean | null)[] | Record<number, string | boolean | number | null | (string | boolean | number | null)[]>, totalItems?: number) => Promise<string[]>;
66
+ export declare const replace: (filePath: string, replacements: string | number | boolean | null | (string | number | boolean | null)[] | Record<number, string | boolean | number | null | (string | boolean | number | null)[]>, totalItems?: number) => Promise<(string | null)[]>;
66
67
  /**
67
68
  * Asynchronously appends data to the end of a file.
68
69
  *
@@ -71,7 +72,7 @@ export declare const replace: (filePath: string, replacements: string | number |
71
72
  * @returns Promise<string[]>. Modifies the file by appending data.
72
73
  *
73
74
  */
74
- export declare const append: (filePath: string, data: string | number | (string | number)[]) => Promise<string[]>;
75
+ export declare const append: (filePath: string, data: string | number | (string | number)[]) => Promise<(string | null)[]>;
75
76
  /**
76
77
  * Asynchronously prepends data to the beginning of a file.
77
78
  *
@@ -80,7 +81,7 @@ export declare const append: (filePath: string, data: string | number | (string
80
81
  * @returns Promise<string[]>. Modifies the file by appending data.
81
82
  *
82
83
  */
83
- export declare const prepend: (filePath: string, data: string | number | (string | number)[]) => Promise<string[]>;
84
+ export declare const prepend: (filePath: string, data: string | number | (string | number)[]) => Promise<(string | null)[]>;
84
85
  /**
85
86
  * Asynchronously removes specified lines from a file.
86
87
  *
@@ -90,7 +91,7 @@ export declare const prepend: (filePath: string, data: string | number | (string
90
91
  *
91
92
  * Note: Creates a temporary file during the process and replaces the original file with it after removing lines.
92
93
  */
93
- export declare const remove: (filePath: string, linesToDelete: number | number[]) => Promise<string[]>;
94
+ export declare const remove: (filePath: string, linesToDelete: number | number[]) => Promise<(string | null)[]>;
94
95
  /**
95
96
  * Asynchronously searches a file for lines matching specified criteria, using comparison and logical operators.
96
97
  *
@@ -110,8 +111,9 @@ export declare const remove: (filePath: string, linesToDelete: number | number[]
110
111
  */
111
112
  export declare const search: (filePath: string, operator: ComparisonOperator | ComparisonOperator[], comparedAtValue: string | number | boolean | null | (string | number | boolean | null)[], logicalOperator?: "and" | "or", searchIn?: Set<number>, field?: Field & {
112
113
  databasePath?: string;
113
- }, limit?: number, offset?: number, readWholeFile?: boolean) => Promise<[Record<number, string | number | boolean | null | (string | number | boolean | null)[]> | null, number, Set<number> | null]>;
114
+ }, limit?: number, offset?: number, readWholeFile?: boolean) => Promise<[Record<number, string | number | boolean | null | undefined | (string | number | boolean | null)[]> | null, number, Set<number> | null]>;
114
115
  export declare const sum: (fp: string, ln?: number | number[]) => Promise<number>;
116
+ export declare const avg: (fp: string, ln?: number | number[]) => Promise<number>;
115
117
  export declare const min: (fp: string, ln?: number | number[]) => Promise<number>;
116
118
  export declare const max: (fp: string, ln?: number | number[]) => Promise<number>;
117
119
  export declare const getFileDate: (path: string) => Promise<Date>;
package/dist/file.js CHANGED
@@ -129,8 +129,12 @@ export const encode = (input) => Array.isArray(input)
129
129
  const unSecureString = (input) => {
130
130
  if (isNumber(input))
131
131
  return String(input).at(0) === "0" ? input : Number(input);
132
+ // Fast path: the common case has no `\n` escape sequence, so avoid
133
+ // allocating a replacement string (and a fresh RegExp) per cell.
132
134
  if (typeof input === "string")
133
- return input.replace(/\\n/g, "\n") || null;
135
+ return input.includes("\\n")
136
+ ? input.replaceAll("\\n", "\n") || null
137
+ : input;
134
138
  return null;
135
139
  };
136
140
  /**
@@ -156,7 +160,8 @@ const decodeHelper = (value, field) => {
156
160
  return value.map((v) => decode(v, {
157
161
  ...field,
158
162
  type: Array.isArray(field.children)
159
- ? detectFieldType(v, field.children)
163
+ ? (detectFieldType(v, field.children) ??
164
+ field.children[0])
160
165
  : field.children,
161
166
  }));
162
167
  break;
@@ -186,15 +191,33 @@ export const decode = (input, field) => {
186
191
  return undefined;
187
192
  // Detect the fieldType based on the input and the provided array of possible types.
188
193
  // Decode the input using the decodeHelper function.
189
- return decodeHelper(typeof input === "string"
190
- ? isStringified(input)
191
- ? Inison.unstringify(input)
192
- : unSecureString(input)
193
- : input, Array.isArray(field.type)
194
- ? { ...field, type: detectFieldType(String(input), field.type) }
194
+ let value = input;
195
+ if (typeof input === "string") {
196
+ if (isStringified(input)) {
197
+ try {
198
+ value = Inison.unstringify(input);
199
+ }
200
+ catch {
201
+ // The stored string merely *starts with* `{` or `[`
202
+ // (e.g. a template binding like `{{item.username}}`
203
+ // or a literal like `{hello world`) but is not valid
204
+ // Inison. Treat it as a plain string so a single row
205
+ // never breaks reads of the whole file/table.
206
+ value = unSecureString(input);
207
+ }
208
+ }
209
+ else
210
+ value = unSecureString(input);
211
+ }
212
+ return decodeHelper(value, Array.isArray(field.type)
213
+ ? {
214
+ ...field,
215
+ type: detectFieldType(String(input), field.type) ??
216
+ field.type[0],
217
+ }
195
218
  : field);
196
219
  };
197
- function _groupIntoRanges(arr, action = "p") {
220
+ export function _groupIntoRanges(arr, action = "p") {
198
221
  if (arr.length === 0)
199
222
  return [];
200
223
  arr.sort((a, b) => a - b); // Ensure the array is sorted
@@ -224,10 +247,14 @@ export async function get(filePath, lineNumbers, field, readWholeFile = false) {
224
247
  const rl = createReadLineInternface(filePath, fileHandle);
225
248
  const lines = {};
226
249
  let linesCount = 0;
250
+ const config = field ?? {
251
+ key: "BLABLA",
252
+ type: "string",
253
+ };
227
254
  if (!lineNumbers) {
228
255
  for await (const line of rl) {
229
256
  linesCount++;
230
- lines[linesCount] = decode(line, field);
257
+ lines[linesCount] = decode(line, config);
231
258
  }
232
259
  }
233
260
  else if (lineNumbers === -1) {
@@ -237,7 +264,7 @@ export async function get(filePath, lineNumbers, field, readWholeFile = false) {
237
264
  : `sed -n '$p' ${escapedFilePath}`;
238
265
  const foundedLine = (await exec(command)).stdout.trimEnd();
239
266
  if (foundedLine)
240
- lines[linesCount] = decode(foundedLine, field);
267
+ lines[linesCount] = decode(foundedLine, config);
241
268
  }
242
269
  else {
243
270
  lineNumbers = Array.isArray(lineNumbers) ? lineNumbers : [lineNumbers];
@@ -249,7 +276,7 @@ export async function get(filePath, lineNumbers, field, readWholeFile = false) {
249
276
  linesCount++;
250
277
  if (!lineNumbersArray.has(linesCount))
251
278
  continue;
252
- lines[linesCount] = decode(line, field);
279
+ lines[linesCount] = decode(line, config);
253
280
  lineNumbersArray.delete(linesCount);
254
281
  }
255
282
  return [lines, linesCount];
@@ -261,7 +288,7 @@ export async function get(filePath, lineNumbers, field, readWholeFile = false) {
261
288
  const foundedLines = (await exec(command)).stdout.trimEnd().split("\n");
262
289
  let index = 0;
263
290
  for (const line of foundedLines) {
264
- lines[lineNumbers[index]] = decode(line, field);
291
+ lines[lineNumbers[index]] = decode(line, config);
265
292
  index++;
266
293
  }
267
294
  }
@@ -561,7 +588,7 @@ function buildEqualsPatterns(comparedAtValue, field) {
561
588
  for (const form of [...new Set(forms)]) {
562
589
  let valid = false;
563
590
  try {
564
- valid = compare("=", decode(form, field), value, type);
591
+ valid = compare("=", decode(form, field) ?? null, value, type);
565
592
  }
566
593
  catch {
567
594
  valid = false;
@@ -578,7 +605,7 @@ function buildEqualsPatterns(comparedAtValue, field) {
578
605
  const form = String(Number(value));
579
606
  let valid = false;
580
607
  try {
581
- valid = compare("=", decode(form, field), value, type);
608
+ valid = compare("=", decode(form, field) ?? null, value, type);
582
609
  }
583
610
  catch {
584
611
  valid = false;
@@ -607,7 +634,7 @@ function buildEqualsPatterns(comparedAtValue, field) {
607
634
  const form = String(encoded);
608
635
  let valid = false;
609
636
  try {
610
- valid = compare("=", decode(form, field), value, type);
637
+ valid = compare("=", decode(form, field) ?? null, value, type);
611
638
  }
612
639
  catch {
613
640
  valid = false;
@@ -642,7 +669,7 @@ async function searchEqualsNative(filePath, patterns, field, searchIn, comparedA
642
669
  // (decode() eagerly unstringifies them), so a native whole-line grep could
643
670
  // silently miss matches that live inside those containers. Detect such lines
644
671
  // up front; when any are present, fall back to the JS reader.
645
- let probe;
672
+ let probe = "";
646
673
  try {
647
674
  ({ stdout: probe } = await exec(`LC_ALL=C ${source} | LC_ALL=C grep -a -m 1 -E '^[[{]'`, { maxBuffer: 1024 * 1024 * 4 }));
648
675
  }
@@ -696,8 +723,8 @@ async function searchEqualsNative(filePath, patterns, field, searchIn, comparedA
696
723
  // Defensive verification: the raw line must decode to one of the compared
697
724
  // values (guaranteed by construction, kept as a safety net).
698
725
  const verifies = Array.isArray(comparedAtValue)
699
- ? comparedAtValue.some((value) => compare("=", decodedLine, value, field.type))
700
- : compare("=", decodedLine, comparedAtValue, field.type);
726
+ ? comparedAtValue.some((value) => compare("=", decodedLine ?? null, value, field.type))
727
+ : compare("=", decodedLine ?? null, comparedAtValue, field.type);
701
728
  if (!verifies)
702
729
  return null;
703
730
  matchingLines[lineNumber] = decodedLine;
@@ -724,14 +751,13 @@ async function searchEqualsNative(filePath, patterns, field, searchIn, comparedA
724
751
  */
725
752
  export const search = async (filePath, operator, comparedAtValue, logicalOperator, searchIn, field, limit, offset, readWholeFile) => {
726
753
  // Native fast path for exact-equality searches (whole-line `grep -x -F`).
727
- const fieldType = field?.type;
728
754
  if (operator === "=" &&
729
755
  !Array.isArray(operator) &&
730
756
  !logicalOperator &&
731
757
  comparedAtValue !== null &&
732
758
  comparedAtValue !== undefined &&
733
- typeof fieldType === "string" &&
734
- EQUALS_FAST_TYPES.has(fieldType)) {
759
+ typeof field?.type === "string" &&
760
+ EQUALS_FAST_TYPES.has(field.type)) {
735
761
  const patterns = buildEqualsPatterns(comparedAtValue, field);
736
762
  if (patterns) {
737
763
  const fastResult = await searchEqualsNative(filePath, patterns, field, searchIn, comparedAtValue, limit, offset, readWholeFile);
@@ -745,13 +771,17 @@ export const search = async (filePath, operator, comparedAtValue, logicalOperato
745
771
  let linesCount = 0;
746
772
  const linesNumbers = new Set();
747
773
  let fileHandle = null;
774
+ const config = field ?? {
775
+ key: "BLABLA",
776
+ type: "string",
777
+ };
748
778
  const meetsConditions = (value) => (Array.isArray(operator) &&
749
779
  Array.isArray(comparedAtValue) &&
750
780
  ((logicalOperator === "or" &&
751
- operator.some((single_operator, index) => compare(single_operator, value, comparedAtValue[index], field.type))) ||
752
- operator.every((single_operator, index) => compare(single_operator, value, comparedAtValue[index], field.type)))) ||
781
+ operator.some((single_operator, index) => compare(single_operator, value, comparedAtValue[index], config.type))) ||
782
+ operator.every((single_operator, index) => compare(single_operator, value, comparedAtValue[index], config.type)))) ||
753
783
  (!Array.isArray(operator) &&
754
- compare(operator, value, comparedAtValue, field.type));
784
+ compare(operator, value, comparedAtValue, config.type));
755
785
  try {
756
786
  // Open the file for reading.
757
787
  fileHandle = await open(filePath, "r");
@@ -766,7 +796,7 @@ export const search = async (filePath, operator, comparedAtValue, logicalOperato
766
796
  (!searchIn.has(linesCount) || searchIn.has(-linesCount)))
767
797
  continue;
768
798
  // Decode the line for comparison.
769
- const decodedLine = decode(line, field);
799
+ const decodedLine = decode(line, config);
770
800
  // Check if the line meets the specified conditions based on comparison and logical operators.
771
801
  const doesMeetCondition = (Array.isArray(decodedLine) &&
772
802
  operator !== "=" &&
@@ -803,11 +833,11 @@ export const search = async (filePath, operator, comparedAtValue, logicalOperato
803
833
  }
804
834
  };
805
835
  /**
806
- * Reads the file once and returns either the sum, min or max of the
836
+ * Reads the file once and returns either the sum, average, min or max of the
807
837
  * (optionally-selected) numeric lines.
808
838
  *
809
839
  * @param filePath Absolute path of the column file (may be .gz-compressed).
810
- * @param wanted Metric to compute: "sum" (default), "min" or "max".
840
+ * @param wanted Metric to compute: "sum" (default), "avg", "min" or "max".
811
841
  * @param lineNumbers Specific line-number(s) to restrict the scan to.
812
842
  *
813
843
  * @returns Promise<number> The requested metric, or 0 if no numeric value found.
@@ -836,7 +866,7 @@ async function reduceNumbers(filePath, wanted = "sum", lineNumbers) {
836
866
  if (Number.isNaN(num))
837
867
  continue;
838
868
  processed++;
839
- if (wanted === "sum") {
869
+ if (wanted === "sum" || wanted === "avg") {
840
870
  sum += num;
841
871
  }
842
872
  else if (wanted === "min") {
@@ -857,10 +887,17 @@ async function reduceNumbers(filePath, wanted = "sum", lineNumbers) {
857
887
  }
858
888
  if (processed === 0)
859
889
  return 0; // nothing numeric found
860
- return wanted === "sum" ? sum : wanted === "min" ? min : max;
890
+ return wanted === "sum"
891
+ ? sum
892
+ : wanted === "avg"
893
+ ? sum / processed
894
+ : wanted === "min"
895
+ ? min
896
+ : max;
861
897
  }
862
898
  /* Optional convenience wrappers (signatures unchanged) */
863
899
  export const sum = (fp, ln) => reduceNumbers(fp, "sum", ln);
900
+ export const avg = (fp, ln) => reduceNumbers(fp, "avg", ln);
864
901
  export const min = (fp, ln) => reduceNumbers(fp, "min", ln);
865
902
  export const max = (fp, ln) => reduceNumbers(fp, "max", ln);
866
903
  export const getFileDate = (path) => stat(path)
package/dist/index.d.ts CHANGED
@@ -73,6 +73,11 @@ export default class Inibase {
73
73
  language: ErrorLang;
74
74
  fileExtension: string;
75
75
  totalItems: Map<string, number>;
76
+ /** Tracks whether a decodeID table's stored ids are the dense sequence
77
+ * 1..rowCount (no rows ever deleted). When true, `get`/`put`/`delete` can
78
+ * resolve numeric ids to line numbers arithmetically instead of scanning
79
+ * the id file. Set false by any partial row deletion. */
80
+ private idDensity;
76
81
  private databasePath;
77
82
  private uniqueMap;
78
83
  private schemaFileExtension;
@@ -134,6 +139,14 @@ export default class Inibase {
134
139
  private processSchemaData;
135
140
  private isSimpleField;
136
141
  private processSimpleField;
142
+ /**
143
+ * Batched read for top-level simple columns: a single `paste | sed` child
144
+ * process returns every requested line of every column at once instead of
145
+ * spawning one `sed`/`gunzip|sed` child process per column. Returning null
146
+ * (e.g. when any column file is missing, or the shell command fails) makes
147
+ * the caller fall back to the existing per-column reads.
148
+ */
149
+ private processSimpleFieldsBatch;
137
150
  private isArrayField;
138
151
  private processArrayField;
139
152
  private isObjectField;
@@ -198,7 +211,16 @@ export default class Inibase {
198
211
  * @param {(number | string | (number | string)[] | Criteria)} [where]
199
212
  * @return {boolean | null} {(Promise<boolean | null>)}
200
213
  */
201
- delete(tableName: string, where?: number | string | (number | string)[] | Criteria, _whereIsLinesNumbers?: boolean): Promise<boolean | null>;
214
+ delete(tableName: string, where?: number | string | (number | string)[] | Criteria, _whereIsLinesNumbers?: boolean, _cascadeGuard?: Set<string>): Promise<boolean | null>;
215
+ /**
216
+ * Cascade delete: remove rows in other tables whose `table`-typed schema
217
+ * fields reference the given rows. Reference columns store the numeric
218
+ * (line-number) id of the referenced row, so deleted ids are matched
219
+ * against them directly. Recursion into child tables happens through
220
+ * `delete` itself (which calls this method again); the `guard` set keeps
221
+ * deep/cyclic reference chains from re-processing the same (table, line).
222
+ */
223
+ private cascadeDelete;
202
224
  /**
203
225
  * Generate sum of column(s) in a table
204
226
  *
@@ -209,6 +231,16 @@ export default class Inibase {
209
231
  */
210
232
  sum(tableName: string, columns: string, where?: number | string | (number | string)[] | Criteria): Promise<number>;
211
233
  sum(tableName: string, columns: string[], where?: number | string | (number | string)[] | Criteria): Promise<Record<string, number>>;
234
+ /**
235
+ * Generate average of column(s) in a table
236
+ *
237
+ * @param {string} tableName
238
+ * @param {string} columns
239
+ * @param {(number | string | (number | string)[] | Criteria)} [where]
240
+ * @return {*} {Promise<number | Record<string, number>>}
241
+ */
242
+ avg(tableName: string, columns: string, where?: number | string | (number | string)[] | Criteria): Promise<number>;
243
+ avg(tableName: string, columns: string[], where?: number | string | (number | string)[] | Criteria): Promise<Record<string, number>>;
212
244
  /**
213
245
  * Generate max of column(s) in a table
214
246
  *
package/dist/index.js CHANGED
@@ -40,6 +40,11 @@ export default class Inibase {
40
40
  language;
41
41
  fileExtension = ".txt";
42
42
  totalItems;
43
+ /** Tracks whether a decodeID table's stored ids are the dense sequence
44
+ * 1..rowCount (no rows ever deleted). When true, `get`/`put`/`delete` can
45
+ * resolve numeric ids to line numbers arithmetically instead of scanning
46
+ * the id file. Set false by any partial row deletion. */
47
+ idDensity = new Map();
43
48
  databasePath;
44
49
  uniqueMap;
45
50
  schemaFileExtension = process.env.INIBASE_SCHEMA_EXTENSION ?? "json";
@@ -145,6 +150,8 @@ export default class Inibase {
145
150
  await writeFile(join(tablePath, ".cache.config"), "");
146
151
  if (config.prepend)
147
152
  await writeFile(join(tablePath, ".prepend.config"), "");
153
+ if (config.decodeID)
154
+ await writeFile(join(tablePath, ".decodeID.config"), "");
148
155
  }
149
156
  if (schema) {
150
157
  const lastSchemaID = { value: 0 };
@@ -156,6 +163,7 @@ export default class Inibase {
156
163
  else
157
164
  await writeFile(join(tablePath, "0.schema"), "");
158
165
  await writeFile(join(tablePath, "0-0.pagination"), "");
166
+ this.idDensity.set(tableName, true);
159
167
  }
160
168
  // Function to replace the string in one schema file
161
169
  async replaceStringInFile(filePath, targetString, replaceString) {
@@ -213,6 +221,23 @@ export default class Inibase {
213
221
  await rename(schemaIdFilePath, join(tablePath, `${lastSchemaID.value}.schema`));
214
222
  else
215
223
  await writeFile(join(tablePath, `${lastSchemaID.value}.schema`), "");
224
+ // Fields added by this migration have no backing file yet. If the
225
+ // first post after the migration writes such a file from scratch it
226
+ // starts at line 1 and every existing row becomes misaligned (the
227
+ // new value lands on the wrong record). Materialize missing column
228
+ // files padded with one empty line per existing row so appends keep
229
+ // line-aligned with the other column files (decode("") is
230
+ // undefined/null, matching the "no value yet" semantics).
231
+ let totalLines = 0;
232
+ for await (const paginationFileName of glob("*.pagination", {
233
+ cwd: tablePath,
234
+ }))
235
+ totalLines = parse(paginationFileName).name.split("-").map(Number)[1];
236
+ await Promise.allSettled(schema.map(async ({ key }) => {
237
+ const filePath = join(tablePath, `${key}${this.getFileExtension(tableName)}`);
238
+ if (!(await File.isExists(filePath)))
239
+ await File.write(filePath, "\n".repeat(totalLines));
240
+ }));
216
241
  }
217
242
  if (config) {
218
243
  if (config.compression !== undefined &&
@@ -442,9 +467,11 @@ export default class Inibase {
442
467
  }
443
468
  }
444
469
  async validateTableData(tableName, data, skipRequiredField = false) {
445
- const clonedData = structuredClone(data);
470
+ // `data` is always a private clone owned by the caller (post/put already
471
+ // cloned it once), so validate in place instead of re-cloning — otherwise
472
+ // every bulk write holds several full copies of the payload in memory.
446
473
  // Skip ID and (created|updated)At
447
- this.validateData(clonedData, globalConfig[this.databasePath].tables
474
+ this.validateData(data, globalConfig[this.databasePath].tables
448
475
  ?.get(tableName)
449
476
  ?.schema?.slice(1, -2) ?? [], skipRequiredField);
450
477
  await this.checkUnique(tableName);
@@ -509,6 +536,8 @@ export default class Inibase {
509
536
  ? value
510
537
  : Number(value.trim())
511
538
  : 0;
539
+ case "date":
540
+ return Utils.dateToTimestamp(value);
512
541
  case "id":
513
542
  return Utils.isNumber(value)
514
543
  ? value
@@ -579,7 +608,11 @@ export default class Inibase {
579
608
  this.uniqueMap = new Map();
580
609
  }
581
610
  formatData(data, schema, formatOnlyAvailiableKeys) {
582
- const clonedData = structuredClone(data);
611
+ // formatData only reads its input (all transformations produce new
612
+ // values), so no defensive clone is needed. Callers pass data they no
613
+ // longer need, and skipping the copy halves the payload footprint of
614
+ // every bulk post/put.
615
+ const clonedData = data;
583
616
  if (Utils.isArrayOfObjects(clonedData))
584
617
  return clonedData.map((singleData) => this.formatData(singleData, schema, formatOnlyAvailiableKeys));
585
618
  if (Utils.isObject(clonedData)) {
@@ -717,7 +750,26 @@ export default class Inibase {
717
750
  }
718
751
  async processSchemaData(tableName, schema, linesNumber, options, prefix) {
719
752
  const RETURN = {};
753
+ // Fast path: read every top-level simple column in one `paste | sed`
754
+ // child process instead of spawning one child process per column.
755
+ // Nested (prefixed) schemas keep the per-column path.
756
+ let batchedKeys = null;
757
+ if (!prefix && linesNumber?.length) {
758
+ const simpleFields = schema.filter((field) => this.isSimpleField(field.type));
759
+ const batched = await this.processSimpleFieldsBatch(tableName, simpleFields, linesNumber);
760
+ if (batched) {
761
+ batchedKeys = new Set(simpleFields.map((field) => field.key));
762
+ for (const [line, row] of Object.entries(batched)) {
763
+ if (!RETURN[line])
764
+ RETURN[line] = {};
765
+ Object.assign(RETURN[line], row);
766
+ }
767
+ }
768
+ }
720
769
  for (const field of schema) {
770
+ // Batch-read fields were already merged into RETURN.
771
+ if (batchedKeys?.has(field.key))
772
+ continue;
721
773
  // If the field is of simple type (non-recursive), process it directly
722
774
  if (this.isSimpleField(field.type)) {
723
775
  await this.processSimpleField(tableName, field, RETURN, linesNumber, prefix);
@@ -768,6 +820,78 @@ export default class Inibase {
768
820
  }
769
821
  }
770
822
  }
823
+ /**
824
+ * Batched read for top-level simple columns: a single `paste | sed` child
825
+ * process returns every requested line of every column at once instead of
826
+ * spawning one `sed`/`gunzip|sed` child process per column. Returning null
827
+ * (e.g. when any column file is missing, or the shell command fails) makes
828
+ * the caller fall back to the existing per-column reads.
829
+ */
830
+ async processSimpleFieldsBatch(tableName, fields, linesNumber) {
831
+ if (!fields.length || !linesNumber.length)
832
+ return null;
833
+ const decodeID = globalConfig[this.databasePath].tables?.get(tableName)?.config
834
+ .decodeID === true;
835
+ // Decode configs are computed once per column (the per-column read path
836
+ // builds one config per file too), not once per cell.
837
+ const cols = [];
838
+ for (const field of fields) {
839
+ const path = join(this.databasePath, tableName, `${field.key}${this.getFileExtension(tableName)}`);
840
+ if (!(await File.isExists(path)))
841
+ return null;
842
+ cols.push({
843
+ path,
844
+ key: field.key,
845
+ config: {
846
+ ...field,
847
+ type: field.key === "id" && decodeID ? "number" : field.type,
848
+ databasePath: this.databasePath,
849
+ },
850
+ });
851
+ }
852
+ const sortedLines = [...linesNumber].sort((a, b) => a - b);
853
+ const range = File._groupIntoRanges(sortedLines);
854
+ const files = cols.map(({ path }) => File.escapeShellPath(path)).join(" ");
855
+ const isGz = this.getFileExtension(tableName).endsWith(".gz");
856
+ // Each compressed column needs its own process substitution so the
857
+ // streams stay aligned column-by-column inside `paste`.
858
+ const pasteInputs = isGz
859
+ ? cols
860
+ .map(({ path }) => `<(gunzip -c ${File.escapeShellPath(path)})`)
861
+ .join(" ")
862
+ : files;
863
+ const command = isGz
864
+ ? `bash -c 'paste -d "\\t" ${pasteInputs} | sed -n "${range}"'`
865
+ : `paste -d'\\t' ${files} | sed -n '${range}'`;
866
+ let output;
867
+ try {
868
+ output = (await UtilsServer.exec(command)).stdout;
869
+ }
870
+ catch {
871
+ return null;
872
+ }
873
+ const outLines = output.trimEnd().split("\n");
874
+ const RETURN = {};
875
+ for (let i = 0; i < outLines.length && i < sortedLines.length; i++) {
876
+ const lineNo = sortedLines[i];
877
+ const cells = outLines[i].split("\t");
878
+ const row = {};
879
+ let added = false;
880
+ for (let c = 0; c < cols.length; c++) {
881
+ const raw = cells[c];
882
+ if (raw === undefined)
883
+ continue;
884
+ const value = File.decode(raw, cols[c].config);
885
+ if (value !== undefined) {
886
+ row[cols[c].key] = value;
887
+ added = true;
888
+ }
889
+ }
890
+ if (added)
891
+ RETURN[lineNo] = row;
892
+ }
893
+ return Object.keys(RETURN).length ? RETURN : null;
894
+ }
771
895
  // Helper function to check if the field type is array
772
896
  isArrayField(fieldType) {
773
897
  return ((Array.isArray(fieldType) &&
@@ -809,7 +933,8 @@ export default class Inibase {
809
933
  RETURN[index] = {};
810
934
  if (Utils.isObject(item)) {
811
935
  const itemEntries = Object.entries(item);
812
- const itemValues = itemEntries.map(([_key, value]) => value);
936
+ // Values without a second per-row tuple array.
937
+ const itemValues = Object.values(item);
813
938
  if (!Utils.isArrayOfNulls(itemValues)) {
814
939
  if (RETURN[index][field.key])
815
940
  for (let _index = 0; _index < itemEntries.length; _index++) {
@@ -1054,11 +1179,19 @@ export default class Inibase {
1054
1179
  return null;
1055
1180
  continue;
1056
1181
  }
1057
- const formatedSearchResult = Object.fromEntries(Object.entries(searchResult).map(([id, value]) => {
1182
+ // Merge matched lines into RETURN. The old code round-tripped through
1183
+ // Object.entries(...).map(...) + Object.fromEntries, allocating two
1184
+ // tuple arrays + a map array per matched row; building the nested
1185
+ // result object directly keeps the exact same semantics with less
1186
+ // garbage. Note: in `allTrue` mode RETURN is *replaced* per key
1187
+ // (searchIn narrowing is what enforces the AND), so the assignment
1188
+ // below intentionally mirrors the original replace/merge behavior.
1189
+ const formatedSearchResult = {};
1190
+ for (const id of Object.keys(searchResult)) {
1058
1191
  const nestedObj = {};
1059
- this._setNestedKey(nestedObj, key, value);
1060
- return [id, nestedObj];
1061
- }));
1192
+ this._setNestedKey(nestedObj, key, searchResult[id]);
1193
+ formatedSearchResult[id] = nestedObj;
1194
+ }
1062
1195
  RETURN = allTrue
1063
1196
  ? formatedSearchResult
1064
1197
  : Utils.deepMerge(RETURN, formatedSearchResult);
@@ -1080,10 +1213,17 @@ export default class Inibase {
1080
1213
  const searchResult = await this.applyCriteria(tableName, options, criteriaOR, false, searchIn);
1081
1214
  if (searchResult) {
1082
1215
  RETURN = Utils.deepMerge(RETURN, searchResult);
1083
- if (!Object.keys(RETURN).length)
1084
- RETURN = {};
1085
- RETURN = Object.fromEntries(Object.entries(RETURN).filter(([_index, item]) => Object.keys(item).filter((key) => Object.keys(criteriaOR).includes(key) ||
1086
- Object.keys(criteriaOR).some((criteriaKey) => criteriaKey.startsWith(`${key}.`))).length));
1216
+ // Filter RETURN in place instead of rebuilding it through
1217
+ // Object.entries/fromEntries on every OR iteration. The key list of
1218
+ // criteriaOR is hoisted out of the per-row check as well.
1219
+ const orKeys = Object.keys(criteriaOR);
1220
+ for (const id of Object.keys(RETURN)) {
1221
+ const item = RETURN[id];
1222
+ const matches = Object.keys(item).some((key) => orKeys.includes(key) ||
1223
+ orKeys.some((criteriaKey) => criteriaKey.startsWith(`${key}.`)));
1224
+ if (!matches)
1225
+ delete RETURN[id];
1226
+ }
1087
1227
  if (!Object.keys(RETURN).length)
1088
1228
  RETURN = {};
1089
1229
  }
@@ -1186,12 +1326,19 @@ export default class Inibase {
1186
1326
  const itemsIDs = Object.values((await File.get(join(tablePath, `id${this.getFileExtension(tableName)}`), lineNumbers, { key: "BLABLA", type: "number" })) ?? {}).map(Number);
1187
1327
  awkCommand = `awk '${itemsIDs.map((id) => `$1 == ${id}`).join(" || ")}'`;
1188
1328
  }
1329
+ // perPage < 0 means "no limit": select every line instead of
1330
+ // generating an empty awk window (with perPage -1 the old code
1331
+ // produced `awk ''`, which prints nothing and the empty stdout
1332
+ // decoded into a single hollow row).
1189
1333
  else
1190
- awkCommand = `awk '${Array.from({ length: options.perPage }, (_, index) => (options.page - 1) * options.perPage +
1191
- index +
1192
- 1)
1193
- .map((lineNumber) => `NR==${lineNumber}`)
1194
- .join(" || ")}'`;
1334
+ awkCommand =
1335
+ options.perPage < 0
1336
+ ? "awk '1'"
1337
+ : `awk '${Array.from({ length: options.perPage }, (_, index) => (options.page - 1) * options.perPage +
1338
+ index +
1339
+ 1)
1340
+ .map((lineNumber) => `NR==${lineNumber}`)
1341
+ .join(" || ")}'`;
1195
1342
  const filesPathes = (sortArray.find(([key]) => key === "id")
1196
1343
  ? sortArray
1197
1344
  : [["id", true], ...sortArray]).map((column) => join(tablePath, `${column[0]}${this.getFileExtension(tableName)}`));
@@ -1200,14 +1347,14 @@ export default class Inibase {
1200
1347
  return null;
1201
1348
  // Construct the paste command to merge files and filter lines by IDs
1202
1349
  const pasteCommand = `paste '${filesPathes.join("' '")}'`;
1203
- // Construct the sort command dynamically based on the number of files for sorting
1204
- const index = 1;
1350
+ const _idPrepended = !sortArray.find(([key]) => key === "id");
1205
1351
  const sortColumns = sortArray
1206
1352
  .map(([key, ascending], i) => {
1207
1353
  const field = Utils.getField(key, schema);
1208
- if (field)
1209
- return `-k${i + index},${i + index}${Utils.isFieldType(field, ["id", "number", "date"]) ? "n" : ""}${!ascending ? "r" : ""}`;
1210
- return "";
1354
+ if (!field)
1355
+ return "";
1356
+ const colIndex = _idPrepended ? i + 2 : i + 1;
1357
+ return `-k${colIndex},${colIndex}${Utils.isFieldType(field, ["id", "number", "date"]) ? "n" : ""}${!ascending ? "r" : ""}`;
1211
1358
  })
1212
1359
  .join(" ");
1213
1360
  const sortCommand = `sort ${sortColumns} -T='${join(tablePath, ".tmp")}'`;
@@ -1299,7 +1446,47 @@ export default class Inibase {
1299
1446
  let Ids = where;
1300
1447
  if (!Array.isArray(Ids))
1301
1448
  Ids = [Ids];
1302
- const [lineNumbers, countItems] = await File.search(join(tablePath, `id${this.getFileExtension(tableName)}`), "[]", Ids.map((id) => Utils.isNumber(id) ? Number(id) : UtilsServer.decodeID(id)), undefined, undefined, { key: "BLABLA", type: "number" }, Ids.length, 0, !this.totalItems.has(`${tableName}-id`));
1449
+ // Fast path for decodeID tables whose ids are the dense sequence
1450
+ // 1..N: when the requested numeric ids form a duplicate-free
1451
+ // consecutive range [min..max] with max within the row count, the
1452
+ // line numbers ARE the ids — no id-file scan required.
1453
+ let lineNumbers = null;
1454
+ let countItems = 0;
1455
+ const isDecodeID = globalConfig[this.databasePath].tables?.get(tableName)?.config
1456
+ .decodeID === true &&
1457
+ !globalConfig[this.databasePath].tables?.get(tableName)?.config.prepend;
1458
+ if (isDecodeID &&
1459
+ this.idDensity.get(tableName) &&
1460
+ Ids.every(Utils.isNumber)) {
1461
+ const seen = new Set();
1462
+ let min = Number.POSITIVE_INFINITY;
1463
+ let max = Number.NEGATIVE_INFINITY;
1464
+ let distinct = true;
1465
+ for (const raw of Ids) {
1466
+ const n = Number(raw);
1467
+ if (seen.has(n)) {
1468
+ distinct = false;
1469
+ break;
1470
+ }
1471
+ seen.add(n);
1472
+ if (n < min)
1473
+ min = n;
1474
+ if (n > max)
1475
+ max = n;
1476
+ }
1477
+ if (distinct &&
1478
+ min >= 1 &&
1479
+ max <= pagination[1] &&
1480
+ max - min + 1 === Ids.length) {
1481
+ lineNumbers = {};
1482
+ for (let line = min; line <= max; line++)
1483
+ lineNumbers[line] = line;
1484
+ countItems = Ids.length;
1485
+ }
1486
+ }
1487
+ if (!lineNumbers) {
1488
+ [lineNumbers, countItems] = await File.search(join(tablePath, `id${this.getFileExtension(tableName)}`), "[]", Ids.map((id) => Utils.isNumber(id) ? Number(id) : UtilsServer.decodeID(id)), undefined, undefined, { key: "BLABLA", type: "number" }, Ids.length, 0, !this.totalItems.has(`${tableName}-id`));
1489
+ }
1303
1490
  if (!lineNumbers)
1304
1491
  return null;
1305
1492
  this.totalItems.set(`${tableName}-id`, countItems);
@@ -1425,7 +1612,7 @@ export default class Inibase {
1425
1612
  ? await File.prepend(path, content)
1426
1613
  : await File.append(path, content))));
1427
1614
  await Promise.allSettled(renameList
1428
- .filter(([_, filePath]) => filePath)
1615
+ .filter((pair) => Boolean(pair[1]))
1429
1616
  .map(async ([tempPath, filePath]) => rename(tempPath, filePath)));
1430
1617
  if (globalConfig[this.databasePath].tables?.get(tableName)?.config.cache)
1431
1618
  await this.clearCache(tableName);
@@ -1453,7 +1640,7 @@ export default class Inibase {
1453
1640
  finally {
1454
1641
  if (renameList.length)
1455
1642
  await Promise.allSettled(renameList
1456
- .filter(([_, filePath]) => filePath)
1643
+ .filter((pair) => Boolean(pair[1]))
1457
1644
  .map(async ([tempPath, _]) => unlink(tempPath)));
1458
1645
  await File.unlock(join(tablePath, ".tmp"), keys);
1459
1646
  }
@@ -1498,7 +1685,7 @@ export default class Inibase {
1498
1685
  this.totalItems.set(`${tableName}-*`, parse(paginationFileName).name.split("-").map(Number)[1]);
1499
1686
  await Promise.allSettled(Object.entries(pathesContents).map(async ([path, content]) => renameList.push(await File.replace(path, content, this.totalItems.get(`${tableName}-*`)))));
1500
1687
  await Promise.allSettled(renameList
1501
- .filter(([_, filePath]) => filePath)
1688
+ .filter((pair) => Boolean(pair[1]))
1502
1689
  .map(async ([tempPath, filePath]) => rename(tempPath, filePath)));
1503
1690
  if (globalConfig[this.databasePath].tables?.get(tableName)?.config.cache)
1504
1691
  await this.clearCache(join(tablePath, ".cache"));
@@ -1508,7 +1695,7 @@ export default class Inibase {
1508
1695
  finally {
1509
1696
  if (renameList.length)
1510
1697
  await Promise.allSettled(renameList
1511
- .filter(([_, filePath]) => filePath)
1698
+ .filter((pair) => Boolean(pair[1]))
1512
1699
  .map(async ([tempPath, _]) => unlink(tempPath)));
1513
1700
  await File.unlock(join(tablePath, ".tmp"));
1514
1701
  }
@@ -1540,7 +1727,7 @@ export default class Inibase {
1540
1727
  await File.lock(join(tablePath, ".tmp"), keys);
1541
1728
  await Promise.allSettled(Object.entries(pathesContents).map(async ([path, content]) => renameList.push(await File.replace(path, content))));
1542
1729
  await Promise.allSettled(renameList
1543
- .filter(([_, filePath]) => filePath)
1730
+ .filter((pair) => Boolean(pair[1]))
1544
1731
  .map(async ([tempPath, filePath]) => rename(tempPath, filePath)));
1545
1732
  if (globalConfig[this.databasePath].tables?.get(tableName)?.config.cache)
1546
1733
  await this.clearCache(tableName);
@@ -1550,7 +1737,7 @@ export default class Inibase {
1550
1737
  finally {
1551
1738
  if (renameList.length)
1552
1739
  await Promise.allSettled(renameList
1553
- .filter(([_, filePath]) => filePath)
1740
+ .filter((pair) => Boolean(pair[1]))
1554
1741
  .map(async ([tempPath, _]) => unlink(tempPath)));
1555
1742
  await File.unlock(join(tablePath, ".tmp"), keys);
1556
1743
  }
@@ -1564,7 +1751,14 @@ export default class Inibase {
1564
1751
  Utils.isValidID(where)) {
1565
1752
  const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
1566
1753
  if (lineNumbers)
1567
- return this.put(tableName, clonedData, lineNumbers, options, returnUpdatedData, true);
1754
+ return this.put(tableName, clonedData,
1755
+ // get() with onlyLinesNumbers always returns an array; a
1756
+ // single-id update must keep the scalar so the recursive
1757
+ // line-numbers branch returns a single row (matching the
1758
+ // shape of get(singleId)) instead of a one-element array.
1759
+ !Array.isArray(where) && Array.isArray(lineNumbers)
1760
+ ? lineNumbers[0]
1761
+ : lineNumbers, options, returnUpdatedData, true);
1568
1762
  }
1569
1763
  else if (Utils.isObject(where)) {
1570
1764
  const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
@@ -1581,7 +1775,7 @@ export default class Inibase {
1581
1775
  * @param {(number | string | (number | string)[] | Criteria)} [where]
1582
1776
  * @return {boolean | null} {(Promise<boolean | null>)}
1583
1777
  */
1584
- async delete(tableName, where, _whereIsLinesNumbers) {
1778
+ async delete(tableName, where, _whereIsLinesNumbers, _cascadeGuard) {
1585
1779
  this.validateName(tableName);
1586
1780
  const tablePath = join(this.databasePath, tableName);
1587
1781
  await this.throwErrorIfTableEmpty(tableName);
@@ -1604,6 +1798,12 @@ export default class Inibase {
1604
1798
  if (globalConfig[this.databasePath].tables?.get(tableName)?.config.cache)
1605
1799
  await this.clearCache(tableName);
1606
1800
  await rename(paginationFilePath, join(tablePath, `${pagination[0]}-0.pagination`));
1801
+ this.idDensity.set(tableName, true);
1802
+ // Deleting every row must also delete rows that reference them.
1803
+ if (pagination[1]) {
1804
+ const allLines = Array.from({ length: pagination[1] }, (_, i) => i + 1);
1805
+ await this.cascadeDelete(tableName, allLines, new Set());
1806
+ }
1607
1807
  return true;
1608
1808
  }
1609
1809
  finally {
@@ -1633,24 +1833,30 @@ export default class Inibase {
1633
1833
  }
1634
1834
  if (pagination[1] &&
1635
1835
  pagination[1] - (Array.isArray(where) ? where.length : 1) > 0) {
1836
+ this.idDensity.set(tableName, false);
1636
1837
  await Promise.allSettled(files.map(async (file) => renameList.push(await File.remove(join(tablePath, file), where))));
1637
1838
  await Promise.allSettled(renameList
1638
- .filter(([_, filePath]) => filePath)
1839
+ .filter((pair) => Boolean(pair[1]))
1639
1840
  .map(async ([tempPath, filePath]) => rename(tempPath, filePath)));
1640
1841
  }
1641
- else
1842
+ else {
1843
+ this.idDensity.set(tableName, true);
1642
1844
  await Promise.allSettled((await readdir(tablePath))
1643
1845
  ?.filter((fileName) => fileName.endsWith(this.getFileExtension(tableName)))
1644
1846
  .map(async (file) => unlink(join(tablePath, file))));
1847
+ }
1645
1848
  if (globalConfig[this.databasePath].tables?.get(tableName)?.config.cache)
1646
1849
  await this.clearCache(tableName);
1647
1850
  await rename(paginationFilePath, join(tablePath, `${pagination[0]}-${pagination[1] - (Array.isArray(where) ? where.length : 1)}.pagination`));
1851
+ // Cascade: rows in other tables referencing the deleted rows
1852
+ // (via `table`-typed fields) are removed too.
1853
+ await this.cascadeDelete(tableName, Array.isArray(where) ? where : [where], _cascadeGuard ?? new Set());
1648
1854
  return true;
1649
1855
  }
1650
1856
  finally {
1651
1857
  if (renameList.length)
1652
1858
  await Promise.allSettled(renameList
1653
- .filter(([_, filePath]) => filePath)
1859
+ .filter((pair) => Boolean(pair[1]))
1654
1860
  .map(async ([tempPath, _]) => unlink(tempPath)));
1655
1861
  await File.unlock(join(tablePath, ".tmp"));
1656
1862
  }
@@ -1664,17 +1870,86 @@ export default class Inibase {
1664
1870
  (Array.isArray(where) && where.every(Utils.isValidID)) ||
1665
1871
  Utils.isValidID(where)) {
1666
1872
  const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
1667
- return this.delete(tableName, lineNumbers, true);
1873
+ // Deleting a non-existent id must not fall through to the
1874
+ // "delete all rows" branch (this.delete(_, null, _) would truncate
1875
+ // the whole table), so resolve the id to line numbers first and
1876
+ // only delegate when something actually matched.
1877
+ if (lineNumbers)
1878
+ return this.delete(tableName, lineNumbers, true, _cascadeGuard ?? new Set());
1879
+ return false;
1668
1880
  }
1669
1881
  if (Utils.isObject(where)) {
1670
1882
  const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
1671
1883
  if (lineNumbers)
1672
- return this.delete(tableName, lineNumbers, true);
1884
+ return this.delete(tableName, lineNumbers, true, _cascadeGuard ?? new Set());
1673
1885
  }
1674
1886
  else
1675
1887
  throw this.createError("INVALID_PARAMETERS");
1676
1888
  return false;
1677
1889
  }
1890
+ /**
1891
+ * Cascade delete: remove rows in other tables whose `table`-typed schema
1892
+ * fields reference the given rows. Reference columns store the numeric
1893
+ * (line-number) id of the referenced row, so deleted ids are matched
1894
+ * against them directly. Recursion into child tables happens through
1895
+ * `delete` itself (which calls this method again); the `guard` set keeps
1896
+ * deep/cyclic reference chains from re-processing the same (table, line).
1897
+ */
1898
+ async cascadeDelete(tableName, deletedLines, guard) {
1899
+ if (!deletedLines.length)
1900
+ return;
1901
+ for (const line of deletedLines)
1902
+ guard.add(`${tableName}:${line}`);
1903
+ const tables = globalConfig[this.databasePath]?.tables;
1904
+ if (!tables)
1905
+ return;
1906
+ for (const [candidateName, tableData] of tables) {
1907
+ if (candidateName === tableName || !tableData?.schema)
1908
+ continue;
1909
+ // Only direct `table`-typed columns hold one stored id per row;
1910
+ // arrays/objects of table refs serialize differently and are
1911
+ // intentionally out of scope for the cascade.
1912
+ const refFields = Utils.flattenSchema(tableData.schema, true).filter((field) => field.table === tableName && field.type === "table");
1913
+ if (!refFields.length)
1914
+ continue;
1915
+ for (const field of refFields) {
1916
+ const refPath = join(this.databasePath, candidateName, `${field.key}${this.getFileExtension(candidateName)}`);
1917
+ if (!(await File.isExists(refPath)))
1918
+ continue;
1919
+ const matching = new Set();
1920
+ for (const line of deletedLines) {
1921
+ try {
1922
+ const [, , found] = await File.search(refPath, "=", line, undefined, undefined, {
1923
+ key: field.key,
1924
+ type: "number",
1925
+ databasePath: this.databasePath,
1926
+ }, undefined, undefined, false);
1927
+ if (found)
1928
+ for (const l of found)
1929
+ matching.add(l);
1930
+ }
1931
+ catch {
1932
+ // Unreadable/unsupported column -> skip this reference.
1933
+ }
1934
+ }
1935
+ const toDelete = [...matching].filter((line) => {
1936
+ const key = `${candidateName}:${line}`;
1937
+ if (guard.has(key))
1938
+ return false;
1939
+ guard.add(key);
1940
+ return true;
1941
+ });
1942
+ if (!toDelete.length)
1943
+ continue;
1944
+ try {
1945
+ await this.delete(candidateName, toDelete, true, guard);
1946
+ }
1947
+ catch {
1948
+ // Cascade is best-effort: never break the parent delete.
1949
+ }
1950
+ }
1951
+ }
1952
+ }
1678
1953
  async sum(tableName, columns, where) {
1679
1954
  this.validateName(tableName);
1680
1955
  if (!Array.isArray(columns))
@@ -1699,6 +1974,30 @@ export default class Inibase {
1699
1974
  }
1700
1975
  return columns.length > 1 ? RETURN : Object.values(RETURN)[0];
1701
1976
  }
1977
+ async avg(tableName, columns, where) {
1978
+ this.validateName(tableName);
1979
+ if (!Array.isArray(columns))
1980
+ columns = [columns];
1981
+ for (const column of columns)
1982
+ this.validateName(column);
1983
+ await this.throwErrorIfTableEmpty(tableName);
1984
+ const RETURN = {};
1985
+ const tablePath = join(this.databasePath, tableName);
1986
+ for await (const column of columns) {
1987
+ const columnPath = join(tablePath, `${column}${this.getFileExtension(tableName)}`);
1988
+ if (await File.isExists(columnPath)) {
1989
+ if (where) {
1990
+ const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
1991
+ RETURN[column] = lineNumbers
1992
+ ? await File.avg(columnPath, lineNumbers)
1993
+ : 0;
1994
+ }
1995
+ else
1996
+ RETURN[column] = await File.avg(columnPath);
1997
+ }
1998
+ }
1999
+ return columns.length > 1 ? RETURN : Object.values(RETURN)[0];
2000
+ }
1702
2001
  async max(tableName, columns, where) {
1703
2002
  this.validateName(tableName);
1704
2003
  if (!Array.isArray(columns))
package/dist/utils.d.ts CHANGED
@@ -115,7 +115,8 @@ export declare const isPassword: (input: unknown) => input is string;
115
115
  * @param input - The input to be checked, can be of any type.
116
116
  * @returns A boolean indicating whether the input is a valid date.
117
117
  */
118
- export declare const isDate: (input: unknown) => input is Date | number;
118
+ export declare const dateToTimestamp: (input: unknown) => number | null;
119
+ export declare const isDate: (input: unknown) => boolean;
119
120
  /**
120
121
  * Checks if the input is a valid ID.
121
122
  *
package/dist/utils.js CHANGED
@@ -163,20 +163,36 @@ export const isPassword = (input) => typeof input === "string" && input.length =
163
163
  * @param input - The input to be checked, can be of any type.
164
164
  * @returns A boolean indicating whether the input is a valid date.
165
165
  */
166
- export const isDate = (input) => {
166
+ export const dateToTimestamp = (input) => {
167
167
  // Check if the input is null, undefined, or an empty string
168
168
  if (input == null || input === "")
169
- return false;
169
+ return null;
170
+ if (typeof input === "string") {
171
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(input);
172
+ if (match) {
173
+ const year = Number(match[1]);
174
+ const month = Number(match[2]);
175
+ const day = Number(match[3]);
176
+ const timestamp = Date.UTC(year, month - 1, day);
177
+ const date = new Date(timestamp);
178
+ return date.getUTCFullYear() === year &&
179
+ date.getUTCMonth() === month - 1 &&
180
+ date.getUTCDate() === day
181
+ ? timestamp
182
+ : null;
183
+ }
184
+ }
170
185
  // Convert to number and check if it's a valid number
171
186
  const numTimestamp = Number(input);
172
187
  // Check if the converted number is NaN or not finite
173
188
  if (Number.isNaN(numTimestamp) || !Number.isFinite(numTimestamp))
174
- return false;
189
+ return null;
175
190
  // Create a Date object from the timestamp
176
191
  const date = new Date(numTimestamp);
177
192
  // Check if the date is valid
178
- return date.getTime() === numTimestamp;
193
+ return date.getTime() === numTimestamp ? numTimestamp : null;
179
194
  };
195
+ export const isDate = (input) => dateToTimestamp(input) !== null;
180
196
  /**
181
197
  * Checks if the input is a valid ID.
182
198
  *
@@ -1,9 +1,9 @@
1
1
  import "dotenv/config";
2
- import { execFile as execFileSync, exec as execSync } from "node:child_process";
2
+ import { execFile as execFileSync, exec as rawExec } from "node:child_process";
3
3
  import { gunzip as gunzipSync, gzip as gzipSync } from "node:zlib";
4
4
  import RE2 from "re2";
5
5
  import type { ComparisonOperator, FieldType } from "./index.js";
6
- export declare const exec: typeof execSync.__promisify__;
6
+ export declare const exec: typeof rawExec.__promisify__;
7
7
  export declare const execFile: typeof execFileSync.__promisify__;
8
8
  export declare const gzip: typeof gzipSync.__promisify__;
9
9
  export declare const gunzip: typeof gunzipSync.__promisify__;
@@ -1,5 +1,5 @@
1
1
  import "dotenv/config";
2
- import { execFile as execFileSync, exec as execSync } from "node:child_process";
2
+ import { execFile as execFileSync, exec as rawExec } from "node:child_process";
3
3
  import { createCipheriv, createDecipheriv, createHash, randomBytes, scryptSync, } from "node:crypto";
4
4
  import { promisify } from "node:util";
5
5
  import { gunzip as gunzipSync, gzip as gzipSync } from "node:zlib";
@@ -7,7 +7,7 @@ import Inison from "inison";
7
7
  import RE2 from "re2";
8
8
  import { globalConfig } from "./index.js";
9
9
  import { detectFieldType, isNumber, isPassword } from "./utils.js";
10
- export const exec = promisify(execSync);
10
+ export const exec = promisify(rawExec);
11
11
  export const execFile = promisify(execFileSync);
12
12
  export const gzip = promisify(gzipSync);
13
13
  export const gunzip = promisify(gunzipSync);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "inibase",
3
- "version": "1.8.0",
3
+ "version": "2.0.1",
4
4
  "type": "module",
5
5
  "author": {
6
6
  "name": "Karim Amahtil",
@@ -87,6 +87,7 @@
87
87
  "build": "tsc",
88
88
  "benchmark": "./benchmark/run.js",
89
89
  "test": "tsx ./tests/inibase.test.ts",
90
- "test:utils": "tsx ./tests/utils.test.ts"
90
+ "test:utils": "tsx ./tests/utils.test.ts",
91
+ "test:advanced": "tsx ./tests/inibase.advanced.test.ts"
91
92
  }
92
93
  }