inibase 3.5.0 → 3.7.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/file.d.ts CHANGED
@@ -88,6 +88,20 @@ export declare function get(filePath: string, lineNumbers: undefined | number |
88
88
  Record<number, string | number | boolean | null | undefined | (string | number | boolean | (string | number | boolean)[] | null)[]> | null,
89
89
  number
90
90
  ]>;
91
+ /**
92
+ * Reads only the requested line numbers of one column file and decodes each
93
+ * with `field`. Unlike the shell `sed -n` reader this stops as soon as the last
94
+ * requested line has been seen, so a point lookup never scans the column to
95
+ * EOF, and it decodes each column independently (no tab-delimited `paste`
96
+ * alignment to break on values that contain a literal tab). Compression is
97
+ * handled transparently: `.gz` columns are gunzipped on the fly.
98
+ *
99
+ * Every resource (readline, gunzip/source stream, file handle) is released on
100
+ * both the normal and the early-exit path.
101
+ */
102
+ export declare const getLines: (filePath: string, lineNumbers: number[], field?: Field & {
103
+ databasePath?: string;
104
+ }) => Promise<Record<number, ReturnType<typeof decode>>>;
91
105
  /**
92
106
  * Asynchronously replaces specific lines in a file based on the provided replacements map or string.
93
107
  *
@@ -146,7 +160,7 @@ export declare const remove: (filePath: string, linesToDelete: number | number[]
146
160
  */
147
161
  export declare const search: (filePath: string, operator: ComparisonOperator | ComparisonOperator[], comparedAtValue: string | number | boolean | null | (string | number | boolean | null)[], logicalOperator?: "and" | "or", searchIn?: Set<number>, field?: Field & {
148
162
  databasePath?: string;
149
- }, limit?: number, offset?: number, readWholeFile?: boolean) => Promise<[Record<number, string | number | boolean | null | undefined | (string | number | boolean | null)[]> | null, number, Set<number> | null]>;
163
+ }, limit?: number, offset?: number, readWholeFile?: boolean, earlyStop?: boolean) => Promise<[Record<number, string | number | boolean | null | undefined | (string | number | boolean | null)[]> | null, number, Set<number> | null]>;
150
164
  export declare const sum: (fp: string, ln?: number | number[]) => Promise<number>;
151
165
  export declare const avg: (fp: string, ln?: number | number[]) => Promise<number>;
152
166
  export declare const min: (fp: string, ln?: number | number[]) => Promise<number>;
