inibase 2.0.0 → 3.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 +97 -2
- package/dist/file.d.ts +21 -1
- package/dist/file.js +225 -21
- package/dist/index.d.ts +124 -0
- package/dist/index.js +770 -153
- package/dist/journal.d.ts +109 -0
- package/dist/journal.js +263 -0
- package/dist/utils.d.ts +2 -1
- package/dist/utils.js +20 -4
- package/package.json +5 -2
package/dist/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import "dotenv/config";
|
|
2
|
-
import { randomBytes, scryptSync } from "node:crypto";
|
|
2
|
+
import { randomBytes, randomUUID, scryptSync } from "node:crypto";
|
|
3
3
|
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
4
|
-
import { glob, mkdir, readdir, readFile, rename, rm, unlink, writeFile, } from "node:fs/promises";
|
|
5
|
-
import { join, parse } from "node:path";
|
|
4
|
+
import { glob, mkdir, readdir, readFile, rename, rm, stat, unlink, writeFile, } from "node:fs/promises";
|
|
5
|
+
import { basename, join, parse, resolve } from "node:path";
|
|
6
6
|
import { inspect } from "node:util";
|
|
7
7
|
import Inison from "inison";
|
|
8
8
|
import * as File from "./file.js";
|
|
9
|
+
import { DatabaseJournal, Journal } from "./journal.js";
|
|
9
10
|
import * as Utils from "./utils.js";
|
|
10
11
|
import * as UtilsServer from "./utils.server.js";
|
|
11
12
|
export const ERROR_CODES = [
|
|
@@ -48,6 +49,13 @@ export default class Inibase {
|
|
|
48
49
|
databasePath;
|
|
49
50
|
uniqueMap;
|
|
50
51
|
schemaFileExtension = process.env.INIBASE_SCHEMA_EXTENSION ?? "json";
|
|
52
|
+
/**
|
|
53
|
+
* Open database transaction (see begin/commit/rollback). Holds the
|
|
54
|
+
* database lock (`<db>/.tmp/.locked`) for its whole lifetime and the
|
|
55
|
+
* per-table writer lock of every table it mutates, so mutations stage into
|
|
56
|
+
* the database journal and publish only on commit().
|
|
57
|
+
*/
|
|
58
|
+
transaction = null;
|
|
51
59
|
constructor(database, mainFolder = ".", language = "en") {
|
|
52
60
|
this.language = language;
|
|
53
61
|
this.validateName(database);
|
|
@@ -128,6 +136,11 @@ export default class Inibase {
|
|
|
128
136
|
*/
|
|
129
137
|
async createTable(tableName, schema, config) {
|
|
130
138
|
this.validateName(tableName);
|
|
139
|
+
// DDL does not participate in the write-ahead journal: schema surgery
|
|
140
|
+
// inside a transaction would escape the atomic publish/rollback scope.
|
|
141
|
+
if (this.transaction)
|
|
142
|
+
throw this.createError("INVALID_PARAMETERS");
|
|
143
|
+
await this.ensureDatabaseRecovered();
|
|
131
144
|
if (schema)
|
|
132
145
|
this.validateSchema(schema);
|
|
133
146
|
const tablePath = join(this.databasePath, tableName);
|
|
@@ -145,24 +158,27 @@ export default class Inibase {
|
|
|
145
158
|
};
|
|
146
159
|
if (config) {
|
|
147
160
|
if (config.compression)
|
|
148
|
-
await
|
|
161
|
+
await File.write(join(tablePath, ".compression.config"), "");
|
|
149
162
|
if (config.cache)
|
|
150
|
-
await
|
|
163
|
+
await File.write(join(tablePath, ".cache.config"), "");
|
|
151
164
|
if (config.prepend)
|
|
152
|
-
await
|
|
165
|
+
await File.write(join(tablePath, ".prepend.config"), "");
|
|
153
166
|
if (config.decodeID)
|
|
154
|
-
await
|
|
167
|
+
await File.write(join(tablePath, ".decodeID.config"), "");
|
|
155
168
|
}
|
|
156
169
|
if (schema) {
|
|
157
170
|
const lastSchemaID = { value: 0 };
|
|
158
|
-
await
|
|
171
|
+
await File.write(join(tablePath, `schema.${this.schemaFileExtension}`), this.schemaFileExtension === "json"
|
|
159
172
|
? JSON.stringify(Utils.addIdToSchema(schema, lastSchemaID), null, 2)
|
|
160
173
|
: Inison.stringify(Utils.addIdToSchema(schema, lastSchemaID)));
|
|
161
|
-
await
|
|
174
|
+
await File.write(join(tablePath, `${lastSchemaID.value}.schema`), "");
|
|
162
175
|
}
|
|
163
176
|
else
|
|
164
|
-
await
|
|
165
|
-
await
|
|
177
|
+
await File.write(join(tablePath, "0.schema"), "");
|
|
178
|
+
await File.write(join(tablePath, "0-0.pagination"), "");
|
|
179
|
+
// Make the new table's metadata durable before acknowledging creation.
|
|
180
|
+
await File.syncDir(tablePath);
|
|
181
|
+
await File.syncDir(join(tablePath, ".tmp"));
|
|
166
182
|
this.idDensity.set(tableName, true);
|
|
167
183
|
}
|
|
168
184
|
// Function to replace the string in one schema file
|
|
@@ -170,7 +186,9 @@ export default class Inibase {
|
|
|
170
186
|
const data = await readFile(filePath, "utf8");
|
|
171
187
|
if (data.includes(targetString)) {
|
|
172
188
|
const updatedContent = data.replaceAll(targetString, replaceString);
|
|
173
|
-
|
|
189
|
+
// File.write fsyncs the replacement so a link-update after a table
|
|
190
|
+
// rename survives a crash.
|
|
191
|
+
await File.write(filePath, updatedContent);
|
|
174
192
|
}
|
|
175
193
|
}
|
|
176
194
|
/**
|
|
@@ -182,12 +200,35 @@ export default class Inibase {
|
|
|
182
200
|
*/
|
|
183
201
|
async updateTable(tableName, schema, config) {
|
|
184
202
|
this.validateName(tableName);
|
|
203
|
+
// DDL does not participate in the write-ahead journal: schema surgery
|
|
204
|
+
// inside a transaction would escape the atomic publish/rollback scope.
|
|
205
|
+
if (this.transaction)
|
|
206
|
+
throw this.createError("INVALID_PARAMETERS");
|
|
207
|
+
await this.ensureDatabaseRecovered();
|
|
185
208
|
if (config?.name)
|
|
186
209
|
this.validateName(config.name);
|
|
187
210
|
const table = await this.getTable(tableName);
|
|
188
211
|
if (!table)
|
|
189
212
|
return;
|
|
190
213
|
const tablePath = join(this.databasePath, tableName);
|
|
214
|
+
// DDL is serialized with DML writers on the same per-table lock, so a
|
|
215
|
+
// post/put/delete can never interleave with schema/file surgery.
|
|
216
|
+
try {
|
|
217
|
+
await File.lock(join(tablePath, ".tmp"));
|
|
218
|
+
await this.updateTableLocked(tableName, table, tablePath, schema, config);
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
await File.unlock(join(tablePath, ".tmp"));
|
|
222
|
+
// Renaming the table moves its .tmp (and with it the lock file)
|
|
223
|
+
// to the new directory, so the unlock above only released the old
|
|
224
|
+
// path. Release the lock at its new location too, or the renamed
|
|
225
|
+
// table is left with a perpetual live-owner lock no writer can
|
|
226
|
+
// steal.
|
|
227
|
+
if (config?.name && config.name !== tableName)
|
|
228
|
+
await File.unlock(join(join(this.databasePath, config.name), ".tmp"));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async updateTableLocked(tableName, table, tablePath, schema, config) {
|
|
191
232
|
if (schema) {
|
|
192
233
|
this.validateSchema(schema);
|
|
193
234
|
// remove id from schema
|
|
@@ -214,13 +255,15 @@ export default class Inibase {
|
|
|
214
255
|
}
|
|
215
256
|
}));
|
|
216
257
|
}
|
|
217
|
-
|
|
258
|
+
// Data-bearing schema writes go through File.write so they are
|
|
259
|
+
// fsynced before updateTable returns.
|
|
260
|
+
await File.write(join(tablePath, `schema.${this.schemaFileExtension}`), this.schemaFileExtension === "json"
|
|
218
261
|
? JSON.stringify(schema, null, 2)
|
|
219
262
|
: Inison.stringify(schema));
|
|
220
263
|
if (schemaIdFilePath)
|
|
221
264
|
await rename(schemaIdFilePath, join(tablePath, `${lastSchemaID.value}.schema`));
|
|
222
265
|
else
|
|
223
|
-
await
|
|
266
|
+
await File.write(join(tablePath, `${lastSchemaID.value}.schema`), "");
|
|
224
267
|
// Fields added by this migration have no backing file yet. If the
|
|
225
268
|
// first post after the migration writes such a file from scratch it
|
|
226
269
|
// starts at line 1 and every existing row becomes misaligned (the
|
|
@@ -242,26 +285,31 @@ export default class Inibase {
|
|
|
242
285
|
if (config) {
|
|
243
286
|
if (config.compression !== undefined &&
|
|
244
287
|
config.compression !== table.config.compression) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
288
|
+
// Toggle compression crash-safely: the shell only decompresses
|
|
289
|
+
// to a temp file (streamed), the publish step fsyncs each temp
|
|
290
|
+
// before renaming it over the original, then the config marker
|
|
291
|
+
// is fsynced. A crash mid-toggle leaves valid (uncompressed or
|
|
292
|
+
// compressed) files, never a truncated one.
|
|
293
|
+
const toggleFiles = (await readdir(tablePath)).filter((name) => config.compression
|
|
294
|
+
? name.endsWith(this.fileExtension) &&
|
|
295
|
+
!name.endsWith(`${this.fileExtension}.gz`)
|
|
296
|
+
: name.endsWith(`${this.fileExtension}.gz`));
|
|
297
|
+
for (const name of toggleFiles) {
|
|
298
|
+
const src = join(tablePath, name);
|
|
299
|
+
const tmp = `${src}.recompressed`;
|
|
300
|
+
const target = config.compression ? `${src}.gz` : src.slice(0, -3); // strip ".gz"
|
|
301
|
+
await UtilsServer.exec(`${config.compression ? "gzip" : "gunzip"} -c ${File.escapeShellPath(src)} > ${File.escapeShellPath(tmp)}`);
|
|
302
|
+
await File.syncFile(tmp);
|
|
303
|
+
await rename(tmp, target);
|
|
304
|
+
}
|
|
257
305
|
if (config.compression)
|
|
258
|
-
await
|
|
306
|
+
await File.write(join(tablePath, ".compression.config"), "");
|
|
259
307
|
else
|
|
260
308
|
await unlink(join(tablePath, ".compression.config"));
|
|
261
309
|
}
|
|
262
310
|
if (config.cache !== undefined && config.cache !== table.config.cache) {
|
|
263
311
|
if (config.cache)
|
|
264
|
-
await
|
|
312
|
+
await File.write(join(tablePath, ".cache.config"), "");
|
|
265
313
|
else {
|
|
266
314
|
await this.clearCache(tableName);
|
|
267
315
|
await unlink(join(tablePath, ".cache.config"));
|
|
@@ -270,12 +318,17 @@ export default class Inibase {
|
|
|
270
318
|
if (config.decodeID !== undefined &&
|
|
271
319
|
config.decodeID !== table.config.decodeID) {
|
|
272
320
|
if (config.decodeID)
|
|
273
|
-
await
|
|
321
|
+
await File.write(join(tablePath, ".decodeID.config"), "");
|
|
274
322
|
else
|
|
275
323
|
await unlink(join(tablePath, ".decodeID.config"));
|
|
276
324
|
}
|
|
277
325
|
if (config.prepend !== undefined &&
|
|
278
326
|
config.prepend !== table.config.prepend) {
|
|
327
|
+
// Reverse every column file so the "first" row stays first after
|
|
328
|
+
// toggling prepend. The (streaming) shell only writes `<file>.reversed`
|
|
329
|
+
// temp files; the publish step below fsyncs each temp before renaming
|
|
330
|
+
// it into place, so a crash mid-toggle never leaves a half-reversed
|
|
331
|
+
// live file (the old file stays valid until the rename).
|
|
279
332
|
await UtilsServer.execFile("find", [
|
|
280
333
|
tableName,
|
|
281
334
|
"-type",
|
|
@@ -286,24 +339,34 @@ export default class Inibase {
|
|
|
286
339
|
"sh",
|
|
287
340
|
"-c",
|
|
288
341
|
`for file; do ${config.compression
|
|
289
|
-
? `zcat "$file" | ${process.platform === "darwin" ? "tail -r" : "tac"} | gzip > "$file.reversed"
|
|
290
|
-
: `${process.platform === "darwin" ? "tail -r" : "tac"} "$file" > "$file.reversed"
|
|
342
|
+
? `zcat "$file" | ${process.platform === "darwin" ? "tail -r" : "tac"} | gzip > "$file.reversed"`
|
|
343
|
+
: `${process.platform === "darwin" ? "tail -r" : "tac"} "$file" > "$file.reversed"`}; done`,
|
|
291
344
|
"_",
|
|
292
345
|
"{}",
|
|
293
346
|
"+",
|
|
294
347
|
], { cwd: this.databasePath });
|
|
348
|
+
const reversedSuffix = `${this.fileExtension}${config.compression ? ".gz" : ""}.reversed`;
|
|
349
|
+
for (const fileName of await readdir(tablePath)) {
|
|
350
|
+
if (!fileName.endsWith(reversedSuffix))
|
|
351
|
+
continue;
|
|
352
|
+
const reversedPath = join(tablePath, fileName);
|
|
353
|
+
await File.syncFile(reversedPath);
|
|
354
|
+
await rename(reversedPath, join(tablePath, fileName.slice(0, -".reversed".length)));
|
|
355
|
+
}
|
|
295
356
|
if (config.prepend)
|
|
296
|
-
await
|
|
357
|
+
await File.write(join(tablePath, ".prepend.config"), "");
|
|
297
358
|
else
|
|
298
359
|
await unlink(join(tablePath, ".prepend.config"));
|
|
299
360
|
}
|
|
300
361
|
if (config.name) {
|
|
301
362
|
await rename(tablePath, join(this.databasePath, config.name));
|
|
302
|
-
// replace table name in other linked tables (relationship)
|
|
363
|
+
// replace table name in other linked tables (relationship).
|
|
364
|
+
// glob() returns paths relative to `cwd`, so resolve them
|
|
365
|
+
// against the database path before touching the files.
|
|
303
366
|
for await (const schemaPath of glob(`**/schema.${this.schemaFileExtension}`, {
|
|
304
367
|
cwd: this.databasePath,
|
|
305
368
|
}))
|
|
306
|
-
await this.replaceStringInFile(schemaPath,
|
|
369
|
+
await this.replaceStringInFile(resolve(this.databasePath, schemaPath),
|
|
307
370
|
// TODO: escape caracters in table name
|
|
308
371
|
this.schemaFileExtension === "json"
|
|
309
372
|
? `"table": "${tableName}"`
|
|
@@ -312,6 +375,10 @@ export default class Inibase {
|
|
|
312
375
|
: `table:${config.name}`);
|
|
313
376
|
}
|
|
314
377
|
}
|
|
378
|
+
// Flush the directory entries touched by this DDL (renames, unlinks,
|
|
379
|
+
// fresh config markers) before updateTable returns so the schema/config
|
|
380
|
+
// change survives a crash.
|
|
381
|
+
await File.syncDir(config?.name ? this.databasePath : tablePath);
|
|
315
382
|
globalConfig[this.databasePath].tables?.delete(tableName);
|
|
316
383
|
}
|
|
317
384
|
/**
|
|
@@ -356,10 +423,14 @@ export default class Inibase {
|
|
|
356
423
|
otherSchemaFileExtension === "json"
|
|
357
424
|
? JSON.parse(schemaFile)
|
|
358
425
|
: Inison.unstringify(schemaFile);
|
|
359
|
-
|
|
426
|
+
// Mirror the legacy schema file into the preferred extension and
|
|
427
|
+
// drop the old one (fsync-backed; this read-path migration must
|
|
428
|
+
// survive a crash).
|
|
429
|
+
await File.write(join(tablePath, `schema.${this.schemaFileExtension}`), this.schemaFileExtension === "json"
|
|
360
430
|
? JSON.stringify(schema, null, 2)
|
|
361
431
|
: Inison.stringify(schema));
|
|
362
432
|
await unlink(join(tablePath, `schema.${otherSchemaFileExtension}`));
|
|
433
|
+
await File.syncDir(tablePath);
|
|
363
434
|
}
|
|
364
435
|
else
|
|
365
436
|
schemaFile = await readFile(join(tablePath, `schema.${this.schemaFileExtension}`), "utf8");
|
|
@@ -536,6 +607,8 @@ export default class Inibase {
|
|
|
536
607
|
? value
|
|
537
608
|
: Number(value.trim())
|
|
538
609
|
: 0;
|
|
610
|
+
case "date":
|
|
611
|
+
return Utils.dateToTimestamp(value);
|
|
539
612
|
case "id":
|
|
540
613
|
return Utils.isNumber(value)
|
|
541
614
|
? value
|
|
@@ -842,9 +915,7 @@ export default class Inibase {
|
|
|
842
915
|
key: field.key,
|
|
843
916
|
config: {
|
|
844
917
|
...field,
|
|
845
|
-
type: field.key === "id" && decodeID
|
|
846
|
-
? "number"
|
|
847
|
-
: field.type,
|
|
918
|
+
type: field.key === "id" && decodeID ? "number" : field.type,
|
|
848
919
|
databasePath: this.databasePath,
|
|
849
920
|
},
|
|
850
921
|
});
|
|
@@ -1264,11 +1335,387 @@ export default class Inibase {
|
|
|
1264
1335
|
await rm(cacheFolderPath, { recursive: true, force: true });
|
|
1265
1336
|
await mkdir(cacheFolderPath);
|
|
1266
1337
|
}
|
|
1338
|
+
/**
|
|
1339
|
+
* Commit a multi-file mutation crash-atomically:
|
|
1340
|
+
* 1. fsync every freshly-written temp file;
|
|
1341
|
+
* 2. write the journal `begin` entry and fsync it;
|
|
1342
|
+
* 3. rename the pagination metadata file first (atomic publication point:
|
|
1343
|
+
* the row count flips in a single rename, which is what lock-free
|
|
1344
|
+
* readers observe) and then swap each live file aside (backup) and
|
|
1345
|
+
* rename the temp into place;
|
|
1346
|
+
* 4. write the journal `commit` marker and fsync it;
|
|
1347
|
+
* 5. discard backups/temps + the journal, fsync the directories.
|
|
1348
|
+
*
|
|
1349
|
+
* On any failure before `commit`, the journal is rolled back so the table
|
|
1350
|
+
* is left exactly as it was. `renameList` entries are [tempPath, livePath]
|
|
1351
|
+
* pairs; a null tempPath means a pure removal (live file taken out).
|
|
1352
|
+
*/
|
|
1353
|
+
async commitFiles(tablePath, renameList, pagination) {
|
|
1354
|
+
const txn = randomUUID();
|
|
1355
|
+
const ops = [];
|
|
1356
|
+
// Backups are parked in a per-transaction subdirectory
|
|
1357
|
+
// (.tmp/backup/<txn>/), so a later transaction can never collide with
|
|
1358
|
+
// the leftovers of an earlier crashed one.
|
|
1359
|
+
const backupDir = join(tablePath, ".tmp", "backup", txn);
|
|
1360
|
+
await mkdir(backupDir, { recursive: true });
|
|
1361
|
+
for (const [tmp, live] of renameList) {
|
|
1362
|
+
if (!live)
|
|
1363
|
+
continue;
|
|
1364
|
+
ops.push({
|
|
1365
|
+
live,
|
|
1366
|
+
backup: join(backupDir, basename(live)),
|
|
1367
|
+
tmp,
|
|
1368
|
+
existed: await File.isExists(live),
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
const journal = new Journal(tablePath, txn);
|
|
1372
|
+
try {
|
|
1373
|
+
// Make every replacement durable before it can be published.
|
|
1374
|
+
await Promise.allSettled(ops
|
|
1375
|
+
.filter((op) => op.tmp)
|
|
1376
|
+
.map(async (op) => File.syncFile(op.tmp)));
|
|
1377
|
+
await journal.begin(ops, pagination);
|
|
1378
|
+
// Publish: the pagination rename is the atomic publication point
|
|
1379
|
+
// (row count flips in one rename) and MUST come first — readers
|
|
1380
|
+
// that snapshot file identities detect the flip and retry. Then
|
|
1381
|
+
// park each original and move the replacement in.
|
|
1382
|
+
if (pagination)
|
|
1383
|
+
await rename(pagination.from, pagination.to);
|
|
1384
|
+
for (const op of ops) {
|
|
1385
|
+
if (op.existed)
|
|
1386
|
+
await rename(op.live, op.backup);
|
|
1387
|
+
if (op.tmp)
|
|
1388
|
+
await rename(op.tmp, op.live);
|
|
1389
|
+
}
|
|
1390
|
+
await journal.commit();
|
|
1391
|
+
}
|
|
1392
|
+
catch (error) {
|
|
1393
|
+
await journal.rollback().catch(() => { });
|
|
1394
|
+
throw error;
|
|
1395
|
+
}
|
|
1396
|
+
finally {
|
|
1397
|
+
await Promise.allSettled(ops.map((op) => unlink(op.backup).catch(() => { })));
|
|
1398
|
+
await rm(backupDir, { recursive: true, force: true }).catch(() => { });
|
|
1399
|
+
await journal.dispose();
|
|
1400
|
+
await unlink(journal.path).catch(() => { });
|
|
1401
|
+
// Make the renames durable before acknowledging the commit.
|
|
1402
|
+
await File.syncDir(join(tablePath, ".tmp"));
|
|
1403
|
+
await File.syncDir(tablePath);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* Runs crash recovery for a table (and any crashed database transaction)
|
|
1408
|
+
* before a read. Mutation paths get the same guarantee implicitly (the
|
|
1409
|
+
* writer lock runs recovery on acquire); reads call this explicitly
|
|
1410
|
+
* because they never take the table lock.
|
|
1411
|
+
*/
|
|
1412
|
+
async ensureTableRecovered(tableName) {
|
|
1413
|
+
const tablePath = join(this.databasePath, tableName);
|
|
1414
|
+
if (await File.isExists(join(tablePath, ".tmp", "journal.jsonl"))) {
|
|
1415
|
+
await File.lock(join(tablePath, ".tmp"));
|
|
1416
|
+
await File.unlock(join(tablePath, ".tmp"));
|
|
1417
|
+
}
|
|
1418
|
+
// A database journal exists only while a live transaction holds the
|
|
1419
|
+
// database lock, or after one crashed. Recovery must never roll back a
|
|
1420
|
+
// live transaction, so acquire the database lock non-blocking here:
|
|
1421
|
+
// on success the previous owner is gone (recovery ran); on failure a
|
|
1422
|
+
// live transaction owns the lock across processes and readers simply
|
|
1423
|
+
// proceed against the committed state.
|
|
1424
|
+
const dbTmp = join(this.databasePath, ".tmp");
|
|
1425
|
+
if (await File.isExists(join(dbTmp, "journal.jsonl"))) {
|
|
1426
|
+
if (await File.tryLock(dbTmp))
|
|
1427
|
+
await File.unlock(dbTmp);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
/**
|
|
1431
|
+
* Blocking database-journal recovery, used by mutation paths (writers are
|
|
1432
|
+
* serialized with live transactions on the database lock anyway).
|
|
1433
|
+
*/
|
|
1434
|
+
async ensureDatabaseRecovered() {
|
|
1435
|
+
const dbTmp = join(this.databasePath, ".tmp");
|
|
1436
|
+
if (await File.isExists(join(dbTmp, "journal.jsonl"))) {
|
|
1437
|
+
await File.lock(dbTmp);
|
|
1438
|
+
await File.unlock(dbTmp);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
async ensureDatabaseTmpDir() {
|
|
1442
|
+
await mkdir(join(this.databasePath, ".tmp"), { recursive: true });
|
|
1443
|
+
}
|
|
1444
|
+
/** Staged per-table entry of the open transaction, or null when none. */
|
|
1445
|
+
txnTableEntry(tableName) {
|
|
1446
|
+
const txn = this.transaction;
|
|
1447
|
+
if (!txn)
|
|
1448
|
+
return null;
|
|
1449
|
+
let entry = txn.tables.get(tableName);
|
|
1450
|
+
if (!entry) {
|
|
1451
|
+
entry = {
|
|
1452
|
+
locked: false,
|
|
1453
|
+
paginationFrom: "",
|
|
1454
|
+
lastId: 0,
|
|
1455
|
+
total: 0,
|
|
1456
|
+
staged: [],
|
|
1457
|
+
};
|
|
1458
|
+
txn.tables.set(tableName, entry);
|
|
1459
|
+
}
|
|
1460
|
+
return entry;
|
|
1461
|
+
}
|
|
1462
|
+
/** Lock a table for the open transaction (idempotent per transaction). */
|
|
1463
|
+
async ensureTxnLock(tableName) {
|
|
1464
|
+
const entry = this.txnTableEntry(tableName);
|
|
1465
|
+
if (!entry || entry.locked)
|
|
1466
|
+
return;
|
|
1467
|
+
await File.lock(join(this.databasePath, tableName, ".tmp"));
|
|
1468
|
+
entry.locked = true;
|
|
1469
|
+
}
|
|
1470
|
+
/**
|
|
1471
|
+
* Resolve the pagination state a DML op should build on. Outside a
|
|
1472
|
+
* transaction this reads the live pagination file (as before); inside a
|
|
1473
|
+
* transaction the first touch reads it once and the entry keeps the staged
|
|
1474
|
+
* id/count so chained ops (guarded to one per table) and commit() stay
|
|
1475
|
+
* consistent without publishing anything early.
|
|
1476
|
+
*/
|
|
1477
|
+
async resolvePagination(tableName) {
|
|
1478
|
+
const tablePath = join(this.databasePath, tableName);
|
|
1479
|
+
const entry = this.txnTableEntry(tableName);
|
|
1480
|
+
if (entry?.paginationFrom) {
|
|
1481
|
+
return {
|
|
1482
|
+
filePath: entry.paginationFrom,
|
|
1483
|
+
lastId: entry.lastId,
|
|
1484
|
+
total: entry.total,
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
let paginationFilePath = "";
|
|
1488
|
+
for await (const fileName of glob("*.pagination", { cwd: tablePath }))
|
|
1489
|
+
paginationFilePath = join(tablePath, fileName);
|
|
1490
|
+
const [lastId, total] = parse(paginationFilePath)
|
|
1491
|
+
.name.split("-")
|
|
1492
|
+
.map(Number);
|
|
1493
|
+
if (entry) {
|
|
1494
|
+
entry.paginationFrom = paginationFilePath;
|
|
1495
|
+
entry.lastId = lastId;
|
|
1496
|
+
entry.total = total;
|
|
1497
|
+
}
|
|
1498
|
+
return { filePath: paginationFilePath, lastId, total };
|
|
1499
|
+
}
|
|
1500
|
+
/**
|
|
1501
|
+
* Stage one table mutation into the open transaction: fsync its temps and
|
|
1502
|
+
* append an `op` entry to the database journal (no live file is touched;
|
|
1503
|
+
* commit() performs the actual renames). One staged mutation per table per
|
|
1504
|
+
* transaction (multi-table atomicity; a second touch of the same table
|
|
1505
|
+
* would need read-your-writes composition).
|
|
1506
|
+
*/
|
|
1507
|
+
async stageTxnOp(tableName, renameList, pagination) {
|
|
1508
|
+
const txn = this.transaction;
|
|
1509
|
+
const entry = this.txnTableEntry(tableName);
|
|
1510
|
+
if (!txn || !entry)
|
|
1511
|
+
throw this.createError("INVALID_PARAMETERS");
|
|
1512
|
+
if (entry.staged.length)
|
|
1513
|
+
throw this.createError("INVALID_PARAMETERS");
|
|
1514
|
+
const backupDir = join(this.databasePath, ".tmp", "backup", txn.id);
|
|
1515
|
+
await mkdir(backupDir, { recursive: true });
|
|
1516
|
+
const ops = [];
|
|
1517
|
+
for (const [tmp, live] of renameList) {
|
|
1518
|
+
if (!live)
|
|
1519
|
+
continue;
|
|
1520
|
+
// Benchmarks must be unique within the whole transaction: the same
|
|
1521
|
+
// column basename can exist in several tables, and rollback
|
|
1522
|
+
// restores by path. Namespace per table (one staged op per table).
|
|
1523
|
+
ops.push({
|
|
1524
|
+
live,
|
|
1525
|
+
backup: join(backupDir, `${entry.staged.length}-${tableName}-${basename(live)}`),
|
|
1526
|
+
tmp,
|
|
1527
|
+
existed: await File.isExists(live),
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1530
|
+
// Make every replacement durable before the journal records intent.
|
|
1531
|
+
await Promise.allSettled(ops
|
|
1532
|
+
.filter((op) => op.tmp)
|
|
1533
|
+
.map(async (op) => File.syncFile(op.tmp)));
|
|
1534
|
+
await txn.journal.op(ops, pagination);
|
|
1535
|
+
entry.staged.push({ ops, pagination });
|
|
1536
|
+
if (pagination)
|
|
1537
|
+
entry.paginationFrom = pagination.to;
|
|
1538
|
+
}
|
|
1539
|
+
/**
|
|
1540
|
+
* Begin a database transaction. Mutations issued while the transaction is
|
|
1541
|
+
* open (post/put/delete, including cascade deletes) are staged into the
|
|
1542
|
+
* database journal and published atomically at commit(); rollback()
|
|
1543
|
+
* discards them without touching any live file.
|
|
1544
|
+
*
|
|
1545
|
+
* @param tables Optional table names to pre-lock at begin() in sorted
|
|
1546
|
+
* order (the deadlock-free way to span tables). Tables not listed are
|
|
1547
|
+
* locked on first touch, in first-touch order.
|
|
1548
|
+
*/
|
|
1549
|
+
async begin(tables = []) {
|
|
1550
|
+
if (this.transaction)
|
|
1551
|
+
throw this.createError("INVALID_PARAMETERS");
|
|
1552
|
+
await this.ensureDatabaseTmpDir();
|
|
1553
|
+
// The database lock is the transaction mutex: it serializes
|
|
1554
|
+
// transactions and its acquisition runs crash recovery on any journal
|
|
1555
|
+
// left behind by a crashed transaction.
|
|
1556
|
+
await File.lock(join(this.databasePath, ".tmp"));
|
|
1557
|
+
const uniqueTables = [...new Set(tables)].sort();
|
|
1558
|
+
const acquired = [];
|
|
1559
|
+
try {
|
|
1560
|
+
// Validate every listed table before locking anything.
|
|
1561
|
+
for (const name of uniqueTables) {
|
|
1562
|
+
this.validateName(name);
|
|
1563
|
+
await this.getTable(name); // throws TABLE_NOT_EXISTS
|
|
1564
|
+
}
|
|
1565
|
+
const id = randomUUID();
|
|
1566
|
+
this.transaction = {
|
|
1567
|
+
id,
|
|
1568
|
+
journal: new DatabaseJournal(this.databasePath, id),
|
|
1569
|
+
tables: new Map(),
|
|
1570
|
+
};
|
|
1571
|
+
await this.transaction.journal.begin(uniqueTables);
|
|
1572
|
+
for (const name of uniqueTables) {
|
|
1573
|
+
await File.lock(join(this.databasePath, name, ".tmp"));
|
|
1574
|
+
acquired.push(name);
|
|
1575
|
+
this.transaction.tables.set(name, {
|
|
1576
|
+
locked: true,
|
|
1577
|
+
paginationFrom: "",
|
|
1578
|
+
lastId: 0,
|
|
1579
|
+
total: 0,
|
|
1580
|
+
staged: [],
|
|
1581
|
+
});
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
catch (error) {
|
|
1585
|
+
// Release only the table locks this process actually took (an
|
|
1586
|
+
// unlock of a never-acquired path could unlink another process's
|
|
1587
|
+
// lock file).
|
|
1588
|
+
for (const name of acquired)
|
|
1589
|
+
await File.unlock(join(this.databasePath, name, ".tmp")).catch(() => { });
|
|
1590
|
+
this.transaction = null;
|
|
1591
|
+
await File.unlock(join(this.databasePath, ".tmp")).catch(() => { });
|
|
1592
|
+
throw error;
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
/**
|
|
1596
|
+
* Publish every staged mutation atomically: per table (sorted), the
|
|
1597
|
+
* pagination rename comes first (the atomic publication point readers
|
|
1598
|
+
* observe) and then live->backup + tmp->live swaps, before a single fsynced
|
|
1599
|
+
* `commit` marker makes the whole transaction durable. A crash at any
|
|
1600
|
+
* point is recovered by the journal rule (no marker -> roll back all
|
|
1601
|
+
* tables, marker -> roll forward all tables).
|
|
1602
|
+
*/
|
|
1603
|
+
async commit() {
|
|
1604
|
+
const txn = this.transaction;
|
|
1605
|
+
if (!txn)
|
|
1606
|
+
throw this.createError("INVALID_PARAMETERS");
|
|
1607
|
+
try {
|
|
1608
|
+
for (const tableName of [...txn.tables.keys()].sort()) {
|
|
1609
|
+
const entry = txn.tables.get(tableName);
|
|
1610
|
+
if (!entry)
|
|
1611
|
+
continue;
|
|
1612
|
+
for (const { ops, pagination } of entry.staged) {
|
|
1613
|
+
if (pagination)
|
|
1614
|
+
await rename(pagination.from, pagination.to);
|
|
1615
|
+
for (const op of ops) {
|
|
1616
|
+
if (op.existed)
|
|
1617
|
+
await rename(op.live, op.backup);
|
|
1618
|
+
if (op.tmp)
|
|
1619
|
+
await rename(op.tmp, op.live);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
await txn.journal.commit();
|
|
1624
|
+
// Clean the fast-path leftovers; recovery owns any crash leftovers.
|
|
1625
|
+
await txn.journal.dispose();
|
|
1626
|
+
await unlink(txn.journal.path).catch(() => { });
|
|
1627
|
+
await rm(join(this.databasePath, ".tmp", "backup", txn.id), {
|
|
1628
|
+
recursive: true,
|
|
1629
|
+
force: true,
|
|
1630
|
+
}).catch(() => { });
|
|
1631
|
+
await File.syncDir(join(this.databasePath, ".tmp"));
|
|
1632
|
+
await File.syncDir(this.databasePath);
|
|
1633
|
+
for (const tableName of txn.tables.keys()) {
|
|
1634
|
+
await File.syncDir(join(this.databasePath, tableName));
|
|
1635
|
+
await File.syncDir(join(this.databasePath, tableName, ".tmp"));
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
catch (error) {
|
|
1639
|
+
await txn.journal.rollback().catch(() => { });
|
|
1640
|
+
throw error;
|
|
1641
|
+
}
|
|
1642
|
+
finally {
|
|
1643
|
+
for (const tableName of [...txn.tables.keys()].sort().reverse())
|
|
1644
|
+
await File.unlock(join(this.databasePath, tableName, ".tmp"));
|
|
1645
|
+
await File.unlock(join(this.databasePath, ".tmp"));
|
|
1646
|
+
this.transaction = null;
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
/**
|
|
1650
|
+
* Discard the open transaction: temps and the journal are removed and no
|
|
1651
|
+
* live file is touched (nothing is published before commit()).
|
|
1652
|
+
*/
|
|
1653
|
+
async rollback() {
|
|
1654
|
+
const txn = this.transaction;
|
|
1655
|
+
if (!txn)
|
|
1656
|
+
throw this.createError("INVALID_PARAMETERS");
|
|
1657
|
+
try {
|
|
1658
|
+
await txn.journal.rollback().catch(() => { });
|
|
1659
|
+
}
|
|
1660
|
+
finally {
|
|
1661
|
+
for (const tableName of [...txn.tables.keys()].sort().reverse())
|
|
1662
|
+
await File.unlock(join(this.databasePath, tableName, ".tmp"));
|
|
1663
|
+
await File.unlock(join(this.databasePath, ".tmp"));
|
|
1664
|
+
this.transaction = null;
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
/**
|
|
1668
|
+
* Snapshot the identity (dev:inode:mtime:size) of every column file and
|
|
1669
|
+
* the pagination file. Reading data and then re-verifying this snapshot
|
|
1670
|
+
* lets lock-free readers detect an in-flight writer commit and retry
|
|
1671
|
+
* instead of returning a torn row set.
|
|
1672
|
+
*/
|
|
1673
|
+
async snapshotTableFiles(tableName) {
|
|
1674
|
+
const tablePath = join(this.databasePath, tableName);
|
|
1675
|
+
const extension = this.getFileExtension(tableName);
|
|
1676
|
+
const snapshot = new Map();
|
|
1677
|
+
for (const fileName of await readdir(tablePath).catch(() => [])) {
|
|
1678
|
+
if (!fileName.endsWith(extension) && !fileName.endsWith(".pagination"))
|
|
1679
|
+
continue;
|
|
1680
|
+
const filePath = join(tablePath, fileName);
|
|
1681
|
+
const fileStat = await stat(filePath).catch(() => null);
|
|
1682
|
+
if (fileStat)
|
|
1683
|
+
snapshot.set(filePath, `${fileStat.dev}:${fileStat.ino}:${fileStat.mtimeMs}:${fileStat.size}`);
|
|
1684
|
+
}
|
|
1685
|
+
return snapshot;
|
|
1686
|
+
}
|
|
1687
|
+
/** True when every snapshotted file is still present and unchanged. */
|
|
1688
|
+
async verifyTableFiles(snapshot) {
|
|
1689
|
+
for (const [filePath, identity] of snapshot) {
|
|
1690
|
+
const fileStat = await stat(filePath).catch(() => null);
|
|
1691
|
+
if (!fileStat ||
|
|
1692
|
+
`${fileStat.dev}:${fileStat.ino}:${fileStat.mtimeMs}:${fileStat.size}` !==
|
|
1693
|
+
identity)
|
|
1694
|
+
return false;
|
|
1695
|
+
}
|
|
1696
|
+
return true;
|
|
1697
|
+
}
|
|
1267
1698
|
async get(tableName, where, options = {
|
|
1268
1699
|
page: 1,
|
|
1269
1700
|
perPage: 15,
|
|
1270
1701
|
}, onlyOne, onlyLinesNumbers, _whereIsLinesNumbers) {
|
|
1271
1702
|
this.validateName(tableName);
|
|
1703
|
+
await this.ensureTableRecovered(tableName);
|
|
1704
|
+
// Lock-free reads with optimistic retry: snapshot the identity of every
|
|
1705
|
+
// column + pagination file, run the read, then verify nothing changed
|
|
1706
|
+
// mid-scan. A writer commit flips at least one file identity, so a torn
|
|
1707
|
+
// read is detected and re-run instead of being returned.
|
|
1708
|
+
for (let attempt = 0;; attempt++) {
|
|
1709
|
+
const snapshot = await this.snapshotTableFiles(tableName);
|
|
1710
|
+
const result = await this.getOnce(tableName, where, options, onlyOne, onlyLinesNumbers, _whereIsLinesNumbers);
|
|
1711
|
+
if (attempt === 2 || (await this.verifyTableFiles(snapshot)))
|
|
1712
|
+
return result;
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
async getOnce(tableName, where, options = {
|
|
1716
|
+
page: 1,
|
|
1717
|
+
perPage: 15,
|
|
1718
|
+
}, onlyOne, onlyLinesNumbers, _whereIsLinesNumbers) {
|
|
1272
1719
|
const tablePath = join(this.databasePath, tableName);
|
|
1273
1720
|
// Ensure options.columns is an array
|
|
1274
1721
|
if (options.columns) {
|
|
@@ -1316,9 +1763,11 @@ export default class Inibase {
|
|
|
1316
1763
|
.concat(options.sort)
|
|
1317
1764
|
.map((column) => [column, true]);
|
|
1318
1765
|
let cacheKey = "";
|
|
1319
|
-
// Criteria
|
|
1766
|
+
// Criteria. The sort cache is versioned by the pagination row count
|
|
1767
|
+
// (see the criteria-cache note) so stale sorted line numbers from
|
|
1768
|
+
// before a post/delete are never replayed.
|
|
1320
1769
|
if (globalConfig[this.databasePath].tables?.get(tableName)?.config.cache)
|
|
1321
|
-
cacheKey = UtilsServer.hashString(inspect(sortArray, { sorted: true }));
|
|
1770
|
+
cacheKey = UtilsServer.hashString(inspect([sortArray, pagination[1]], { sorted: true }));
|
|
1322
1771
|
if (where) {
|
|
1323
1772
|
const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
|
|
1324
1773
|
if (!lineNumbers?.length)
|
|
@@ -1326,16 +1775,15 @@ export default class Inibase {
|
|
|
1326
1775
|
const itemsIDs = Object.values((await File.get(join(tablePath, `id${this.getFileExtension(tableName)}`), lineNumbers, { key: "BLABLA", type: "number" })) ?? {}).map(Number);
|
|
1327
1776
|
awkCommand = `awk '${itemsIDs.map((id) => `$1 == ${id}`).join(" || ")}'`;
|
|
1328
1777
|
}
|
|
1778
|
+
// perPage < 0 means "no limit": select every line instead of
|
|
1779
|
+
// generating an empty awk window (with perPage -1 the old code
|
|
1780
|
+
// produced `awk ''`, which prints nothing and the empty stdout
|
|
1781
|
+
// decoded into a single hollow row).
|
|
1329
1782
|
else
|
|
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
1783
|
awkCommand =
|
|
1335
1784
|
options.perPage < 0
|
|
1336
1785
|
? "awk '1'"
|
|
1337
|
-
: `awk '${Array.from({ length: options.perPage }, (_, index) => (options.page - 1) *
|
|
1338
|
-
options.perPage +
|
|
1786
|
+
: `awk '${Array.from({ length: options.perPage }, (_, index) => (options.page - 1) * options.perPage +
|
|
1339
1787
|
index +
|
|
1340
1788
|
1)
|
|
1341
1789
|
.map((lineNumber) => `NR==${lineNumber}`)
|
|
@@ -1455,8 +1903,7 @@ export default class Inibase {
|
|
|
1455
1903
|
let countItems = 0;
|
|
1456
1904
|
const isDecodeID = globalConfig[this.databasePath].tables?.get(tableName)?.config
|
|
1457
1905
|
.decodeID === true &&
|
|
1458
|
-
!globalConfig[this.databasePath].tables?.get(tableName)?.config
|
|
1459
|
-
.prepend;
|
|
1906
|
+
!globalConfig[this.databasePath].tables?.get(tableName)?.config.prepend;
|
|
1460
1907
|
if (isDecodeID &&
|
|
1461
1908
|
this.idDensity.get(tableName) &&
|
|
1462
1909
|
Ids.every(Utils.isNumber)) {
|
|
@@ -1509,7 +1956,11 @@ export default class Inibase {
|
|
|
1509
1956
|
let cachedFilePath = "";
|
|
1510
1957
|
// Criteria
|
|
1511
1958
|
if (globalConfig[this.databasePath].tables?.get(tableName)?.config.cache) {
|
|
1512
|
-
|
|
1959
|
+
// Cache entries are versioned by the pagination row count so a
|
|
1960
|
+
// stale cache written before a post/delete (in this process or
|
|
1961
|
+
// another) is detectable: the candidate filename simply stops
|
|
1962
|
+
// matching and the cache is rebuilt.
|
|
1963
|
+
cachedFilePath = join(tablePath, ".cache", `${UtilsServer.hashString(inspect(where, { sorted: true }))}-${pagination[1]}${this.fileExtension}`);
|
|
1513
1964
|
if (await File.isExists(cachedFilePath)) {
|
|
1514
1965
|
const cachedItems = (await readFile(cachedFilePath, "utf8")).split(",");
|
|
1515
1966
|
if (!this.totalItems.has(`${tableName}-*`))
|
|
@@ -1573,33 +2024,37 @@ export default class Inibase {
|
|
|
1573
2024
|
? options.columns
|
|
1574
2025
|
: [options.columns]));
|
|
1575
2026
|
const tablePath = join(this.databasePath, tableName);
|
|
2027
|
+
if (!this.transaction)
|
|
2028
|
+
await this.ensureDatabaseRecovered();
|
|
1576
2029
|
await this.getTable(tableName);
|
|
1577
2030
|
if (!globalConfig[this.databasePath].tables?.get(tableName)?.schema)
|
|
1578
2031
|
throw this.createError("NO_SCHEMA", tableName);
|
|
1579
2032
|
if (!returnPostedData)
|
|
1580
2033
|
returnPostedData = false;
|
|
1581
2034
|
let clonedData = structuredClone(data);
|
|
1582
|
-
const keys = UtilsServer.hashString(Object.keys(Array.isArray(clonedData) ? clonedData[0] : clonedData).join("."));
|
|
1583
2035
|
await this.validateTableData(tableName, clonedData);
|
|
1584
2036
|
const renameList = [];
|
|
2037
|
+
let txnStaged = false;
|
|
1585
2038
|
try {
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
.
|
|
1592
|
-
|
|
1593
|
-
|
|
2039
|
+
// Inside a transaction the table lock is taken once per txn (and
|
|
2040
|
+
// held until commit/rollback); otherwise the usual writer lock.
|
|
2041
|
+
if (this.transaction)
|
|
2042
|
+
await this.ensureTxnLock(tableName);
|
|
2043
|
+
else
|
|
2044
|
+
await File.lock(join(tablePath, ".tmp"));
|
|
2045
|
+
const { filePath: paginationFilePath, lastId, total: _totalItems, } = await this.resolvePagination(tableName);
|
|
2046
|
+
let lastIdValue = lastId;
|
|
2047
|
+
if (!this.transaction)
|
|
2048
|
+
this.totalItems.set(`${tableName}-*`, _totalItems);
|
|
1594
2049
|
if (Utils.isArrayOfObjects(clonedData))
|
|
1595
2050
|
for (let index = 0; index < clonedData.length; index++) {
|
|
1596
2051
|
const element = clonedData[index];
|
|
1597
|
-
element.id = ++
|
|
2052
|
+
element.id = ++lastIdValue;
|
|
1598
2053
|
element.createdAt = Date.now();
|
|
1599
2054
|
element.updatedAt = undefined;
|
|
1600
2055
|
}
|
|
1601
2056
|
else {
|
|
1602
|
-
clonedData.id = ++
|
|
2057
|
+
clonedData.id = ++lastIdValue;
|
|
1603
2058
|
clonedData.createdAt = Date.now();
|
|
1604
2059
|
clonedData.updatedAt = undefined;
|
|
1605
2060
|
}
|
|
@@ -1613,15 +2068,34 @@ export default class Inibase {
|
|
|
1613
2068
|
.prepend
|
|
1614
2069
|
? await File.prepend(path, content)
|
|
1615
2070
|
: await File.append(path, content))));
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
2071
|
+
const newTotal = _totalItems + (Array.isArray(data) ? data.length : 1);
|
|
2072
|
+
const pagination = {
|
|
2073
|
+
from: paginationFilePath,
|
|
2074
|
+
to: join(tablePath, `${lastIdValue}-${newTotal}.pagination`),
|
|
2075
|
+
};
|
|
2076
|
+
if (this.transaction) {
|
|
2077
|
+
// Stage: journal the intent (fsynced op entry); the live files
|
|
2078
|
+
// only change when commit() publishes.
|
|
2079
|
+
await this.stageTxnOp(tableName, renameList, pagination);
|
|
2080
|
+
txnStaged = true;
|
|
2081
|
+
const stagedEntry = this.txnTableEntry(tableName);
|
|
2082
|
+
if (stagedEntry) {
|
|
2083
|
+
stagedEntry.lastId = lastIdValue;
|
|
2084
|
+
stagedEntry.total = newTotal;
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
else {
|
|
2088
|
+
// Crash-atomic commit: journal + backup swap + pagination rename.
|
|
2089
|
+
await this.commitFiles(tablePath, renameList, pagination);
|
|
2090
|
+
this.totalItems.set(`${tableName}-*`, newTotal);
|
|
2091
|
+
if (globalConfig[this.databasePath].tables?.get(tableName)?.config.cache)
|
|
2092
|
+
await this.clearCache(tableName);
|
|
2093
|
+
}
|
|
2094
|
+
if (returnPostedData) {
|
|
2095
|
+
if (this.transaction)
|
|
2096
|
+
// No read-your-writes yet: return the formatted staged rows
|
|
2097
|
+
// (ids + defaults) instead of a committed-state read.
|
|
2098
|
+
return (Array.isArray(clonedData) ? clonedData : clonedData);
|
|
1625
2099
|
return this.get(tableName, globalConfig[this.databasePath].tables?.get(tableName)?.config.prepend
|
|
1626
2100
|
? Array.isArray(clonedData)
|
|
1627
2101
|
? clonedData.map((_, index) => index + 1).toReversed()
|
|
@@ -1632,6 +2106,7 @@ export default class Inibase {
|
|
|
1632
2106
|
.toReversed()
|
|
1633
2107
|
: this.totalItems.get(`${tableName}-*`), options, !Utils.isArrayOfObjects(clonedData), // return only one item if data is not array of objects
|
|
1634
2108
|
undefined, true);
|
|
2109
|
+
}
|
|
1635
2110
|
return Array.isArray(clonedData)
|
|
1636
2111
|
? (globalConfig[this.databasePath].tables?.get(tableName)?.config
|
|
1637
2112
|
.prepend
|
|
@@ -1640,11 +2115,22 @@ export default class Inibase {
|
|
|
1640
2115
|
: UtilsServer.encodeID(clonedData.id);
|
|
1641
2116
|
}
|
|
1642
2117
|
finally {
|
|
1643
|
-
if (
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
2118
|
+
if (this.transaction) {
|
|
2119
|
+
// Staged temps belong to the journal op; commit()/rollback()
|
|
2120
|
+
// owns them. Temps from a failed pre-stage attempt are cleaned
|
|
2121
|
+
// here so nothing leaks.
|
|
2122
|
+
if (!txnStaged && renameList.length)
|
|
2123
|
+
await Promise.allSettled(renameList
|
|
2124
|
+
.filter((pair) => Boolean(pair[1]))
|
|
2125
|
+
.map(async ([tempPath, _]) => unlink(tempPath)));
|
|
2126
|
+
}
|
|
2127
|
+
else {
|
|
2128
|
+
if (renameList.length)
|
|
2129
|
+
await Promise.allSettled(renameList
|
|
2130
|
+
.filter((pair) => Boolean(pair[1]))
|
|
2131
|
+
.map(async ([tempPath, _]) => unlink(tempPath)));
|
|
2132
|
+
await File.unlock(join(tablePath, ".tmp"));
|
|
2133
|
+
}
|
|
1648
2134
|
}
|
|
1649
2135
|
}
|
|
1650
2136
|
async put(tableName, data, where, options = {
|
|
@@ -1652,12 +2138,15 @@ export default class Inibase {
|
|
|
1652
2138
|
perPage: 15,
|
|
1653
2139
|
}, returnUpdatedData, _whereIsLinesNumbers) {
|
|
1654
2140
|
const renameList = [];
|
|
2141
|
+
let txnStaged = false;
|
|
1655
2142
|
this.validateName(tableName);
|
|
1656
2143
|
if (options.columns)
|
|
1657
2144
|
this.validateColumns((Array.isArray(options.columns)
|
|
1658
2145
|
? options.columns
|
|
1659
2146
|
: [options.columns]));
|
|
1660
2147
|
const tablePath = join(this.databasePath, tableName);
|
|
2148
|
+
if (!this.transaction)
|
|
2149
|
+
await this.ensureDatabaseRecovered();
|
|
1661
2150
|
await this.throwErrorIfTableEmpty(tableName);
|
|
1662
2151
|
let clonedData = structuredClone(data);
|
|
1663
2152
|
if (!where) {
|
|
@@ -1680,26 +2169,48 @@ export default class Inibase {
|
|
|
1680
2169
|
updatedAt: Date.now(),
|
|
1681
2170
|
});
|
|
1682
2171
|
try {
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
await Promise.allSettled(Object.entries(pathesContents).map(async ([path, content]) => renameList.push(await File.replace(path, content,
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
2172
|
+
if (this.transaction)
|
|
2173
|
+
await this.ensureTxnLock(tableName);
|
|
2174
|
+
else
|
|
2175
|
+
await File.lock(join(tablePath, ".tmp"));
|
|
2176
|
+
const { total } = await this.resolvePagination(tableName);
|
|
2177
|
+
await Promise.allSettled(Object.entries(pathesContents).map(async ([path, content]) => renameList.push(await File.replace(path, content, total))));
|
|
2178
|
+
if (this.transaction) {
|
|
2179
|
+
// Stage instead of publishing: row count is unchanged so
|
|
2180
|
+
// there is no pagination rename to journal.
|
|
2181
|
+
await this.stageTxnOp(tableName, renameList, null);
|
|
2182
|
+
txnStaged = true;
|
|
2183
|
+
}
|
|
2184
|
+
else {
|
|
2185
|
+
// Crash-atomic commit (row count unchanged -> no pagination rename).
|
|
2186
|
+
await this.commitFiles(tablePath, renameList, null);
|
|
2187
|
+
if (globalConfig[this.databasePath].tables?.get(tableName)?.config.cache)
|
|
2188
|
+
await this.clearCache(tableName);
|
|
2189
|
+
}
|
|
2190
|
+
if (returnUpdatedData) {
|
|
2191
|
+
if (this.transaction)
|
|
2192
|
+
// Reading the committed state would miss the staged
|
|
2193
|
+
// write (no read-your-writes yet).
|
|
2194
|
+
throw this.createError("INVALID_PARAMETERS");
|
|
1695
2195
|
return await this.get(tableName, undefined, options);
|
|
2196
|
+
}
|
|
1696
2197
|
}
|
|
1697
2198
|
finally {
|
|
1698
|
-
if (
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
2199
|
+
if (this.transaction) {
|
|
2200
|
+
// Staged temps belong to the journal op; commit()/rollback()
|
|
2201
|
+
// owns them.
|
|
2202
|
+
if (!txnStaged && renameList.length)
|
|
2203
|
+
await Promise.allSettled(renameList
|
|
2204
|
+
.filter((pair) => Boolean(pair[1]))
|
|
2205
|
+
.map(async ([tempPath, _]) => unlink(tempPath)));
|
|
2206
|
+
}
|
|
2207
|
+
else {
|
|
2208
|
+
if (renameList.length)
|
|
2209
|
+
await Promise.allSettled(renameList
|
|
2210
|
+
.filter((pair) => Boolean(pair[1]))
|
|
2211
|
+
.map(async ([tempPath, _]) => unlink(tempPath)));
|
|
2212
|
+
await File.unlock(join(tablePath, ".tmp"));
|
|
2213
|
+
}
|
|
1703
2214
|
}
|
|
1704
2215
|
}
|
|
1705
2216
|
else if (((Array.isArray(where) && where.every(Utils.isNumber)) ||
|
|
@@ -1722,26 +2233,43 @@ export default class Inibase {
|
|
|
1722
2233
|
return obj;
|
|
1723
2234
|
}, {}),
|
|
1724
2235
|
]));
|
|
1725
|
-
const keys = UtilsServer.hashString(Object.keys(pathesContents)
|
|
1726
|
-
.map((path) => path.replaceAll(this.getFileExtension(tableName), ""))
|
|
1727
|
-
.join("."));
|
|
1728
2236
|
try {
|
|
1729
|
-
|
|
2237
|
+
// One global lock per table serializes every writer; inside a
|
|
2238
|
+
// transaction the lock is held for the whole txn.
|
|
2239
|
+
if (this.transaction)
|
|
2240
|
+
await this.ensureTxnLock(tableName);
|
|
2241
|
+
else
|
|
2242
|
+
await File.lock(join(tablePath, ".tmp"));
|
|
1730
2243
|
await Promise.allSettled(Object.entries(pathesContents).map(async ([path, content]) => renameList.push(await File.replace(path, content))));
|
|
1731
|
-
|
|
1732
|
-
.
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
2244
|
+
if (this.transaction) {
|
|
2245
|
+
await this.stageTxnOp(tableName, renameList, null);
|
|
2246
|
+
txnStaged = true;
|
|
2247
|
+
}
|
|
2248
|
+
else {
|
|
2249
|
+
await this.commitFiles(tablePath, renameList, null);
|
|
2250
|
+
if (globalConfig[this.databasePath].tables?.get(tableName)?.config.cache)
|
|
2251
|
+
await this.clearCache(tableName);
|
|
2252
|
+
}
|
|
2253
|
+
if (returnUpdatedData) {
|
|
2254
|
+
if (this.transaction)
|
|
2255
|
+
throw this.createError("INVALID_PARAMETERS");
|
|
1737
2256
|
return this.get(tableName, where, options, !Array.isArray(where), undefined, true);
|
|
2257
|
+
}
|
|
1738
2258
|
}
|
|
1739
2259
|
finally {
|
|
1740
|
-
if (
|
|
1741
|
-
|
|
1742
|
-
.
|
|
1743
|
-
|
|
1744
|
-
|
|
2260
|
+
if (this.transaction) {
|
|
2261
|
+
if (!txnStaged && renameList.length)
|
|
2262
|
+
await Promise.allSettled(renameList
|
|
2263
|
+
.filter((pair) => Boolean(pair[1]))
|
|
2264
|
+
.map(async ([tempPath, _]) => unlink(tempPath)));
|
|
2265
|
+
}
|
|
2266
|
+
else {
|
|
2267
|
+
if (renameList.length)
|
|
2268
|
+
await Promise.allSettled(renameList
|
|
2269
|
+
.filter((pair) => Boolean(pair[1]))
|
|
2270
|
+
.map(async ([tempPath, _]) => unlink(tempPath)));
|
|
2271
|
+
await File.unlock(join(tablePath, ".tmp"));
|
|
2272
|
+
}
|
|
1745
2273
|
}
|
|
1746
2274
|
}
|
|
1747
2275
|
else if ((!_whereIsLinesNumbers &&
|
|
@@ -1779,37 +2307,63 @@ export default class Inibase {
|
|
|
1779
2307
|
*/
|
|
1780
2308
|
async delete(tableName, where, _whereIsLinesNumbers, _cascadeGuard) {
|
|
1781
2309
|
this.validateName(tableName);
|
|
2310
|
+
if (!this.transaction)
|
|
2311
|
+
await this.ensureDatabaseRecovered();
|
|
1782
2312
|
const tablePath = join(this.databasePath, tableName);
|
|
1783
2313
|
await this.throwErrorIfTableEmpty(tableName);
|
|
1784
2314
|
if (!where) {
|
|
2315
|
+
let txnStaged = false;
|
|
2316
|
+
// Crash-atomic truncate: park every column file (pure removal
|
|
2317
|
+
// ops) and publish the empty row count in one journaled commit.
|
|
2318
|
+
const renameList = [];
|
|
1785
2319
|
try {
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
2320
|
+
if (this.transaction)
|
|
2321
|
+
await this.ensureTxnLock(tableName);
|
|
2322
|
+
else
|
|
2323
|
+
await File.lock(join(tablePath, ".tmp"));
|
|
2324
|
+
const files = (await readdir(tablePath)) ?? [];
|
|
2325
|
+
renameList.push(...files
|
|
2326
|
+
.filter((fileName) => fileName.endsWith(this.getFileExtension(tableName)))
|
|
2327
|
+
.map((file) => [null, join(tablePath, file)]));
|
|
2328
|
+
const { filePath: paginationFilePath, lastId, total, } = await this.resolvePagination(tableName);
|
|
2329
|
+
const pagination = {
|
|
2330
|
+
from: paginationFilePath,
|
|
2331
|
+
to: join(tablePath, `${lastId}-0.pagination`),
|
|
2332
|
+
};
|
|
2333
|
+
if (this.transaction) {
|
|
2334
|
+
await this.stageTxnOp(tableName, renameList, pagination);
|
|
2335
|
+
txnStaged = true;
|
|
2336
|
+
const stagedEntry = this.txnTableEntry(tableName);
|
|
2337
|
+
if (stagedEntry)
|
|
2338
|
+
stagedEntry.total = 0;
|
|
2339
|
+
}
|
|
2340
|
+
else {
|
|
2341
|
+
await this.commitFiles(tablePath, renameList, pagination);
|
|
2342
|
+
if (globalConfig[this.databasePath].tables?.get(tableName)?.config.cache)
|
|
2343
|
+
await this.clearCache(tableName);
|
|
1796
2344
|
}
|
|
1797
|
-
await Promise.allSettled((await readdir(tablePath))
|
|
1798
|
-
?.filter((fileName) => fileName.endsWith(this.getFileExtension(tableName)))
|
|
1799
|
-
.map(async (file) => unlink(join(tablePath, file))));
|
|
1800
|
-
if (globalConfig[this.databasePath].tables?.get(tableName)?.config.cache)
|
|
1801
|
-
await this.clearCache(tableName);
|
|
1802
|
-
await rename(paginationFilePath, join(tablePath, `${pagination[0]}-0.pagination`));
|
|
1803
2345
|
this.idDensity.set(tableName, true);
|
|
1804
2346
|
// Deleting every row must also delete rows that reference them.
|
|
1805
|
-
if (
|
|
1806
|
-
const allLines = Array.from({ length:
|
|
2347
|
+
if (total) {
|
|
2348
|
+
const allLines = Array.from({ length: total }, (_, i) => i + 1);
|
|
1807
2349
|
await this.cascadeDelete(tableName, allLines, new Set());
|
|
1808
2350
|
}
|
|
1809
2351
|
return true;
|
|
1810
2352
|
}
|
|
1811
2353
|
finally {
|
|
1812
|
-
|
|
2354
|
+
if (this.transaction) {
|
|
2355
|
+
if (!txnStaged && renameList.length)
|
|
2356
|
+
await Promise.allSettled(renameList
|
|
2357
|
+
.filter((pair) => Boolean(pair[1]))
|
|
2358
|
+
.map(async ([tempPath, _]) => unlink(tempPath)));
|
|
2359
|
+
}
|
|
2360
|
+
else {
|
|
2361
|
+
if (renameList.length)
|
|
2362
|
+
await Promise.allSettled(renameList
|
|
2363
|
+
.filter((pair) => Boolean(pair[1]))
|
|
2364
|
+
.map(async ([tempPath, _]) => unlink(tempPath)));
|
|
2365
|
+
await File.unlock(join(tablePath, ".tmp"));
|
|
2366
|
+
}
|
|
1813
2367
|
}
|
|
1814
2368
|
}
|
|
1815
2369
|
if (((Array.isArray(where) && where.every(Utils.isNumber)) ||
|
|
@@ -1821,46 +2375,77 @@ export default class Inibase {
|
|
|
1821
2375
|
const files = (await readdir(tablePath))?.filter((fileName) => fileName.endsWith(this.getFileExtension(tableName)));
|
|
1822
2376
|
if (files.length) {
|
|
1823
2377
|
const renameList = [];
|
|
2378
|
+
let txnStaged = false;
|
|
1824
2379
|
try {
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
pagination = parse(paginationFileName)
|
|
1833
|
-
.name.split("-")
|
|
1834
|
-
.map(Number);
|
|
1835
|
-
}
|
|
1836
|
-
if (pagination[1] &&
|
|
1837
|
-
pagination[1] - (Array.isArray(where) ? where.length : 1) > 0) {
|
|
2380
|
+
if (this.transaction)
|
|
2381
|
+
await this.ensureTxnLock(tableName);
|
|
2382
|
+
else
|
|
2383
|
+
await File.lock(join(tablePath, ".tmp"));
|
|
2384
|
+
const { filePath: paginationFilePath, lastId, total, } = await this.resolvePagination(tableName);
|
|
2385
|
+
const remaining = total - (Array.isArray(where) ? where.length : 1);
|
|
2386
|
+
if (total && remaining > 0) {
|
|
1838
2387
|
this.idDensity.set(tableName, false);
|
|
1839
2388
|
await Promise.allSettled(files.map(async (file) => renameList.push(await File.remove(join(tablePath, file), where))));
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
2389
|
+
const pagination = {
|
|
2390
|
+
from: paginationFilePath,
|
|
2391
|
+
to: join(tablePath, `${lastId}-${remaining}.pagination`),
|
|
2392
|
+
};
|
|
2393
|
+
if (this.transaction) {
|
|
2394
|
+
await this.stageTxnOp(tableName, renameList, pagination);
|
|
2395
|
+
txnStaged = true;
|
|
2396
|
+
const stagedEntry = this.txnTableEntry(tableName);
|
|
2397
|
+
if (stagedEntry)
|
|
2398
|
+
stagedEntry.total = remaining;
|
|
2399
|
+
}
|
|
2400
|
+
else {
|
|
2401
|
+
await this.commitFiles(tablePath, renameList, pagination);
|
|
2402
|
+
}
|
|
1843
2403
|
}
|
|
1844
2404
|
else {
|
|
1845
2405
|
this.idDensity.set(tableName, true);
|
|
1846
|
-
|
|
2406
|
+
// Deleting every remaining row: pure removals.
|
|
2407
|
+
const truncateList = (await readdir(tablePath))
|
|
1847
2408
|
?.filter((fileName) => fileName.endsWith(this.getFileExtension(tableName)))
|
|
1848
|
-
.map(
|
|
2409
|
+
.map((file) => [null, join(tablePath, file)]);
|
|
2410
|
+
const pagination = {
|
|
2411
|
+
from: paginationFilePath,
|
|
2412
|
+
to: join(tablePath, `${lastId}-0.pagination`),
|
|
2413
|
+
};
|
|
2414
|
+
if (this.transaction) {
|
|
2415
|
+
await this.stageTxnOp(tableName, truncateList, pagination);
|
|
2416
|
+
txnStaged = true;
|
|
2417
|
+
const stagedEntry = this.txnTableEntry(tableName);
|
|
2418
|
+
if (stagedEntry)
|
|
2419
|
+
stagedEntry.total = 0;
|
|
2420
|
+
}
|
|
2421
|
+
else {
|
|
2422
|
+
await this.commitFiles(tablePath, truncateList, pagination);
|
|
2423
|
+
}
|
|
1849
2424
|
}
|
|
1850
|
-
|
|
2425
|
+
// Cache still describes the committed state while a
|
|
2426
|
+
// transaction is open, so only clear it outside one.
|
|
2427
|
+
if (!this.transaction &&
|
|
2428
|
+
globalConfig[this.databasePath].tables?.get(tableName)?.config.cache)
|
|
1851
2429
|
await this.clearCache(tableName);
|
|
1852
|
-
await rename(paginationFilePath, join(tablePath, `${pagination[0]}-${pagination[1] - (Array.isArray(where) ? where.length : 1)}.pagination`));
|
|
1853
2430
|
// Cascade: rows in other tables referencing the deleted rows
|
|
1854
2431
|
// (via `table`-typed fields) are removed too.
|
|
1855
2432
|
await this.cascadeDelete(tableName, Array.isArray(where) ? where : [where], _cascadeGuard ?? new Set());
|
|
1856
2433
|
return true;
|
|
1857
2434
|
}
|
|
1858
2435
|
finally {
|
|
1859
|
-
if (
|
|
1860
|
-
|
|
1861
|
-
.
|
|
1862
|
-
|
|
1863
|
-
|
|
2436
|
+
if (this.transaction) {
|
|
2437
|
+
if (!txnStaged && renameList.length)
|
|
2438
|
+
await Promise.allSettled(renameList
|
|
2439
|
+
.filter((pair) => Boolean(pair[1]))
|
|
2440
|
+
.map(async ([tempPath, _]) => unlink(tempPath)));
|
|
2441
|
+
}
|
|
2442
|
+
else {
|
|
2443
|
+
if (renameList.length)
|
|
2444
|
+
await Promise.allSettled(renameList
|
|
2445
|
+
.filter((pair) => Boolean(pair[1]))
|
|
2446
|
+
.map(async ([tempPath, _]) => unlink(tempPath)));
|
|
2447
|
+
await File.unlock(join(tablePath, ".tmp"));
|
|
2448
|
+
}
|
|
1864
2449
|
}
|
|
1865
2450
|
}
|
|
1866
2451
|
}
|
|
@@ -1930,8 +2515,12 @@ export default class Inibase {
|
|
|
1930
2515
|
for (const l of found)
|
|
1931
2516
|
matching.add(l);
|
|
1932
2517
|
}
|
|
1933
|
-
catch {
|
|
2518
|
+
catch (error) {
|
|
1934
2519
|
// Unreadable/unsupported column -> skip this reference.
|
|
2520
|
+
// Inside a transaction a broken reference must abort the
|
|
2521
|
+
// whole cascade (all-or-nothing).
|
|
2522
|
+
if (this.transaction)
|
|
2523
|
+
throw error;
|
|
1935
2524
|
}
|
|
1936
2525
|
}
|
|
1937
2526
|
const toDelete = [...matching].filter((line) => {
|
|
@@ -1946,8 +2535,12 @@ export default class Inibase {
|
|
|
1946
2535
|
try {
|
|
1947
2536
|
await this.delete(candidateName, toDelete, true, guard);
|
|
1948
2537
|
}
|
|
1949
|
-
catch {
|
|
1950
|
-
// Cascade is best-effort: never break
|
|
2538
|
+
catch (error) {
|
|
2539
|
+
// Cascade is best-effort outside a transaction: never break
|
|
2540
|
+
// the parent delete. Inside a transaction a cascade failure
|
|
2541
|
+
// must abort the whole txn (all-or-nothing).
|
|
2542
|
+
if (this.transaction)
|
|
2543
|
+
throw error;
|
|
1951
2544
|
}
|
|
1952
2545
|
}
|
|
1953
2546
|
}
|
|
@@ -1976,6 +2569,30 @@ export default class Inibase {
|
|
|
1976
2569
|
}
|
|
1977
2570
|
return columns.length > 1 ? RETURN : Object.values(RETURN)[0];
|
|
1978
2571
|
}
|
|
2572
|
+
async avg(tableName, columns, where) {
|
|
2573
|
+
this.validateName(tableName);
|
|
2574
|
+
if (!Array.isArray(columns))
|
|
2575
|
+
columns = [columns];
|
|
2576
|
+
for (const column of columns)
|
|
2577
|
+
this.validateName(column);
|
|
2578
|
+
await this.throwErrorIfTableEmpty(tableName);
|
|
2579
|
+
const RETURN = {};
|
|
2580
|
+
const tablePath = join(this.databasePath, tableName);
|
|
2581
|
+
for await (const column of columns) {
|
|
2582
|
+
const columnPath = join(tablePath, `${column}${this.getFileExtension(tableName)}`);
|
|
2583
|
+
if (await File.isExists(columnPath)) {
|
|
2584
|
+
if (where) {
|
|
2585
|
+
const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
|
|
2586
|
+
RETURN[column] = lineNumbers
|
|
2587
|
+
? await File.avg(columnPath, lineNumbers)
|
|
2588
|
+
: 0;
|
|
2589
|
+
}
|
|
2590
|
+
else
|
|
2591
|
+
RETURN[column] = await File.avg(columnPath);
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
return columns.length > 1 ? RETURN : Object.values(RETURN)[0];
|
|
2595
|
+
}
|
|
1979
2596
|
async max(tableName, columns, where) {
|
|
1980
2597
|
this.validateName(tableName);
|
|
1981
2598
|
if (!Array.isArray(columns))
|