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 +1 -0
- package/dist/file.d.ts +10 -8
- package/dist/file.js +67 -30
- package/dist/index.d.ts +33 -1
- package/dist/index.js +335 -36
- package/dist/utils.d.ts +2 -1
- package/dist/utils.js +20 -4
- package/dist/utils.server.d.ts +2 -2
- package/dist/utils.server.js +2 -2
- package/package.json +3 -2
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.
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
734
|
-
EQUALS_FAST_TYPES.has(
|
|
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],
|
|
752
|
-
operator.every((single_operator, index) => compare(single_operator, value, comparedAtValue[index],
|
|
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,
|
|
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,
|
|
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"
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
1060
|
-
|
|
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
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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 =
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
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
|
-
|
|
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
|
|
1210
|
-
|
|
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
|
-
|
|
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((
|
|
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((
|
|
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((
|
|
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((
|
|
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((
|
|
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((
|
|
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,
|
|
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((
|
|
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((
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
*
|
package/dist/utils.server.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import "dotenv/config";
|
|
2
|
-
import { execFile as execFileSync, exec as
|
|
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
|
|
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__;
|
package/dist/utils.server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import "dotenv/config";
|
|
2
|
-
import { execFile as execFileSync, exec as
|
|
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(
|
|
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": "
|
|
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
|
}
|