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/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 writeFile(join(tablePath, ".compression.config"), "");
161
+ await File.write(join(tablePath, ".compression.config"), "");
149
162
  if (config.cache)
150
- await writeFile(join(tablePath, ".cache.config"), "");
163
+ await File.write(join(tablePath, ".cache.config"), "");
151
164
  if (config.prepend)
152
- await writeFile(join(tablePath, ".prepend.config"), "");
165
+ await File.write(join(tablePath, ".prepend.config"), "");
153
166
  if (config.decodeID)
154
- await writeFile(join(tablePath, ".decodeID.config"), "");
167
+ await File.write(join(tablePath, ".decodeID.config"), "");
155
168
  }
156
169
  if (schema) {
157
170
  const lastSchemaID = { value: 0 };
158
- await writeFile(join(tablePath, `schema.${this.schemaFileExtension}`), this.schemaFileExtension === "json"
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 writeFile(join(tablePath, `${lastSchemaID.value}.schema`), "");
174
+ await File.write(join(tablePath, `${lastSchemaID.value}.schema`), "");
162
175
  }
163
176
  else
164
- await writeFile(join(tablePath, "0.schema"), "");
165
- await writeFile(join(tablePath, "0-0.pagination"), "");
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
- await writeFile(filePath, updatedContent, "utf8");
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
- await writeFile(join(tablePath, `schema.${this.schemaFileExtension}`), this.schemaFileExtension === "json"
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 writeFile(join(tablePath, `${lastSchemaID.value}.schema`), "");
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
- await UtilsServer.execFile("find", [
246
- tableName,
247
- "-type",
248
- "f",
249
- "-name",
250
- `*${this.fileExtension}${config.compression ? "" : ".gz"}`,
251
- "-exec",
252
- config.compression ? "gzip" : "gunzip",
253
- "-f",
254
- "{}",
255
- "+",
256
- ], { cwd: this.databasePath });
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 writeFile(join(tablePath, ".compression.config"), "");
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 writeFile(join(tablePath, ".cache.config"), "");
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 writeFile(join(tablePath, ".decodeID.config"), "");
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" && mv "$file.reversed" "$file"`
290
- : `${process.platform === "darwin" ? "tail -r" : "tac"} "$file" > "$file.reversed" && mv "$file.reversed" "$file"`}; done`,
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 writeFile(join(tablePath, ".prepend.config"), "");
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
- await writeFile(join(tablePath, `schema.${this.schemaFileExtension}`), this.schemaFileExtension === "json"
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
- cachedFilePath = join(tablePath, ".cache", `${UtilsServer.hashString(inspect(where, { sorted: true }))}${this.fileExtension}`);
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
- await File.lock(join(tablePath, ".tmp"), keys);
1587
- let paginationFilePath = "";
1588
- for await (const fileName of glob("*.pagination", { cwd: tablePath }))
1589
- paginationFilePath = join(tablePath, fileName);
1590
- let [lastId, _totalItems] = parse(paginationFilePath)
1591
- .name.split("-")
1592
- .map(Number);
1593
- this.totalItems.set(`${tableName}-*`, _totalItems);
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 = ++lastId;
2052
+ element.id = ++lastIdValue;
1598
2053
  element.createdAt = Date.now();
1599
2054
  element.updatedAt = undefined;
1600
2055
  }
1601
2056
  else {
1602
- clonedData.id = ++lastId;
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
- await Promise.allSettled(renameList
1617
- .filter((pair) => Boolean(pair[1]))
1618
- .map(async ([tempPath, filePath]) => rename(tempPath, filePath)));
1619
- if (globalConfig[this.databasePath].tables?.get(tableName)?.config.cache)
1620
- await this.clearCache(tableName);
1621
- const currentValue = this.totalItems.get(`${tableName}-*`) || 0;
1622
- this.totalItems.set(`${tableName}-*`, currentValue + (Array.isArray(data) ? data.length : 1));
1623
- await rename(paginationFilePath, join(tablePath, `${lastId}-${this.totalItems.get(`${tableName}-*`)}.pagination`));
1624
- if (returnPostedData)
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 (renameList.length)
1644
- await Promise.allSettled(renameList
1645
- .filter((pair) => Boolean(pair[1]))
1646
- .map(async ([tempPath, _]) => unlink(tempPath)));
1647
- await File.unlock(join(tablePath, ".tmp"), keys);
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
- await File.lock(join(tablePath, ".tmp"));
1684
- for await (const paginationFileName of glob("*.pagination", {
1685
- cwd: tablePath,
1686
- }))
1687
- this.totalItems.set(`${tableName}-*`, parse(paginationFileName).name.split("-").map(Number)[1]);
1688
- await Promise.allSettled(Object.entries(pathesContents).map(async ([path, content]) => renameList.push(await File.replace(path, content, this.totalItems.get(`${tableName}-*`)))));
1689
- await Promise.allSettled(renameList
1690
- .filter((pair) => Boolean(pair[1]))
1691
- .map(async ([tempPath, filePath]) => rename(tempPath, filePath)));
1692
- if (globalConfig[this.databasePath].tables?.get(tableName)?.config.cache)
1693
- await this.clearCache(join(tablePath, ".cache"));
1694
- if (returnUpdatedData)
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 (renameList.length)
1699
- await Promise.allSettled(renameList
1700
- .filter((pair) => Boolean(pair[1]))
1701
- .map(async ([tempPath, _]) => unlink(tempPath)));
1702
- await File.unlock(join(tablePath, ".tmp"));
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
- await File.lock(join(tablePath, ".tmp"), keys);
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
- await Promise.allSettled(renameList
1732
- .filter((pair) => Boolean(pair[1]))
1733
- .map(async ([tempPath, filePath]) => rename(tempPath, filePath)));
1734
- if (globalConfig[this.databasePath].tables?.get(tableName)?.config.cache)
1735
- await this.clearCache(tableName);
1736
- if (returnUpdatedData)
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 (renameList.length)
1741
- await Promise.allSettled(renameList
1742
- .filter((pair) => Boolean(pair[1]))
1743
- .map(async ([tempPath, _]) => unlink(tempPath)));
1744
- await File.unlock(join(tablePath, ".tmp"), keys);
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
- await File.lock(join(tablePath, ".tmp"));
1787
- let paginationFilePath = "";
1788
- let pagination = [0, 0];
1789
- for await (const paginationFileName of glob("*.pagination", {
1790
- cwd: tablePath,
1791
- })) {
1792
- paginationFilePath = join(tablePath, paginationFileName);
1793
- pagination = parse(paginationFileName)
1794
- .name.split("-")
1795
- .map(Number);
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 (pagination[1]) {
1806
- const allLines = Array.from({ length: pagination[1] }, (_, i) => i + 1);
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
- await File.unlock(join(tablePath, ".tmp"));
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
- await File.lock(join(tablePath, ".tmp"));
1826
- let paginationFilePath = "";
1827
- let pagination = [0, 0];
1828
- for await (const paginationFileName of glob("*.pagination", {
1829
- cwd: tablePath,
1830
- })) {
1831
- paginationFilePath = join(tablePath, paginationFileName);
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
- await Promise.allSettled(renameList
1841
- .filter((pair) => Boolean(pair[1]))
1842
- .map(async ([tempPath, filePath]) => rename(tempPath, filePath)));
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
- await Promise.allSettled((await readdir(tablePath))
2406
+ // Deleting every remaining row: pure removals.
2407
+ const truncateList = (await readdir(tablePath))
1847
2408
  ?.filter((fileName) => fileName.endsWith(this.getFileExtension(tableName)))
1848
- .map(async (file) => unlink(join(tablePath, file))));
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
- if (globalConfig[this.databasePath].tables?.get(tableName)?.config.cache)
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 (renameList.length)
1860
- await Promise.allSettled(renameList
1861
- .filter((pair) => Boolean(pair[1]))
1862
- .map(async ([tempPath, _]) => unlink(tempPath)));
1863
- await File.unlock(join(tablePath, ".tmp"));
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 the parent delete.
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))