inibase 1.8.0 → 2.0.0
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 +9 -8
- package/dist/file.js +54 -26
- package/dist/index.d.ts +23 -1
- package/dist/index.js +313 -36
- 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,7 +111,7 @@ 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>;
|
|
115
116
|
export declare const min: (fp: string, ln?: number | number[]) => Promise<number>;
|
|
116
117
|
export declare const max: (fp: string, ln?: number | number[]) => Promise<number>;
|
package/dist/file.js
CHANGED
|
@@ -129,8 +129,10 @@ 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") ? input.replaceAll("\\n", "\n") || null : input;
|
|
134
136
|
return null;
|
|
135
137
|
};
|
|
136
138
|
/**
|
|
@@ -156,7 +158,8 @@ const decodeHelper = (value, field) => {
|
|
|
156
158
|
return value.map((v) => decode(v, {
|
|
157
159
|
...field,
|
|
158
160
|
type: Array.isArray(field.children)
|
|
159
|
-
? detectFieldType(v, field.children)
|
|
161
|
+
? (detectFieldType(v, field.children) ??
|
|
162
|
+
field.children[0])
|
|
160
163
|
: field.children,
|
|
161
164
|
}));
|
|
162
165
|
break;
|
|
@@ -186,15 +189,33 @@ export const decode = (input, field) => {
|
|
|
186
189
|
return undefined;
|
|
187
190
|
// Detect the fieldType based on the input and the provided array of possible types.
|
|
188
191
|
// Decode the input using the decodeHelper function.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
192
|
+
let value = input;
|
|
193
|
+
if (typeof input === "string") {
|
|
194
|
+
if (isStringified(input)) {
|
|
195
|
+
try {
|
|
196
|
+
value = Inison.unstringify(input);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// The stored string merely *starts with* `{` or `[`
|
|
200
|
+
// (e.g. a template binding like `{{item.username}}`
|
|
201
|
+
// or a literal like `{hello world`) but is not valid
|
|
202
|
+
// Inison. Treat it as a plain string so a single row
|
|
203
|
+
// never breaks reads of the whole file/table.
|
|
204
|
+
value = unSecureString(input);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
else
|
|
208
|
+
value = unSecureString(input);
|
|
209
|
+
}
|
|
210
|
+
return decodeHelper(value, Array.isArray(field.type)
|
|
211
|
+
? {
|
|
212
|
+
...field,
|
|
213
|
+
type: detectFieldType(String(input), field.type) ??
|
|
214
|
+
field.type[0],
|
|
215
|
+
}
|
|
195
216
|
: field);
|
|
196
217
|
};
|
|
197
|
-
function _groupIntoRanges(arr, action = "p") {
|
|
218
|
+
export function _groupIntoRanges(arr, action = "p") {
|
|
198
219
|
if (arr.length === 0)
|
|
199
220
|
return [];
|
|
200
221
|
arr.sort((a, b) => a - b); // Ensure the array is sorted
|
|
@@ -224,10 +245,14 @@ export async function get(filePath, lineNumbers, field, readWholeFile = false) {
|
|
|
224
245
|
const rl = createReadLineInternface(filePath, fileHandle);
|
|
225
246
|
const lines = {};
|
|
226
247
|
let linesCount = 0;
|
|
248
|
+
const config = field ?? {
|
|
249
|
+
key: "BLABLA",
|
|
250
|
+
type: "string",
|
|
251
|
+
};
|
|
227
252
|
if (!lineNumbers) {
|
|
228
253
|
for await (const line of rl) {
|
|
229
254
|
linesCount++;
|
|
230
|
-
lines[linesCount] = decode(line,
|
|
255
|
+
lines[linesCount] = decode(line, config);
|
|
231
256
|
}
|
|
232
257
|
}
|
|
233
258
|
else if (lineNumbers === -1) {
|
|
@@ -237,7 +262,7 @@ export async function get(filePath, lineNumbers, field, readWholeFile = false) {
|
|
|
237
262
|
: `sed -n '$p' ${escapedFilePath}`;
|
|
238
263
|
const foundedLine = (await exec(command)).stdout.trimEnd();
|
|
239
264
|
if (foundedLine)
|
|
240
|
-
lines[linesCount] = decode(foundedLine,
|
|
265
|
+
lines[linesCount] = decode(foundedLine, config);
|
|
241
266
|
}
|
|
242
267
|
else {
|
|
243
268
|
lineNumbers = Array.isArray(lineNumbers) ? lineNumbers : [lineNumbers];
|
|
@@ -249,7 +274,7 @@ export async function get(filePath, lineNumbers, field, readWholeFile = false) {
|
|
|
249
274
|
linesCount++;
|
|
250
275
|
if (!lineNumbersArray.has(linesCount))
|
|
251
276
|
continue;
|
|
252
|
-
lines[linesCount] = decode(line,
|
|
277
|
+
lines[linesCount] = decode(line, config);
|
|
253
278
|
lineNumbersArray.delete(linesCount);
|
|
254
279
|
}
|
|
255
280
|
return [lines, linesCount];
|
|
@@ -261,7 +286,7 @@ export async function get(filePath, lineNumbers, field, readWholeFile = false) {
|
|
|
261
286
|
const foundedLines = (await exec(command)).stdout.trimEnd().split("\n");
|
|
262
287
|
let index = 0;
|
|
263
288
|
for (const line of foundedLines) {
|
|
264
|
-
lines[lineNumbers[index]] = decode(line,
|
|
289
|
+
lines[lineNumbers[index]] = decode(line, config);
|
|
265
290
|
index++;
|
|
266
291
|
}
|
|
267
292
|
}
|
|
@@ -561,7 +586,7 @@ function buildEqualsPatterns(comparedAtValue, field) {
|
|
|
561
586
|
for (const form of [...new Set(forms)]) {
|
|
562
587
|
let valid = false;
|
|
563
588
|
try {
|
|
564
|
-
valid = compare("=", decode(form, field), value, type);
|
|
589
|
+
valid = compare("=", decode(form, field) ?? null, value, type);
|
|
565
590
|
}
|
|
566
591
|
catch {
|
|
567
592
|
valid = false;
|
|
@@ -578,7 +603,7 @@ function buildEqualsPatterns(comparedAtValue, field) {
|
|
|
578
603
|
const form = String(Number(value));
|
|
579
604
|
let valid = false;
|
|
580
605
|
try {
|
|
581
|
-
valid = compare("=", decode(form, field), value, type);
|
|
606
|
+
valid = compare("=", decode(form, field) ?? null, value, type);
|
|
582
607
|
}
|
|
583
608
|
catch {
|
|
584
609
|
valid = false;
|
|
@@ -607,7 +632,7 @@ function buildEqualsPatterns(comparedAtValue, field) {
|
|
|
607
632
|
const form = String(encoded);
|
|
608
633
|
let valid = false;
|
|
609
634
|
try {
|
|
610
|
-
valid = compare("=", decode(form, field), value, type);
|
|
635
|
+
valid = compare("=", decode(form, field) ?? null, value, type);
|
|
611
636
|
}
|
|
612
637
|
catch {
|
|
613
638
|
valid = false;
|
|
@@ -642,7 +667,7 @@ async function searchEqualsNative(filePath, patterns, field, searchIn, comparedA
|
|
|
642
667
|
// (decode() eagerly unstringifies them), so a native whole-line grep could
|
|
643
668
|
// silently miss matches that live inside those containers. Detect such lines
|
|
644
669
|
// up front; when any are present, fall back to the JS reader.
|
|
645
|
-
let probe;
|
|
670
|
+
let probe = "";
|
|
646
671
|
try {
|
|
647
672
|
({ stdout: probe } = await exec(`LC_ALL=C ${source} | LC_ALL=C grep -a -m 1 -E '^[[{]'`, { maxBuffer: 1024 * 1024 * 4 }));
|
|
648
673
|
}
|
|
@@ -696,8 +721,8 @@ async function searchEqualsNative(filePath, patterns, field, searchIn, comparedA
|
|
|
696
721
|
// Defensive verification: the raw line must decode to one of the compared
|
|
697
722
|
// values (guaranteed by construction, kept as a safety net).
|
|
698
723
|
const verifies = Array.isArray(comparedAtValue)
|
|
699
|
-
? comparedAtValue.some((value) => compare("=", decodedLine, value, field.type))
|
|
700
|
-
: compare("=", decodedLine, comparedAtValue, field.type);
|
|
724
|
+
? comparedAtValue.some((value) => compare("=", decodedLine ?? null, value, field.type))
|
|
725
|
+
: compare("=", decodedLine ?? null, comparedAtValue, field.type);
|
|
701
726
|
if (!verifies)
|
|
702
727
|
return null;
|
|
703
728
|
matchingLines[lineNumber] = decodedLine;
|
|
@@ -724,14 +749,13 @@ async function searchEqualsNative(filePath, patterns, field, searchIn, comparedA
|
|
|
724
749
|
*/
|
|
725
750
|
export const search = async (filePath, operator, comparedAtValue, logicalOperator, searchIn, field, limit, offset, readWholeFile) => {
|
|
726
751
|
// Native fast path for exact-equality searches (whole-line `grep -x -F`).
|
|
727
|
-
const fieldType = field?.type;
|
|
728
752
|
if (operator === "=" &&
|
|
729
753
|
!Array.isArray(operator) &&
|
|
730
754
|
!logicalOperator &&
|
|
731
755
|
comparedAtValue !== null &&
|
|
732
756
|
comparedAtValue !== undefined &&
|
|
733
|
-
typeof
|
|
734
|
-
EQUALS_FAST_TYPES.has(
|
|
757
|
+
typeof field?.type === "string" &&
|
|
758
|
+
EQUALS_FAST_TYPES.has(field.type)) {
|
|
735
759
|
const patterns = buildEqualsPatterns(comparedAtValue, field);
|
|
736
760
|
if (patterns) {
|
|
737
761
|
const fastResult = await searchEqualsNative(filePath, patterns, field, searchIn, comparedAtValue, limit, offset, readWholeFile);
|
|
@@ -745,13 +769,17 @@ export const search = async (filePath, operator, comparedAtValue, logicalOperato
|
|
|
745
769
|
let linesCount = 0;
|
|
746
770
|
const linesNumbers = new Set();
|
|
747
771
|
let fileHandle = null;
|
|
772
|
+
const config = field ?? {
|
|
773
|
+
key: "BLABLA",
|
|
774
|
+
type: "string",
|
|
775
|
+
};
|
|
748
776
|
const meetsConditions = (value) => (Array.isArray(operator) &&
|
|
749
777
|
Array.isArray(comparedAtValue) &&
|
|
750
778
|
((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],
|
|
779
|
+
operator.some((single_operator, index) => compare(single_operator, value, comparedAtValue[index], config.type))) ||
|
|
780
|
+
operator.every((single_operator, index) => compare(single_operator, value, comparedAtValue[index], config.type)))) ||
|
|
753
781
|
(!Array.isArray(operator) &&
|
|
754
|
-
compare(operator, value, comparedAtValue,
|
|
782
|
+
compare(operator, value, comparedAtValue, config.type));
|
|
755
783
|
try {
|
|
756
784
|
// Open the file for reading.
|
|
757
785
|
fileHandle = await open(filePath, "r");
|
|
@@ -766,7 +794,7 @@ export const search = async (filePath, operator, comparedAtValue, logicalOperato
|
|
|
766
794
|
(!searchIn.has(linesCount) || searchIn.has(-linesCount)))
|
|
767
795
|
continue;
|
|
768
796
|
// Decode the line for comparison.
|
|
769
|
-
const decodedLine = decode(line,
|
|
797
|
+
const decodedLine = decode(line, config);
|
|
770
798
|
// Check if the line meets the specified conditions based on comparison and logical operators.
|
|
771
799
|
const doesMeetCondition = (Array.isArray(decodedLine) &&
|
|
772
800
|
operator !== "=" &&
|
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
|
*
|
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);
|
|
@@ -579,7 +606,11 @@ export default class Inibase {
|
|
|
579
606
|
this.uniqueMap = new Map();
|
|
580
607
|
}
|
|
581
608
|
formatData(data, schema, formatOnlyAvailiableKeys) {
|
|
582
|
-
|
|
609
|
+
// formatData only reads its input (all transformations produce new
|
|
610
|
+
// values), so no defensive clone is needed. Callers pass data they no
|
|
611
|
+
// longer need, and skipping the copy halves the payload footprint of
|
|
612
|
+
// every bulk post/put.
|
|
613
|
+
const clonedData = data;
|
|
583
614
|
if (Utils.isArrayOfObjects(clonedData))
|
|
584
615
|
return clonedData.map((singleData) => this.formatData(singleData, schema, formatOnlyAvailiableKeys));
|
|
585
616
|
if (Utils.isObject(clonedData)) {
|
|
@@ -717,7 +748,26 @@ export default class Inibase {
|
|
|
717
748
|
}
|
|
718
749
|
async processSchemaData(tableName, schema, linesNumber, options, prefix) {
|
|
719
750
|
const RETURN = {};
|
|
751
|
+
// Fast path: read every top-level simple column in one `paste | sed`
|
|
752
|
+
// child process instead of spawning one child process per column.
|
|
753
|
+
// Nested (prefixed) schemas keep the per-column path.
|
|
754
|
+
let batchedKeys = null;
|
|
755
|
+
if (!prefix && linesNumber?.length) {
|
|
756
|
+
const simpleFields = schema.filter((field) => this.isSimpleField(field.type));
|
|
757
|
+
const batched = await this.processSimpleFieldsBatch(tableName, simpleFields, linesNumber);
|
|
758
|
+
if (batched) {
|
|
759
|
+
batchedKeys = new Set(simpleFields.map((field) => field.key));
|
|
760
|
+
for (const [line, row] of Object.entries(batched)) {
|
|
761
|
+
if (!RETURN[line])
|
|
762
|
+
RETURN[line] = {};
|
|
763
|
+
Object.assign(RETURN[line], row);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
720
767
|
for (const field of schema) {
|
|
768
|
+
// Batch-read fields were already merged into RETURN.
|
|
769
|
+
if (batchedKeys?.has(field.key))
|
|
770
|
+
continue;
|
|
721
771
|
// If the field is of simple type (non-recursive), process it directly
|
|
722
772
|
if (this.isSimpleField(field.type)) {
|
|
723
773
|
await this.processSimpleField(tableName, field, RETURN, linesNumber, prefix);
|
|
@@ -768,6 +818,80 @@ export default class Inibase {
|
|
|
768
818
|
}
|
|
769
819
|
}
|
|
770
820
|
}
|
|
821
|
+
/**
|
|
822
|
+
* Batched read for top-level simple columns: a single `paste | sed` child
|
|
823
|
+
* process returns every requested line of every column at once instead of
|
|
824
|
+
* spawning one `sed`/`gunzip|sed` child process per column. Returning null
|
|
825
|
+
* (e.g. when any column file is missing, or the shell command fails) makes
|
|
826
|
+
* the caller fall back to the existing per-column reads.
|
|
827
|
+
*/
|
|
828
|
+
async processSimpleFieldsBatch(tableName, fields, linesNumber) {
|
|
829
|
+
if (!fields.length || !linesNumber.length)
|
|
830
|
+
return null;
|
|
831
|
+
const decodeID = globalConfig[this.databasePath].tables?.get(tableName)?.config
|
|
832
|
+
.decodeID === true;
|
|
833
|
+
// Decode configs are computed once per column (the per-column read path
|
|
834
|
+
// builds one config per file too), not once per cell.
|
|
835
|
+
const cols = [];
|
|
836
|
+
for (const field of fields) {
|
|
837
|
+
const path = join(this.databasePath, tableName, `${field.key}${this.getFileExtension(tableName)}`);
|
|
838
|
+
if (!(await File.isExists(path)))
|
|
839
|
+
return null;
|
|
840
|
+
cols.push({
|
|
841
|
+
path,
|
|
842
|
+
key: field.key,
|
|
843
|
+
config: {
|
|
844
|
+
...field,
|
|
845
|
+
type: field.key === "id" && decodeID
|
|
846
|
+
? "number"
|
|
847
|
+
: 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
|
}
|
|
@@ -1187,11 +1327,19 @@ export default class Inibase {
|
|
|
1187
1327
|
awkCommand = `awk '${itemsIDs.map((id) => `$1 == ${id}`).join(" || ")}'`;
|
|
1188
1328
|
}
|
|
1189
1329
|
else
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1330
|
+
// perPage < 0 means "no limit": select every line instead of
|
|
1331
|
+
// generating an empty awk window (with perPage -1 the old code
|
|
1332
|
+
// produced `awk ''`, which prints nothing and the empty stdout
|
|
1333
|
+
// decoded into a single hollow row).
|
|
1334
|
+
awkCommand =
|
|
1335
|
+
options.perPage < 0
|
|
1336
|
+
? "awk '1'"
|
|
1337
|
+
: `awk '${Array.from({ length: options.perPage }, (_, index) => (options.page - 1) *
|
|
1338
|
+
options.perPage +
|
|
1339
|
+
index +
|
|
1340
|
+
1)
|
|
1341
|
+
.map((lineNumber) => `NR==${lineNumber}`)
|
|
1342
|
+
.join(" || ")}'`;
|
|
1195
1343
|
const filesPathes = (sortArray.find(([key]) => key === "id")
|
|
1196
1344
|
? sortArray
|
|
1197
1345
|
: [["id", true], ...sortArray]).map((column) => join(tablePath, `${column[0]}${this.getFileExtension(tableName)}`));
|
|
@@ -1200,14 +1348,14 @@ export default class Inibase {
|
|
|
1200
1348
|
return null;
|
|
1201
1349
|
// Construct the paste command to merge files and filter lines by IDs
|
|
1202
1350
|
const pasteCommand = `paste '${filesPathes.join("' '")}'`;
|
|
1203
|
-
|
|
1204
|
-
const index = 1;
|
|
1351
|
+
const _idPrepended = !sortArray.find(([key]) => key === "id");
|
|
1205
1352
|
const sortColumns = sortArray
|
|
1206
1353
|
.map(([key, ascending], i) => {
|
|
1207
1354
|
const field = Utils.getField(key, schema);
|
|
1208
|
-
if (field)
|
|
1209
|
-
return
|
|
1210
|
-
|
|
1355
|
+
if (!field)
|
|
1356
|
+
return "";
|
|
1357
|
+
const colIndex = _idPrepended ? i + 2 : i + 1;
|
|
1358
|
+
return `-k${colIndex},${colIndex}${Utils.isFieldType(field, ["id", "number", "date"]) ? "n" : ""}${!ascending ? "r" : ""}`;
|
|
1211
1359
|
})
|
|
1212
1360
|
.join(" ");
|
|
1213
1361
|
const sortCommand = `sort ${sortColumns} -T='${join(tablePath, ".tmp")}'`;
|
|
@@ -1299,7 +1447,48 @@ export default class Inibase {
|
|
|
1299
1447
|
let Ids = where;
|
|
1300
1448
|
if (!Array.isArray(Ids))
|
|
1301
1449
|
Ids = [Ids];
|
|
1302
|
-
|
|
1450
|
+
// Fast path for decodeID tables whose ids are the dense sequence
|
|
1451
|
+
// 1..N: when the requested numeric ids form a duplicate-free
|
|
1452
|
+
// consecutive range [min..max] with max within the row count, the
|
|
1453
|
+
// line numbers ARE the ids — no id-file scan required.
|
|
1454
|
+
let lineNumbers = null;
|
|
1455
|
+
let countItems = 0;
|
|
1456
|
+
const isDecodeID = globalConfig[this.databasePath].tables?.get(tableName)?.config
|
|
1457
|
+
.decodeID === true &&
|
|
1458
|
+
!globalConfig[this.databasePath].tables?.get(tableName)?.config
|
|
1459
|
+
.prepend;
|
|
1460
|
+
if (isDecodeID &&
|
|
1461
|
+
this.idDensity.get(tableName) &&
|
|
1462
|
+
Ids.every(Utils.isNumber)) {
|
|
1463
|
+
const seen = new Set();
|
|
1464
|
+
let min = Number.POSITIVE_INFINITY;
|
|
1465
|
+
let max = Number.NEGATIVE_INFINITY;
|
|
1466
|
+
let distinct = true;
|
|
1467
|
+
for (const raw of Ids) {
|
|
1468
|
+
const n = Number(raw);
|
|
1469
|
+
if (seen.has(n)) {
|
|
1470
|
+
distinct = false;
|
|
1471
|
+
break;
|
|
1472
|
+
}
|
|
1473
|
+
seen.add(n);
|
|
1474
|
+
if (n < min)
|
|
1475
|
+
min = n;
|
|
1476
|
+
if (n > max)
|
|
1477
|
+
max = n;
|
|
1478
|
+
}
|
|
1479
|
+
if (distinct &&
|
|
1480
|
+
min >= 1 &&
|
|
1481
|
+
max <= pagination[1] &&
|
|
1482
|
+
max - min + 1 === Ids.length) {
|
|
1483
|
+
lineNumbers = {};
|
|
1484
|
+
for (let line = min; line <= max; line++)
|
|
1485
|
+
lineNumbers[line] = line;
|
|
1486
|
+
countItems = Ids.length;
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
if (!lineNumbers) {
|
|
1490
|
+
[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`));
|
|
1491
|
+
}
|
|
1303
1492
|
if (!lineNumbers)
|
|
1304
1493
|
return null;
|
|
1305
1494
|
this.totalItems.set(`${tableName}-id`, countItems);
|
|
@@ -1425,7 +1614,7 @@ export default class Inibase {
|
|
|
1425
1614
|
? await File.prepend(path, content)
|
|
1426
1615
|
: await File.append(path, content))));
|
|
1427
1616
|
await Promise.allSettled(renameList
|
|
1428
|
-
.filter((
|
|
1617
|
+
.filter((pair) => Boolean(pair[1]))
|
|
1429
1618
|
.map(async ([tempPath, filePath]) => rename(tempPath, filePath)));
|
|
1430
1619
|
if (globalConfig[this.databasePath].tables?.get(tableName)?.config.cache)
|
|
1431
1620
|
await this.clearCache(tableName);
|
|
@@ -1453,7 +1642,7 @@ export default class Inibase {
|
|
|
1453
1642
|
finally {
|
|
1454
1643
|
if (renameList.length)
|
|
1455
1644
|
await Promise.allSettled(renameList
|
|
1456
|
-
.filter((
|
|
1645
|
+
.filter((pair) => Boolean(pair[1]))
|
|
1457
1646
|
.map(async ([tempPath, _]) => unlink(tempPath)));
|
|
1458
1647
|
await File.unlock(join(tablePath, ".tmp"), keys);
|
|
1459
1648
|
}
|
|
@@ -1498,7 +1687,7 @@ export default class Inibase {
|
|
|
1498
1687
|
this.totalItems.set(`${tableName}-*`, parse(paginationFileName).name.split("-").map(Number)[1]);
|
|
1499
1688
|
await Promise.allSettled(Object.entries(pathesContents).map(async ([path, content]) => renameList.push(await File.replace(path, content, this.totalItems.get(`${tableName}-*`)))));
|
|
1500
1689
|
await Promise.allSettled(renameList
|
|
1501
|
-
.filter((
|
|
1690
|
+
.filter((pair) => Boolean(pair[1]))
|
|
1502
1691
|
.map(async ([tempPath, filePath]) => rename(tempPath, filePath)));
|
|
1503
1692
|
if (globalConfig[this.databasePath].tables?.get(tableName)?.config.cache)
|
|
1504
1693
|
await this.clearCache(join(tablePath, ".cache"));
|
|
@@ -1508,7 +1697,7 @@ export default class Inibase {
|
|
|
1508
1697
|
finally {
|
|
1509
1698
|
if (renameList.length)
|
|
1510
1699
|
await Promise.allSettled(renameList
|
|
1511
|
-
.filter((
|
|
1700
|
+
.filter((pair) => Boolean(pair[1]))
|
|
1512
1701
|
.map(async ([tempPath, _]) => unlink(tempPath)));
|
|
1513
1702
|
await File.unlock(join(tablePath, ".tmp"));
|
|
1514
1703
|
}
|
|
@@ -1540,7 +1729,7 @@ export default class Inibase {
|
|
|
1540
1729
|
await File.lock(join(tablePath, ".tmp"), keys);
|
|
1541
1730
|
await Promise.allSettled(Object.entries(pathesContents).map(async ([path, content]) => renameList.push(await File.replace(path, content))));
|
|
1542
1731
|
await Promise.allSettled(renameList
|
|
1543
|
-
.filter((
|
|
1732
|
+
.filter((pair) => Boolean(pair[1]))
|
|
1544
1733
|
.map(async ([tempPath, filePath]) => rename(tempPath, filePath)));
|
|
1545
1734
|
if (globalConfig[this.databasePath].tables?.get(tableName)?.config.cache)
|
|
1546
1735
|
await this.clearCache(tableName);
|
|
@@ -1550,7 +1739,7 @@ export default class Inibase {
|
|
|
1550
1739
|
finally {
|
|
1551
1740
|
if (renameList.length)
|
|
1552
1741
|
await Promise.allSettled(renameList
|
|
1553
|
-
.filter((
|
|
1742
|
+
.filter((pair) => Boolean(pair[1]))
|
|
1554
1743
|
.map(async ([tempPath, _]) => unlink(tempPath)));
|
|
1555
1744
|
await File.unlock(join(tablePath, ".tmp"), keys);
|
|
1556
1745
|
}
|
|
@@ -1564,7 +1753,14 @@ export default class Inibase {
|
|
|
1564
1753
|
Utils.isValidID(where)) {
|
|
1565
1754
|
const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
|
|
1566
1755
|
if (lineNumbers)
|
|
1567
|
-
return this.put(tableName, clonedData,
|
|
1756
|
+
return this.put(tableName, clonedData,
|
|
1757
|
+
// get() with onlyLinesNumbers always returns an array; a
|
|
1758
|
+
// single-id update must keep the scalar so the recursive
|
|
1759
|
+
// line-numbers branch returns a single row (matching the
|
|
1760
|
+
// shape of get(singleId)) instead of a one-element array.
|
|
1761
|
+
!Array.isArray(where) && Array.isArray(lineNumbers)
|
|
1762
|
+
? lineNumbers[0]
|
|
1763
|
+
: lineNumbers, options, returnUpdatedData, true);
|
|
1568
1764
|
}
|
|
1569
1765
|
else if (Utils.isObject(where)) {
|
|
1570
1766
|
const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
|
|
@@ -1581,7 +1777,7 @@ export default class Inibase {
|
|
|
1581
1777
|
* @param {(number | string | (number | string)[] | Criteria)} [where]
|
|
1582
1778
|
* @return {boolean | null} {(Promise<boolean | null>)}
|
|
1583
1779
|
*/
|
|
1584
|
-
async delete(tableName, where, _whereIsLinesNumbers) {
|
|
1780
|
+
async delete(tableName, where, _whereIsLinesNumbers, _cascadeGuard) {
|
|
1585
1781
|
this.validateName(tableName);
|
|
1586
1782
|
const tablePath = join(this.databasePath, tableName);
|
|
1587
1783
|
await this.throwErrorIfTableEmpty(tableName);
|
|
@@ -1604,6 +1800,12 @@ export default class Inibase {
|
|
|
1604
1800
|
if (globalConfig[this.databasePath].tables?.get(tableName)?.config.cache)
|
|
1605
1801
|
await this.clearCache(tableName);
|
|
1606
1802
|
await rename(paginationFilePath, join(tablePath, `${pagination[0]}-0.pagination`));
|
|
1803
|
+
this.idDensity.set(tableName, true);
|
|
1804
|
+
// Deleting every row must also delete rows that reference them.
|
|
1805
|
+
if (pagination[1]) {
|
|
1806
|
+
const allLines = Array.from({ length: pagination[1] }, (_, i) => i + 1);
|
|
1807
|
+
await this.cascadeDelete(tableName, allLines, new Set());
|
|
1808
|
+
}
|
|
1607
1809
|
return true;
|
|
1608
1810
|
}
|
|
1609
1811
|
finally {
|
|
@@ -1633,24 +1835,30 @@ export default class Inibase {
|
|
|
1633
1835
|
}
|
|
1634
1836
|
if (pagination[1] &&
|
|
1635
1837
|
pagination[1] - (Array.isArray(where) ? where.length : 1) > 0) {
|
|
1838
|
+
this.idDensity.set(tableName, false);
|
|
1636
1839
|
await Promise.allSettled(files.map(async (file) => renameList.push(await File.remove(join(tablePath, file), where))));
|
|
1637
1840
|
await Promise.allSettled(renameList
|
|
1638
|
-
.filter((
|
|
1841
|
+
.filter((pair) => Boolean(pair[1]))
|
|
1639
1842
|
.map(async ([tempPath, filePath]) => rename(tempPath, filePath)));
|
|
1640
1843
|
}
|
|
1641
|
-
else
|
|
1844
|
+
else {
|
|
1845
|
+
this.idDensity.set(tableName, true);
|
|
1642
1846
|
await Promise.allSettled((await readdir(tablePath))
|
|
1643
1847
|
?.filter((fileName) => fileName.endsWith(this.getFileExtension(tableName)))
|
|
1644
1848
|
.map(async (file) => unlink(join(tablePath, file))));
|
|
1849
|
+
}
|
|
1645
1850
|
if (globalConfig[this.databasePath].tables?.get(tableName)?.config.cache)
|
|
1646
1851
|
await this.clearCache(tableName);
|
|
1647
1852
|
await rename(paginationFilePath, join(tablePath, `${pagination[0]}-${pagination[1] - (Array.isArray(where) ? where.length : 1)}.pagination`));
|
|
1853
|
+
// Cascade: rows in other tables referencing the deleted rows
|
|
1854
|
+
// (via `table`-typed fields) are removed too.
|
|
1855
|
+
await this.cascadeDelete(tableName, Array.isArray(where) ? where : [where], _cascadeGuard ?? new Set());
|
|
1648
1856
|
return true;
|
|
1649
1857
|
}
|
|
1650
1858
|
finally {
|
|
1651
1859
|
if (renameList.length)
|
|
1652
1860
|
await Promise.allSettled(renameList
|
|
1653
|
-
.filter((
|
|
1861
|
+
.filter((pair) => Boolean(pair[1]))
|
|
1654
1862
|
.map(async ([tempPath, _]) => unlink(tempPath)));
|
|
1655
1863
|
await File.unlock(join(tablePath, ".tmp"));
|
|
1656
1864
|
}
|
|
@@ -1664,17 +1872,86 @@ export default class Inibase {
|
|
|
1664
1872
|
(Array.isArray(where) && where.every(Utils.isValidID)) ||
|
|
1665
1873
|
Utils.isValidID(where)) {
|
|
1666
1874
|
const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
|
|
1667
|
-
|
|
1875
|
+
// Deleting a non-existent id must not fall through to the
|
|
1876
|
+
// "delete all rows" branch (this.delete(_, null, _) would truncate
|
|
1877
|
+
// the whole table), so resolve the id to line numbers first and
|
|
1878
|
+
// only delegate when something actually matched.
|
|
1879
|
+
if (lineNumbers)
|
|
1880
|
+
return this.delete(tableName, lineNumbers, true, _cascadeGuard ?? new Set());
|
|
1881
|
+
return false;
|
|
1668
1882
|
}
|
|
1669
1883
|
if (Utils.isObject(where)) {
|
|
1670
1884
|
const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
|
|
1671
1885
|
if (lineNumbers)
|
|
1672
|
-
return this.delete(tableName, lineNumbers, true);
|
|
1886
|
+
return this.delete(tableName, lineNumbers, true, _cascadeGuard ?? new Set());
|
|
1673
1887
|
}
|
|
1674
1888
|
else
|
|
1675
1889
|
throw this.createError("INVALID_PARAMETERS");
|
|
1676
1890
|
return false;
|
|
1677
1891
|
}
|
|
1892
|
+
/**
|
|
1893
|
+
* Cascade delete: remove rows in other tables whose `table`-typed schema
|
|
1894
|
+
* fields reference the given rows. Reference columns store the numeric
|
|
1895
|
+
* (line-number) id of the referenced row, so deleted ids are matched
|
|
1896
|
+
* against them directly. Recursion into child tables happens through
|
|
1897
|
+
* `delete` itself (which calls this method again); the `guard` set keeps
|
|
1898
|
+
* deep/cyclic reference chains from re-processing the same (table, line).
|
|
1899
|
+
*/
|
|
1900
|
+
async cascadeDelete(tableName, deletedLines, guard) {
|
|
1901
|
+
if (!deletedLines.length)
|
|
1902
|
+
return;
|
|
1903
|
+
for (const line of deletedLines)
|
|
1904
|
+
guard.add(`${tableName}:${line}`);
|
|
1905
|
+
const tables = globalConfig[this.databasePath]?.tables;
|
|
1906
|
+
if (!tables)
|
|
1907
|
+
return;
|
|
1908
|
+
for (const [candidateName, tableData] of tables) {
|
|
1909
|
+
if (candidateName === tableName || !tableData?.schema)
|
|
1910
|
+
continue;
|
|
1911
|
+
// Only direct `table`-typed columns hold one stored id per row;
|
|
1912
|
+
// arrays/objects of table refs serialize differently and are
|
|
1913
|
+
// intentionally out of scope for the cascade.
|
|
1914
|
+
const refFields = Utils.flattenSchema(tableData.schema, true).filter((field) => field.table === tableName && field.type === "table");
|
|
1915
|
+
if (!refFields.length)
|
|
1916
|
+
continue;
|
|
1917
|
+
for (const field of refFields) {
|
|
1918
|
+
const refPath = join(this.databasePath, candidateName, `${field.key}${this.getFileExtension(candidateName)}`);
|
|
1919
|
+
if (!(await File.isExists(refPath)))
|
|
1920
|
+
continue;
|
|
1921
|
+
const matching = new Set();
|
|
1922
|
+
for (const line of deletedLines) {
|
|
1923
|
+
try {
|
|
1924
|
+
const [, , found] = await File.search(refPath, "=", line, undefined, undefined, {
|
|
1925
|
+
key: field.key,
|
|
1926
|
+
type: "number",
|
|
1927
|
+
databasePath: this.databasePath,
|
|
1928
|
+
}, undefined, undefined, false);
|
|
1929
|
+
if (found)
|
|
1930
|
+
for (const l of found)
|
|
1931
|
+
matching.add(l);
|
|
1932
|
+
}
|
|
1933
|
+
catch {
|
|
1934
|
+
// Unreadable/unsupported column -> skip this reference.
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
const toDelete = [...matching].filter((line) => {
|
|
1938
|
+
const key = `${candidateName}:${line}`;
|
|
1939
|
+
if (guard.has(key))
|
|
1940
|
+
return false;
|
|
1941
|
+
guard.add(key);
|
|
1942
|
+
return true;
|
|
1943
|
+
});
|
|
1944
|
+
if (!toDelete.length)
|
|
1945
|
+
continue;
|
|
1946
|
+
try {
|
|
1947
|
+
await this.delete(candidateName, toDelete, true, guard);
|
|
1948
|
+
}
|
|
1949
|
+
catch {
|
|
1950
|
+
// Cascade is best-effort: never break the parent delete.
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1678
1955
|
async sum(tableName, columns, where) {
|
|
1679
1956
|
this.validateName(tableName);
|
|
1680
1957
|
if (!Array.isArray(columns))
|
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.0",
|
|
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
|
}
|