package/dist/file.js CHANGED
@@ -343,6 +343,16 @@ export function escapeShellPath(filePath) {
343
343
  */
344
344
  let tempSeq = 0;
345
345
  const tempPathFor = (filePath) => filePath.replace(/([^/]+)\/?$/, `.tmp/$1.${process.pid}-${(tempSeq++).toString(36)}`);
346
+ /**
347
+ * Cleanup-and-rethrow for a failed column write. A partial temp file must never
348
+ * be left behind, and the caller must observe the failure so it can abort the
349
+ * operation instead of publishing a column that is missing a line — which would
350
+ * desync the pagination row count from the column files.
351
+ */
352
+ const failWrite = async (fileTempPath, error) => {
353
+ await unlink(fileTempPath).catch(() => { });
354
+ throw error;
355
+ };
346
356
  const _pipeline = async (filePath, rl, writeStream, transform) => {
347
357
  if (filePath.endsWith(".gz"))
348
358
  await pipeline(rl, transform, createGzip(), writeStream);
@@ -361,6 +371,30 @@ const createReadLineInternface = (filePath, fileHandle) => createInterface({
361
371
  : fileHandle.createReadStream(),
362
372
  crlfDelay: Number.POSITIVE_INFINITY,
363
373
  });
374
+ /**
375
+ * Like {@link createReadLineInternface} but also hands back a disposer that
376
+ * tears down the readline interface and both the source and (for `.gz`
377
+ * columns) gunzip streams. Sparse readers call the disposer in their `finally`
378
+ * so an early `break` — the whole point of a point lookup — cannot leave a read
379
+ * stream (and its libuv handle) dangling, which would keep the process alive.
380
+ */
381
+ const openReadline = (filePath, fileHandle) => {
382
+ const source = fileHandle.createReadStream();
383
+ const input = filePath.endsWith(".gz") ? source.pipe(createGunzip()) : source;
384
+ const rl = createInterface({
385
+ input,
386
+ crlfDelay: Number.POSITIVE_INFINITY,
387
+ });
388
+ return {
389
+ rl,
390
+ destroy: () => {
391
+ rl.close();
392
+ input.destroy();
393
+ if (input !== source)
394
+ source.destroy();
395
+ },
396
+ };
397
+ };
364
398
  /**
365
399
  * Checks if a file or directory exists at the specified path.
366
400
  *
@@ -619,6 +653,45 @@ export async function get(filePath, lineNumbers, field, readWholeFile = false) {
619
653
  await fileHandle?.close();
620
654
  }
621
655
  }
656
+ /**
657
+ * Reads only the requested line numbers of one column file and decodes each
658
+ * with `field`. Unlike the shell `sed -n` reader this stops as soon as the last
659
+ * requested line has been seen, so a point lookup never scans the column to
660
+ * EOF, and it decodes each column independently (no tab-delimited `paste`
661
+ * alignment to break on values that contain a literal tab). Compression is
662
+ * handled transparently: `.gz` columns are gunzipped on the fly.
663
+ *
664
+ * Every resource (readline, gunzip/source stream, file handle) is released on
665
+ * both the normal and the early-exit path.
666
+ */
667
+ export const getLines = async (filePath, lineNumbers, field) => {
668
+ const lines = {};
669
+ if (!lineNumbers.length)
670
+ return lines;
671
+ const wanted = new Set(lineNumbers);
672
+ const last = Math.max(...lineNumbers);
673
+ const config = field ?? {
674
+ key: "BLABLA",
675
+ type: "string",
676
+ };
677
+ const fileHandle = await open(filePath, "r");
678
+ const { rl, destroy } = openReadline(filePath, fileHandle);
679
+ try {
680
+ let linesCount = 0;
681
+ for await (const line of rl) {
682
+ linesCount++;
683
+ if (wanted.has(linesCount))
684
+ lines[linesCount] = decode(line, config);
685
+ if (linesCount >= last)
686
+ break;
687
+ }
688
+ return lines;
689
+ }
690
+ finally {
691
+ destroy();
692
+ await fileHandle.close().catch(() => { });
693
+ }
694
+ };
622
695
  /**
623
696
  * Asynchronously replaces specific lines in a file based on the provided replacements map or string.
624
697
  *
@@ -675,8 +748,8 @@ export const replace = async (filePath, replacements, totalItems) => {
675
748
  }));
676
749
  return [fileTempPath, filePath];
677
750
  }
678
- catch {
679
- return [fileTempPath, null];
751
+ catch (error) {
752
+ return failWrite(fileTempPath, error);
680
753
  }
681
754
  finally {
682
755
  // Ensure that file handles are closed, even if an error occurred
@@ -695,8 +768,8 @@ export const replace = async (filePath, replacements, totalItems) => {
695
768
  await exec(command);
696
769
  return [fileTempPath, filePath];
697
770
  }
698
- catch {
699
- return [fileTempPath, null];
771
+ catch (error) {
772
+ return failWrite(fileTempPath, error);
700
773
  }
701
774
  }
702
775
  }
@@ -736,8 +809,8 @@ export const replace = async (filePath, replacements, totalItems) => {
736
809
  }
737
810
  return [fileTempPath, filePath];
738
811
  }
739
- catch {
740
- return [fileTempPath, null];
812
+ catch (error) {
813
+ return failWrite(fileTempPath, error);
741
814
  }
742
815
  }
743
816
  return [];
@@ -759,10 +832,12 @@ export const append = async (filePath, data) => {
759
832
  await appendFile(fileTempPath, `${Array.isArray(data) ? data.join("\n") : data}\n`);
760
833
  }
761
834
  else {
762
- const escapedFileTempPath = escapeShellPath(fileTempPath);
763
- await exec(`echo '${(Array.isArray(data) ? data.join("\n") : data)
764
- .toString()
765
- .replace(/'/g, "\\'")}' | gzip - >> ${escapedFileTempPath}`);
835
+ // Append a complete gzip member. Readers gunzip every member in
836
+ // sequence (multi-member stream), and this avoids the shell's
837
+ // argument-length limits and single-quote escaping the old
838
+ // `echo … | gzip - >> file` pipeline suffered from.
839
+ const text = `${Array.isArray(data) ? data.join("\n") : data}\n`;
840
+ await appendFile(fileTempPath, await gzip(text));
766
841
  }
767
842
  }
768
843
  else
@@ -775,8 +850,8 @@ export const append = async (filePath, data) => {
775
850
  : `${Array.isArray(data) ? data.join("\n") : data}\n`);
776
851
  return [fileTempPath, filePath];
777
852
  }
778
- catch {
779
- return [fileTempPath, null];
853
+ catch (error) {
854
+ return failWrite(fileTempPath, error);
780
855
  }
781
856
  };
782
857
  /**
@@ -808,8 +883,8 @@ export const prepend = async (filePath, data) => {
808
883
  },
809
884
  }));
810
885
  }
811
- catch {
812
- return [fileTempPath, null];
886
+ catch (error) {
887
+ return failWrite(fileTempPath, error);
813
888
  }
814
889
  finally {
815
890
  // Ensure that file handles are closed, even if an error occurred
@@ -826,8 +901,8 @@ export const prepend = async (filePath, data) => {
826
901
  const escapedFileChildTempPath = escapeShellPath(fileChildTempPath);
827
902
  await exec(`cat ${escapedFileChildTempPath} ${escapedFilePath} > ${escapedFileTempPath}`);
828
903
  }
829
- catch {
830
- return [fileTempPath, null];
904
+ catch (error) {
905
+ return failWrite(fileTempPath, error);
831
906
  }
832
907
  finally {
833
908
  await unlink(fileChildTempPath);
@@ -843,8 +918,8 @@ export const prepend = async (filePath, data) => {
843
918
  ? await gzip(`${Array.isArray(data) ? data.join("\n") : data}\n`)
844
919
  : `${Array.isArray(data) ? data.join("\n") : data}\n`);
845
920
  }
846
- catch {
847
- return [fileTempPath, null];
921
+ catch (error) {
922
+ return failWrite(fileTempPath, error);
848
923
  }
849
924
  }
850
925
  return [fileTempPath, filePath];
@@ -874,8 +949,8 @@ export const remove = async (filePath, linesToDelete) => {
874
949
  await exec(command);
875
950
  return [fileTempPath, filePath];
876
951
  }
877
- catch {
878
- return [fileTempPath, null];
952
+ catch (error) {
953
+ return failWrite(fileTempPath, error);
879
954
  }
880
955
  };
881
956
  /**
@@ -1094,7 +1169,7 @@ async function searchEqualsNative(filePath, patterns, field, searchIn, comparedA
1094
1169
  *
1095
1170
  * Note: Decodes each line for comparison and can handle complex queries with multiple conditions.
1096
1171
  */
1097
- export const search = async (filePath, operator, comparedAtValue, logicalOperator, searchIn, field, limit, offset, readWholeFile) => {
1172
+ export const search = async (filePath, operator, comparedAtValue, logicalOperator, searchIn, field, limit, offset, readWholeFile, earlyStop) => {
1098
1173
  // Native fast path for exact-equality searches (whole-line `grep -x -F`).
1099
1174
  if (operator === "=" &&
1100
1175
  !Array.isArray(operator) &&
@@ -1116,6 +1191,7 @@ export const search = async (filePath, operator, comparedAtValue, logicalOperato
1116
1191
  let linesCount = 0;
1117
1192
  const linesNumbers = new Set();
1118
1193
  let fileHandle = null;
1194
+ let destroyStreams = () => { };
1119
1195
  const config = field ?? {
1120
1196
  key: "BLABLA",
1121
1197
  type: "string",
@@ -1131,7 +1207,8 @@ export const search = async (filePath, operator, comparedAtValue, logicalOperato
1131
1207
  // Open the file for reading.
1132
1208
  fileHandle = await open(filePath, "r");
1133
1209
  // Create a Readline interface to read the file line by line.
1134
- const rl = createReadLineInternface(filePath, fileHandle);
1210
+ const { rl, destroy } = openReadline(filePath, fileHandle);
1211
+ destroyStreams = destroy;
1135
1212
  // Iterate through each line in the file.
1136
1213
  for await (const line of rl) {
1137
1214
  // Increment the line count for each line.
@@ -1162,6 +1239,10 @@ export const search = async (filePath, operator, comparedAtValue, logicalOperato
1162
1239
  }
1163
1240
  // Store the decoded line in the result object.
1164
1241
  matchingLines[linesCount] = decodedLine;
1242
+ // Every requested match is now in hand: stop instead of
1243
+ // scanning the rest of the column (id -> line resolution).
1244
+ if (earlyStop && limit && linesNumbers.size >= limit)
1245
+ break;
1165
1246
  }
1166
1247
  }
1167
1248
  // Convert the Map to an object using Object.fromEntries and return the result.
@@ -1173,7 +1254,9 @@ export const search = async (filePath, operator, comparedAtValue, logicalOperato
1173
1254
  return [null, 0, null];
1174
1255
  }
1175
1256
  finally {
1176
- // Close the file handle in the finally block to ensure it is closed even if an error occurs.
1257
+ // Tear down the read streams (an early `break` must not leave a handle
1258
+ // open) and then the file handle, even if an error occurred.
1259
+ destroyStreams();
1177
1260
  await fileHandle?.close();
1178
1261
  }
1179
1262
  };
package/dist/index.d.ts CHANGED
@@ -130,7 +130,7 @@ export default class Inibase {
130
130
  * per-table writer lock of every table it mutates, so mutations stage into
131
131
  * the database journal and publish only on commit().
132
132
  */
133
- private transaction;
133
+ private activeTransaction;
134
134
  constructor(database: string, mainFolder?: string, language?: ErrorLang);
135
135
  createError(name: ErrorCode, variable?: string | number | (string | number)[]): Error;
136
136
  private validateName;
@@ -252,14 +252,6 @@ export default class Inibase {
252
252
  private sameCellValue;
253
253
  private isSimpleField;
254
254
  private processSimpleField;
255
- /**
256
- * Batched read for top-level simple columns: a single `paste | sed` child
257
- * process returns every requested line of every column at once instead of
258
- * spawning one `sed`/`gunzip|sed` child process per column. Returning null
259
- * (e.g. when any column file is missing, or the shell command fails) makes
260
- * the caller fall back to the existing per-column reads.
261
- */
262
- private processSimpleFieldsBatch;
263
255
  private isArrayField;
264
256
  private processArrayField;
265
257
  private isObjectField;
@@ -350,6 +342,20 @@ export default class Inibase {
350
342
  * live file is touched (nothing is published before commit()).
351
343
  */
352
344
  rollback(): Promise<void>;
345
+ /**
346
+ * Run `callback` inside a transaction, committing when it resolves and
347
+ * rolling back when it throws. Prefer this over a bare begin/commit pair:
348
+ * the database lock taken by begin() is held for the transaction's whole
349
+ * lifetime, so an exception between begin() and commit() would otherwise
350
+ * block every later write on this database for the full lock-wait timeout.
351
+ *
352
+ * @param tablesOrCallback Pre-lock list (sorted, deadlock-free) or the callback
353
+ * @param maybeCallback The callback, when tables were passed first
354
+ * @param tables Pre-lock list when the callback was passed first
355
+ * @return {*} Whatever the callback resolves to
356
+ */
357
+ transaction<T>(tablesOrCallback: string[] | ((db: Inibase) => Promise<T>), maybeCallback?: (db: Inibase) => Promise<T>, tables?: string[]): Promise<T>;
358
+ private _transaction;
353
359
  /**
354
360
  * Snapshot the identity (dev:inode:mtime:size) of every column file and
355
361
  * the pagination file. Reading data and then re-verifying this snapshot
package/dist/index.js CHANGED
@@ -110,6 +110,18 @@ function astHasHops(node) {
110
110
  return astHasHops(node.arg);
111
111
  }
112
112
  }
113
+ /**
114
+ * Await a batch of column-write promises and surface the first rejection.
115
+ * `Promise.allSettled` never short-circuits, so a swallowed failure would let
116
+ * the caller publish a partial column set — the pagination row count and the
117
+ * column line counts would diverge. Every batch that fills a `renameList` must
118
+ * be inspected through here before the journal/pagination is touched.
119
+ */
120
+ async function settleWrites(results) {
121
+ const failed = results.find((result) => result.status === "rejected");
122
+ if (failed)
123
+ throw failed.reason;
124
+ }
113
125
  /**
114
126
  * @param {string} database - Database name
115
127
  * @param {string} [mainFolder="."] - Main folder path
@@ -140,7 +152,7 @@ export default class Inibase {
140
152
  * per-table writer lock of every table it mutates, so mutations stage into
141
153
  * the database journal and publish only on commit().
142
154
  */
143
- transaction = null;
155
+ activeTransaction = null;
144
156
  constructor(database, mainFolder = ".", language = "en") {
145
157
  this.language = language;
146
158
  this.validateName(database);
@@ -226,7 +238,7 @@ export default class Inibase {
226
238
  this.validateName(tableName);
227
239
  // DDL does not participate in the write-ahead journal: schema surgery
228
240
  // inside a transaction would escape the atomic publish/rollback scope.
229
- if (this.transaction)
241
+ if (this.activeTransaction)
230
242
  throw this.createError("INVALID_PARAMETERS");
231
243
  await this.ensureDatabaseRecovered();
232
244
  if (schema)
@@ -300,7 +312,7 @@ export default class Inibase {
300
312
  this.validateName(tableName);
301
313
  // DDL does not participate in the write-ahead journal: schema surgery
302
314
  // inside a transaction would escape the atomic publish/rollback scope.
303
- if (this.transaction)
315
+ if (this.activeTransaction)
304
316
  throw this.createError("INVALID_PARAMETERS");
305
317
  await this.ensureDatabaseRecovered();
306
318
  if (config?.name)
@@ -982,19 +994,39 @@ export default class Inibase {
982
994
  }
983
995
  async processSchemaData(tableName, schema, linesNumber, options, prefix) {
984
996
  const RETURN = {};
985
- // Fast path: read every top-level simple column in one `paste | sed`
986
- // child process instead of spawning one child process per column.
987
- // Nested (prefixed) schemas keep the per-column path.
997
+ // Sparse path: for an explicit finite selection, stream each top-level
998
+ // simple column only up to the last requested line (never to EOF) and
999
+ // decode it independently, instead of pasting every column through a
1000
+ // shell pipeline that always scans the whole table and can misalign on
1001
+ // values containing a literal tab. Nested (prefixed) schemas keep the
1002
+ // per-column path below.
988
1003
  let batchedKeys = null;
989
1004
  if (!prefix && linesNumber?.length) {
990
1005
  const simpleFields = schema.filter((field) => this.isSimpleField(field.type));
991
- const batched = await this.processSimpleFieldsBatch(tableName, simpleFields, linesNumber);
992
- if (batched) {
993
- batchedKeys = new Set(simpleFields.map((field) => field.key));
994
- for (const [line, row] of Object.entries(batched)) {
1006
+ const decodeID = globalConfig[this.databasePath].tables?.get(tableName)?.config
1007
+ .decodeID === true;
1008
+ const extension = this.getFileExtension(tableName);
1009
+ const reads = await Promise.all(simpleFields.map(async (field) => {
1010
+ const fieldPath = join(this.databasePath, tableName, `${field.key}${extension}`);
1011
+ if (!(await File.isExists(fieldPath)))
1012
+ return null;
1013
+ const items = await File.getLines(fieldPath, linesNumber, {
1014
+ ...field,
1015
+ type: field.key === "id" && decodeID ? "number" : field.type,
1016
+ databasePath: this.databasePath,
1017
+ });
1018
+ return { key: field.key, items };
1019
+ }));
1020
+ batchedKeys = new Set(simpleFields.map((field) => field.key));
1021
+ for (const read of reads) {
1022
+ if (!read)
1023
+ continue;
1024
+ for (const [line, value] of Object.entries(read.items)) {
1025
+ if (typeof value === "undefined")
1026
+ continue;
995
1027
  if (!RETURN[line])
996
1028
  RETURN[line] = {};
997
- Object.assign(RETURN[line], row);
1029
+ Object.assign(RETURN[line], { [read.key]: value });
998
1030
  }
999
1031
  }
1000
1032
  }
@@ -1428,7 +1460,7 @@ export default class Inibase {
1428
1460
  return null;
1429
1461
  // A transaction cannot read a dependency table it has already
1430
1462
  // staged (no read-your-writes past its commit point).
1431
- if (this.transaction?.tables.has(table))
1463
+ if (this.activeTransaction?.tables.has(table))
1432
1464
  throw this.createError("INVALID_PARAMETERS");
1433
1465
  // Batched link-hop reads: during the collection pass a hop
1434
1466
  // only records its (table, column, id) triple; the real pass
@@ -1658,78 +1690,6 @@ export default class Inibase {
1658
1690
  }
1659
1691
  }
1660
1692
  }
1661
- /**
1662
- * Batched read for top-level simple columns: a single `paste | sed` child
1663
- * process returns every requested line of every column at once instead of
1664
- * spawning one `sed`/`gunzip|sed` child process per column. Returning null
1665
- * (e.g. when any column file is missing, or the shell command fails) makes
1666
- * the caller fall back to the existing per-column reads.
1667
- */
1668
- async processSimpleFieldsBatch(tableName, fields, linesNumber) {
1669
- if (!fields.length || !linesNumber.length)
1670
- return null;
1671
- const decodeID = globalConfig[this.databasePath].tables?.get(tableName)?.config
1672
- .decodeID === true;
1673
- // Decode configs are computed once per column (the per-column read path
1674
- // builds one config per file too), not once per cell.
1675
- const cols = [];
1676
- for (const field of fields) {
1677
- const path = join(this.databasePath, tableName, `${field.key}${this.getFileExtension(tableName)}`);
1678
- if (!(await File.isExists(path)))
1679
- return null;
1680
- cols.push({
1681
- path,
1682
- key: field.key,
1683
- config: {
1684
- ...field,
1685
- type: field.key === "id" && decodeID ? "number" : field.type,
1686
- databasePath: this.databasePath,
1687
- },
1688
- });
1689
- }
1690
- const sortedLines = [...linesNumber].sort((a, b) => a - b);
1691
- const range = File._groupIntoRanges(sortedLines);
1692
- const files = cols.map(({ path }) => File.escapeShellPath(path)).join(" ");
1693
- const isGz = this.getFileExtension(tableName).endsWith(".gz");
1694
- // Each compressed column needs its own process substitution so the
1695
- // streams stay aligned column-by-column inside `paste`.
1696
- const pasteInputs = isGz
1697
- ? cols
1698
- .map(({ path }) => `<(gunzip -c ${File.escapeShellPath(path)})`)
1699
- .join(" ")
1700
- : files;
1701
- const command = isGz
1702
- ? `bash -c 'paste -d "\\t" ${pasteInputs} | sed -n "${range}"'`
1703
- : `paste -d'\\t' ${files} | sed -n '${range}'`;
1704
- let output;
1705
- try {
1706
- output = (await UtilsServer.exec(command)).stdout;
1707
- }
1708
- catch {
1709
- return null;
1710
- }
1711
- const outLines = output.trimEnd().split("\n");
1712
- const RETURN = {};
1713
- for (let i = 0; i < outLines.length && i < sortedLines.length; i++) {
1714
- const lineNo = sortedLines[i];
1715
- const cells = outLines[i].split("\t");
1716
- const row = {};
1717
- let added = false;
1718
- for (let c = 0; c < cols.length; c++) {
1719
- const raw = cells[c];
1720
- if (raw === undefined)
1721
- continue;
1722
- const value = File.decode(raw, cols[c].config);
1723
- if (value !== undefined) {
1724
- row[cols[c].key] = value;
1725
- added = true;
1726
- }
1727
- }
1728
- if (added)
1729
- RETURN[lineNo] = row;
1730
- }
1731
- return Object.keys(RETURN).length ? RETURN : null;
1732
- }
1733
1693
  // Helper function to check if the field type is array
1734
1694
  isArrayField(fieldType) {
1735
1695
  return ((Array.isArray(fieldType) &&
@@ -2210,7 +2170,7 @@ export default class Inibase {
2210
2170
  }
2211
2171
  /** Staged per-table entry of the open transaction, or null when none. */
2212
2172
  txnTableEntry(tableName) {
2213
- const txn = this.transaction;
2173
+ const txn = this.activeTransaction;
2214
2174
  if (!txn)
2215
2175
  return null;
2216
2176
  let entry = txn.tables.get(tableName);
@@ -2272,7 +2232,7 @@ export default class Inibase {
2272
2232
  * would need read-your-writes composition).
2273
2233
  */
2274
2234
  async stageTxnOp(tableName, renameList, pagination) {
2275
- const txn = this.transaction;
2235
+ const txn = this.activeTransaction;
2276
2236
  const entry = this.txnTableEntry(tableName);
2277
2237
  if (!txn || !entry)
2278
2238
  throw this.createError("INVALID_PARAMETERS");
@@ -2317,7 +2277,7 @@ export default class Inibase {
2317
2277
  return File.runWithLockStore(() => this._begin(tables));
2318
2278
  }
2319
2279
  async _begin(tables = []) {
2320
- if (this.transaction)
2280
+ if (this.activeTransaction)
2321
2281
  throw this.createError("INVALID_PARAMETERS");
2322
2282
  await this.ensureDatabaseTmpDir();
2323
2283
  // The database lock is the transaction mutex: it serializes
@@ -2333,16 +2293,16 @@ export default class Inibase {
2333
2293
  await this.getTable(name); // throws TABLE_NOT_EXISTS
2334
2294
  }
2335
2295
  const id = randomUUID();
2336
- this.transaction = {
2296
+ this.activeTransaction = {
2337
2297
  id,
2338
2298
  journal: new DatabaseJournal(this.databasePath, id),
2339
2299
  tables: new Map(),
2340
2300
  };
2341
- await this.transaction.journal.begin(uniqueTables);
2301
+ await this.activeTransaction.journal.begin(uniqueTables);
2342
2302
  for (const name of uniqueTables) {
2343
2303
  await File.lock(join(this.databasePath, name, ".tmp"));
2344
2304
  acquired.push(name);
2345
- this.transaction.tables.set(name, {
2305
+ this.activeTransaction.tables.set(name, {
2346
2306
  locked: true,
2347
2307
  paginationFrom: "",
2348
2308
  lastId: 0,
@@ -2357,7 +2317,7 @@ export default class Inibase {
2357
2317
  // lock file).
2358
2318
  for (const name of acquired)
2359
2319
  await File.unlock(join(this.databasePath, name, ".tmp")).catch(() => { });
2360
- this.transaction = null;
2320
+ this.activeTransaction = null;
2361
2321
  await File.unlock(join(this.databasePath, ".tmp")).catch(() => { });
2362
2322
  throw error;
2363
2323
  }
@@ -2371,7 +2331,7 @@ export default class Inibase {
2371
2331
  * tables, marker -> roll forward all tables).
2372
2332
  */
2373
2333
  async commit() {
2374
- const txn = this.transaction;
2334
+ const txn = this.activeTransaction;
2375
2335
  if (!txn)
2376
2336
  throw this.createError("INVALID_PARAMETERS");
2377
2337
  try {
@@ -2404,6 +2364,25 @@ export default class Inibase {
2404
2364
  await File.syncDir(join(this.databasePath, tableName));
2405
2365
  await File.syncDir(join(this.databasePath, tableName, ".tmp"));
2406
2366
  }
2367
+ // A staged table's row count and derived caches were deliberately
2368
+ // left untouched while the transaction was open (nothing is
2369
+ // published before commit), so they must be refreshed here — the
2370
+ // single-table fast path does the same right after commitFiles().
2371
+ // Without this a committed `put` (row count unchanged) would keep
2372
+ // serving sorted/criteria .cache entries built before the write.
2373
+ for (const tableName of txn.tables.keys()) {
2374
+ const entry = txn.tables.get(tableName);
2375
+ if (!entry?.staged.length)
2376
+ continue; // pre-locked but untouched
2377
+ // Only trust entry.total when resolvePagination() recorded it.
2378
+ // A `put` selected by `where` never reads the pagination file
2379
+ // (its row count cannot change), so entry.total is still its
2380
+ // initial 0 — publishing that would corrupt the live counter.
2381
+ if (entry.paginationFrom)
2382
+ this.totalItems.set(`${tableName}-*`, entry.total);
2383
+ if (globalConfig[this.databasePath].tables?.get(tableName)?.config.cache)
2384
+ await this.clearCache(tableName);
2385
+ }
2407
2386
  }
2408
2387
  catch (error) {
2409
2388
  await txn.journal.rollback().catch(() => { });
@@ -2413,7 +2392,7 @@ export default class Inibase {
2413
2392
  for (const tableName of [...txn.tables.keys()].sort().reverse())
2414
2393
  await File.unlock(join(this.databasePath, tableName, ".tmp"));
2415
2394
  await File.unlock(join(this.databasePath, ".tmp"));
2416
- this.transaction = null;
2395
+ this.activeTransaction = null;
2417
2396
  }
2418
2397
  }
2419
2398
  /**
@@ -2421,7 +2400,7 @@ export default class Inibase {
2421
2400
  * live file is touched (nothing is published before commit()).
2422
2401
  */
2423
2402
  async rollback() {
2424
- const txn = this.transaction;
2403
+ const txn = this.activeTransaction;
2425
2404
  if (!txn)
2426
2405
  throw this.createError("INVALID_PARAMETERS");
2427
2406
  try {
@@ -2431,7 +2410,44 @@ export default class Inibase {
2431
2410
  for (const tableName of [...txn.tables.keys()].sort().reverse())
2432
2411
  await File.unlock(join(this.databasePath, tableName, ".tmp"));
2433
2412
  await File.unlock(join(this.databasePath, ".tmp"));
2434
- this.transaction = null;
2413
+ this.activeTransaction = null;
2414
+ }
2415
+ }
2416
+ /**
2417
+ * Run `callback` inside a transaction, committing when it resolves and
2418
+ * rolling back when it throws. Prefer this over a bare begin/commit pair:
2419
+ * the database lock taken by begin() is held for the transaction's whole
2420
+ * lifetime, so an exception between begin() and commit() would otherwise
2421
+ * block every later write on this database for the full lock-wait timeout.
2422
+ *
2423
+ * @param tablesOrCallback Pre-lock list (sorted, deadlock-free) or the callback
2424
+ * @param maybeCallback The callback, when tables were passed first
2425
+ * @param tables Pre-lock list when the callback was passed first
2426
+ * @return {*} Whatever the callback resolves to
2427
+ */
2428
+ async transaction(tablesOrCallback, maybeCallback, tables = []) {
2429
+ return File.runWithLockStore(() => this._transaction(tablesOrCallback, maybeCallback, tables));
2430
+ }
2431
+ async _transaction(tablesOrCallback, maybeCallback, tables = []) {
2432
+ const callback = typeof tablesOrCallback === "function" ? tablesOrCallback : maybeCallback;
2433
+ if (!callback)
2434
+ throw this.createError("INVALID_PARAMETERS");
2435
+ if (Array.isArray(tablesOrCallback))
2436
+ tables = tablesOrCallback;
2437
+ await this.begin(tables);
2438
+ let committed = false;
2439
+ try {
2440
+ const result = await callback(this);
2441
+ await this.commit();
2442
+ committed = true;
2443
+ return result;
2444
+ }
2445
+ finally {
2446
+ // commit() already rolled back and cleared the transaction when it
2447
+ // failed, so this only fires for a throwing callback (or one that
2448
+ // never committed) — the case that would otherwise leak the lock.
2449
+ if (!committed && this.activeTransaction)
2450
+ await this.rollback().catch(() => { });
2435
2451
  }
2436
2452
  }
2437
2453
  /**
@@ -2712,7 +2728,11 @@ export default class Inibase {
2712
2728
  }
2713
2729
  }
2714
2730
  if (!lineNumbers) {
2715
- [lineNumbers, countItems] = await File.search(join(tablePath, `id${this.getFileExtension(tableName)}`), "[]", Ids.map((id) => Utils.isNumber(id) ? Number(id) : UtilsServer.decodeID(id)), undefined, undefined, { key: "BLABLA", type: "number" }, Ids.length, 0, !this.totalItems.has(`${tableName}-id`));
2731
+ [lineNumbers, countItems] = await File.search(join(tablePath, `id${this.getFileExtension(tableName)}`), "[]", Ids.map((id) => Utils.isNumber(id) ? Number(id) : UtilsServer.decodeID(id)), undefined, undefined, { key: "BLABLA", type: "number" }, Ids.length, 0,
2732
+ // The caller only needs the matching line numbers, not a
2733
+ // whole-table count: stop as soon as every requested id has
2734
+ // been found instead of scanning the column to EOF.
2735
+ false, true);
2716
2736
  }
2717
2737
  if (!lineNumbers)
2718
2738
  return null;
@@ -2805,7 +2825,7 @@ export default class Inibase {
2805
2825
  ? options.columns
2806
2826
  : [options.columns]));
2807
2827
  const tablePath = join(this.databasePath, tableName);
2808
- if (!this.transaction)
2828
+ if (!this.activeTransaction)
2809
2829
  await this.ensureDatabaseRecovered();
2810
2830
  await this.getTable(tableName);
2811
2831
  if (!globalConfig[this.databasePath].tables?.get(tableName)?.schema)
@@ -2819,13 +2839,13 @@ export default class Inibase {
2819
2839
  try {
2820
2840
  // Inside a transaction the table lock is taken once per txn (and
2821
2841
  // held until commit/rollback); otherwise the usual writer lock.
2822
- if (this.transaction)
2842
+ if (this.activeTransaction)
2823
2843
  await this.ensureTxnLock(tableName);
2824
2844
  else
2825
2845
  await File.lock(join(tablePath, ".tmp"));
2826
2846
  const { filePath: paginationFilePath, lastId, total: _totalItems, } = await this.resolvePagination(tableName);
2827
2847
  let lastIdValue = lastId;
2828
- if (!this.transaction)
2848
+ if (!this.activeTransaction)
2829
2849
  this.totalItems.set(`${tableName}-*`, _totalItems);
2830
2850
  if (Utils.isArrayOfObjects(clonedData))
2831
2851
  for (let index = 0; index < clonedData.length; index++) {
@@ -2859,16 +2879,16 @@ export default class Inibase {
2859
2879
  ? clonedData.toReversed()
2860
2880
  : clonedData
2861
2881
  : clonedData);
2862
- await Promise.allSettled(Object.entries(pathesContents).map(async ([path, content]) => renameList.push(globalConfig[this.databasePath].tables?.get(tableName)?.config
2882
+ await settleWrites(await Promise.allSettled(Object.entries(pathesContents).map(async ([path, content]) => renameList.push(globalConfig[this.databasePath].tables?.get(tableName)?.config
2863
2883
  .prepend
2864
2884
  ? await File.prepend(path, content)
2865
- : await File.append(path, content))));
2885
+ : await File.append(path, content)))));
2866
2886
  const newTotal = _totalItems + (Array.isArray(data) ? data.length : 1);
2867
2887
  const pagination = {
2868
2888
  from: paginationFilePath,
2869
2889
  to: join(tablePath, `${lastIdValue}-${newTotal}.pagination`),
2870
2890
  };
2871
- if (this.transaction) {
2891
+ if (this.activeTransaction) {
2872
2892
  // Stage: journal the intent (fsynced op entry); the live files
2873
2893
  // only change when commit() publishes.
2874
2894
  await this.stageTxnOp(tableName, renameList, pagination);
@@ -2887,7 +2907,7 @@ export default class Inibase {
2887
2907
  await this.clearCache(tableName);
2888
2908
  }
2889
2909
  if (returnPostedData) {
2890
- if (this.transaction)
2910
+ if (this.activeTransaction)
2891
2911
  // No read-your-writes yet: return the formatted staged rows
2892
2912
  // (ids + defaults) instead of a committed-state read.
2893
2913
  return (Array.isArray(clonedData) ? clonedData : clonedData);
@@ -2908,15 +2928,13 @@ export default class Inibase {
2908
2928
  // not encode it here. Every other table keeps returning the
2909
2929
  // encrypted id.
2910
2930
  const tableConfig = globalConfig[this.databasePath].tables?.get(tableName)?.config;
2911
- const postedID = (id) => tableConfig?.decodeID === true
2912
- ? Number(id)
2913
- : UtilsServer.encodeID(id);
2931
+ const postedID = (id) => tableConfig?.decodeID === true ? Number(id) : UtilsServer.encodeID(id);
2914
2932
  return Array.isArray(clonedData)
2915
2933
  ? (tableConfig?.prepend ? clonedData.toReversed() : clonedData).map(({ id }) => postedID(id))
2916
2934
  : postedID(clonedData.id);
2917
2935
  }
2918
2936
  finally {
2919
- if (this.transaction) {
2937
+ if (this.activeTransaction) {
2920
2938
  // Staged temps belong to the journal op; commit()/rollback()
2921
2939
  // owns them. Temps from a failed pre-stage attempt are cleaned
2922
2940
  // here so nothing leaks.
@@ -2952,7 +2970,7 @@ export default class Inibase {
2952
2970
  ? options.columns
2953
2971
  : [options.columns]));
2954
2972
  const tablePath = join(this.databasePath, tableName);
2955
- if (!this.transaction)
2973
+ if (!this.activeTransaction)
2956
2974
  await this.ensureDatabaseRecovered();
2957
2975
  await this.throwErrorIfTableEmpty(tableName);
2958
2976
  let clonedData = structuredClone(data);
@@ -2980,7 +2998,7 @@ export default class Inibase {
2980
2998
  // row overlaid with the payload. The plan itself is schema-only.
2981
2999
  const computedPlan = await this.buildComputedPlan(tableName);
2982
3000
  try {
2983
- if (this.transaction)
3001
+ if (this.activeTransaction)
2984
3002
  await this.ensureTxnLock(tableName);
2985
3003
  else
2986
3004
  await File.lock(join(tablePath, ".tmp"));
@@ -3006,8 +3024,8 @@ export default class Inibase {
3006
3024
  const evaluated = await this.evaluateComputedRows(tableName, computedPlan, mergedRows);
3007
3025
  this.mergeComputedLineRecords(tableName, computedPlan, evaluated, pathesContents, existing);
3008
3026
  }
3009
- await Promise.allSettled(Object.entries(pathesContents).map(async ([path, content]) => renameList.push(await File.replace(path, content, total))));
3010
- if (this.transaction) {
3027
+ await settleWrites(await Promise.allSettled(Object.entries(pathesContents).map(async ([path, content]) => renameList.push(await File.replace(path, content, total)))));
3028
+ if (this.activeTransaction) {
3011
3029
  // Stage instead of publishing: row count is unchanged so
3012
3030
  // there is no pagination rename to journal.
3013
3031
  await this.stageTxnOp(tableName, renameList, null);
@@ -3020,7 +3038,7 @@ export default class Inibase {
3020
3038
  await this.clearCache(tableName);
3021
3039
  }
3022
3040
  if (returnUpdatedData) {
3023
- if (this.transaction)
3041
+ if (this.activeTransaction)
3024
3042
  // Reading the committed state would miss the staged
3025
3043
  // write (no read-your-writes yet).
3026
3044
  throw this.createError("INVALID_PARAMETERS");
@@ -3028,7 +3046,7 @@ export default class Inibase {
3028
3046
  }
3029
3047
  }
3030
3048
  finally {
3031
- if (this.transaction) {
3049
+ if (this.activeTransaction) {
3032
3050
  // Staged temps belong to the journal op; commit()/rollback()
3033
3051
  // owns them.
3034
3052
  if (!txnStaged && renameList.length)
@@ -3075,7 +3093,7 @@ export default class Inibase {
3075
3093
  try {
3076
3094
  // One global lock per table serializes every writer; inside a
3077
3095
  // transaction the lock is held for the whole txn.
3078
- if (this.transaction)
3096
+ if (this.activeTransaction)
3079
3097
  await this.ensureTxnLock(tableName);
3080
3098
  else
3081
3099
  await File.lock(join(tablePath, ".tmp"));
@@ -3103,8 +3121,8 @@ export default class Inibase {
3103
3121
  }
3104
3122
  this.mergeComputedLineRecords(tableName, computedPlan, evaluated, pathesContents, existing);
3105
3123
  }
3106
- await Promise.allSettled(Object.entries(pathesContents).map(async ([path, content]) => renameList.push(await File.replace(path, content))));
3107
- if (this.transaction) {
3124
+ await settleWrites(await Promise.allSettled(Object.entries(pathesContents).map(async ([path, content]) => renameList.push(await File.replace(path, content)))));
3125
+ if (this.activeTransaction) {
3108
3126
  await this.stageTxnOp(tableName, renameList, null);
3109
3127
  txnStaged = true;
3110
3128
  }
@@ -3114,7 +3132,7 @@ export default class Inibase {
3114
3132
  await this.clearCache(tableName);
3115
3133
  }
3116
3134
  if (returnUpdatedData) {
3117
- if (this.transaction)
3135
+ if (this.activeTransaction)
3118
3136
  throw this.createError("INVALID_PARAMETERS");
3119
3137
  // AWAITED readback: must complete inside the writer lock. An
3120
3138
  // async-tail `return this.get(...)` lets the finally below
@@ -3123,7 +3141,7 @@ export default class Inibase {
3123
3141
  }
3124
3142
  }
3125
3143
  finally {
3126
- if (this.transaction) {
3144
+ if (this.activeTransaction) {
3127
3145
  if (!txnStaged && renameList.length)
3128
3146
  await Promise.allSettled(renameList
3129
3147
  .filter((pair) => Boolean(pair[0]))
@@ -3150,7 +3168,7 @@ export default class Inibase {
3150
3168
  // the lock is held continuously from resolution to publish.
3151
3169
  let inlineLock = false;
3152
3170
  try {
3153
- if (this.transaction)
3171
+ if (this.activeTransaction)
3154
3172
  await this.ensureTxnLock(tableName);
3155
3173
  else {
3156
3174
  await File.lock(join(tablePath, ".tmp"));
@@ -3194,7 +3212,7 @@ export default class Inibase {
3194
3212
  }
3195
3213
  async _delete(tableName, where, _whereIsLinesNumbers, _cascadeGuard) {
3196
3214
  this.validateName(tableName);
3197
- if (!this.transaction)
3215
+ if (!this.activeTransaction)
3198
3216
  await this.ensureDatabaseRecovered();
3199
3217
  const tablePath = join(this.databasePath, tableName);
3200
3218
  await this.throwErrorIfTableEmpty(tableName);
@@ -3204,7 +3222,7 @@ export default class Inibase {
3204
3222
  // ops) and publish the empty row count in one journaled commit.
3205
3223
  const renameList = [];
3206
3224
  try {
3207
- if (this.transaction)
3225
+ if (this.activeTransaction)
3208
3226
  await this.ensureTxnLock(tableName);
3209
3227
  else
3210
3228
  await File.lock(join(tablePath, ".tmp"));
@@ -3217,7 +3235,7 @@ export default class Inibase {
3217
3235
  from: paginationFilePath,
3218
3236
  to: join(tablePath, `${lastId}-0.pagination`),
3219
3237
  };
3220
- if (this.transaction) {
3238
+ if (this.activeTransaction) {
3221
3239
  await this.stageTxnOp(tableName, renameList, pagination);
3222
3240
  txnStaged = true;
3223
3241
  const stagedEntry = this.txnTableEntry(tableName);
@@ -3238,7 +3256,7 @@ export default class Inibase {
3238
3256
  return true;
3239
3257
  }
3240
3258
  finally {
3241
- if (this.transaction) {
3259
+ if (this.activeTransaction) {
3242
3260
  if (!txnStaged && renameList.length)
3243
3261
  await Promise.allSettled(renameList
3244
3262
  .filter((pair) => Boolean(pair[0]))
@@ -3268,7 +3286,7 @@ export default class Inibase {
3268
3286
  const renameList = [];
3269
3287
  let txnStaged = false;
3270
3288
  try {
3271
- if (this.transaction)
3289
+ if (this.activeTransaction)
3272
3290
  await this.ensureTxnLock(tableName);
3273
3291
  else
3274
3292
  await File.lock(join(tablePath, ".tmp"));
@@ -3276,12 +3294,12 @@ export default class Inibase {
3276
3294
  const remaining = total - (Array.isArray(where) ? where.length : 1);
3277
3295
  if (total && remaining > 0) {
3278
3296
  this.idDensity.set(tableName, false);
3279
- await Promise.allSettled(files.map(async (file) => renameList.push(await File.remove(join(tablePath, file), where))));
3297
+ await settleWrites(await Promise.allSettled(files.map(async (file) => renameList.push(await File.remove(join(tablePath, file), where)))));
3280
3298
  const pagination = {
3281
3299
  from: paginationFilePath,
3282
3300
  to: join(tablePath, `${lastId}-${remaining}.pagination`),
3283
3301
  };
3284
- if (this.transaction) {
3302
+ if (this.activeTransaction) {
3285
3303
  await this.stageTxnOp(tableName, renameList, pagination);
3286
3304
  txnStaged = true;
3287
3305
  const stagedEntry = this.txnTableEntry(tableName);
@@ -3302,7 +3320,7 @@ export default class Inibase {
3302
3320
  from: paginationFilePath,
3303
3321
  to: join(tablePath, `${lastId}-0.pagination`),
3304
3322
  };
3305
- if (this.transaction) {
3323
+ if (this.activeTransaction) {
3306
3324
  await this.stageTxnOp(tableName, truncateList, pagination);
3307
3325
  txnStaged = true;
3308
3326
  const stagedEntry = this.txnTableEntry(tableName);
@@ -3315,7 +3333,7 @@ export default class Inibase {
3315
3333
  }
3316
3334
  // Cache still describes the committed state while a
3317
3335
  // transaction is open, so only clear it outside one.
3318
- if (!this.transaction &&
3336
+ if (!this.activeTransaction &&
3319
3337
  globalConfig[this.databasePath].tables?.get(tableName)?.config.cache)
3320
3338
  await this.clearCache(tableName);
3321
3339
  // Cascade: rows in other tables referencing the deleted rows
@@ -3324,7 +3342,7 @@ export default class Inibase {
3324
3342
  return true;
3325
3343
  }
3326
3344
  finally {
3327
- if (this.transaction) {
3345
+ if (this.activeTransaction) {
3328
3346
  if (!txnStaged && renameList.length)
3329
3347
  await Promise.allSettled(renameList
3330
3348
  .filter((pair) => Boolean(pair[0]))
@@ -3350,7 +3368,7 @@ export default class Inibase {
3350
3368
  // lines of a prepend table and remove the wrong row).
3351
3369
  let inlineLock = false;
3352
3370
  try {
3353
- if (this.transaction)
3371
+ if (this.activeTransaction)
3354
3372
  await this.ensureTxnLock(tableName);
3355
3373
  else {
3356
3374
  await File.lock(join(tablePath, ".tmp"));
@@ -3424,7 +3442,7 @@ export default class Inibase {
3424
3442
  // Unreadable/unsupported column -> skip this reference.
3425
3443
  // Inside a transaction a broken reference must abort the
3426
3444
  // whole cascade (all-or-nothing).
3427
- if (this.transaction)
3445
+ if (this.activeTransaction)
3428
3446
  throw error;
3429
3447
  }
3430
3448
  }
@@ -3444,7 +3462,7 @@ export default class Inibase {
3444
3462
  // Cascade is best-effort outside a transaction: never break
3445
3463
  // the parent delete. Inside a transaction a cascade failure
3446
3464
  // must abort the whole txn (all-or-nothing).
3447
- if (this.transaction)
3465
+ if (this.activeTransaction)
3448
3466
  throw error;
3449
3467
  }
3450
3468
  }
@@ -3503,7 +3521,9 @@ export default class Inibase {
3503
3521
  const targetFields = new Map();
3504
3522
  for (const column of columns) {
3505
3523
  const dot = column.indexOf(".");
3506
- if (dot <= 0 || dot === column.length - 1 || column.indexOf(".", dot + 1) !== -1)
3524
+ if (dot <= 0 ||
3525
+ dot === column.length - 1 ||
3526
+ column.indexOf(".", dot + 1) !== -1)
3507
3527
  throw this.createError("INVALID_PARAMETERS", [
3508
3528
  `sum nested: '${column}' must be a single-level child path of an array-of-objects column`,
3509
3529
  ]);
@@ -3629,7 +3649,8 @@ export default class Inibase {
3629
3649
  let matches = true;
3630
3650
  for (const p of elementPredicates) {
3631
3651
  const pv = predCells.get(p.field.key)?.[Number(lineStr)]?.[i];
3632
- if (pv === undefined || !UtilsServer.compare(p.operator, pv, p.comparedValue, p.field.type)) {
3652
+ if (pv === undefined ||
3653
+ !UtilsServer.compare(p.operator, pv, p.comparedValue, p.field.type)) {
3633
3654
  matches = false;
3634
3655
  break;
3635
3656
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "inibase",
3
- "version": "3.5.0",
3
+ "version": "3.7.0",
4
4
  "type": "module",
5
5
  "author": {
6
6
  "name": "Karim Amahtil",