inibase 3.2.0 → 3.3.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
@@ -1,5 +1,21 @@
1
1
  import { type ComparisonOperator, type Field } from "./index.js";
2
2
  export declare const DURABLE: boolean;
3
+ /**
4
+ * Run `fn` with a per-logical-task lock-owner store on its AsyncLocalStorage
5
+ * context. Every await continuation below `fn` then shares that store, so
6
+ * nested/reentrant acquisitions (put/delete id->line recursion, journal
7
+ * recovery re-locks, transaction flows) see the SAME store the outermost
8
+ * acquisition recorded as owner.
9
+ *
10
+ * Do NOT replace this with an enterWith() from inside lock(): that store is
11
+ * created in lock()'s own frame and (a) never propagates back to the caller's
12
+ * await continuation — every recursion then waits on its OWN lock until the
13
+ * watchdog (LOCK_WAIT_TIMEOUT) — and (b) can leak into sibling tasks, which
14
+ * then pass the writer lock as "reentrant" and re-race the Sep-2026 commit
15
+ * interleave (ENOENT renames, duplicate ids). `run()` gives clean per-op
16
+ * isolation and correct same-chain propagation.
17
+ */
18
+ export declare const runWithLockStore: <T>(fn: () => T) => T;
3
19
  export declare const lock: (folderPath: string, prefix?: string, ttl?: number) => Promise<void>;
4
20
  /**
5
21
  * Non-blocking lock acquisition (used by the read path and the open-time
package/dist/file.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { access, appendFile, copyFile, constants as fsConstants, open, readFile, stat, unlink, } from "node:fs/promises";
2
3
  import { hostname } from "node:os";
3
4
  import { join, resolve } from "node:path";
@@ -31,6 +32,87 @@ const LOCK_RETRY_MS = 13;
31
32
  // multiple times (e.g. post() -> get() -> sort-cache). Depth 1 is a real
32
33
  // filesystem acquisition; deeper acquisitions just bump the counter.
33
34
  const lockDepths = new Map();
35
+ // In-process serialization: sibling tasks (e.g. two concurrent HTTP requests
36
+ // posting to the same table) must QUEUE behind the holder instead of passing
37
+ // straight through the depth counter. That bypass is exactly what let
38
+ // concurrent writers share the fixed `.tmp/<column>` staging path and race the
39
+ // pagination rename (diverged column files, duplicate ids, lost rows in the
40
+ // Sep-2026 pteam incident). Reentrancy from the *same* logical task (post() ->
41
+ // get() -> ensureTableRecovered re-lock, transaction flows) still bumps the
42
+ // depth without touching the filesystem, proven by the AsyncLocalStorage
43
+ // context recorded at acquisition time.
44
+ const lockReleaseWaiters = new Map();
45
+ const lockOwners = new Map();
46
+ // Upper bound on in-process wait time: converts a wedged or undetectable
47
+ // holder into a visible error instead of a silent hang. 0 disables it.
48
+ const LOCK_WAIT_TIMEOUT_MS = Number(process.env.INIBASE_LOCK_WAIT_TIMEOUT_MS ?? 120_000);
49
+ const lockContext = new AsyncLocalStorage();
50
+ // Resolve once the holder releases so the waiter can re-race for the lock.
51
+ const waitForLockRelease = (resolvedPath) => new Promise((resolveWaiter, rejectWaiter) => {
52
+ let waiters = lockReleaseWaiters.get(resolvedPath);
53
+ if (!waiters) {
54
+ waiters = new Set();
55
+ lockReleaseWaiters.set(resolvedPath, waiters);
56
+ }
57
+ let timer = null;
58
+ const wake = (error) => {
59
+ waiters.delete(wake);
60
+ if (timer)
61
+ clearTimeout(timer);
62
+ error ? rejectWaiter(error) : resolveWaiter();
63
+ };
64
+ waiters.add(wake);
65
+ if (LOCK_WAIT_TIMEOUT_MS > 0)
66
+ timer = setTimeout(() => wake(new Error(`LOCK_WAIT_TIMEOUT: ${resolvedPath}`)), LOCK_WAIT_TIMEOUT_MS);
67
+ });
68
+ // The AsyncLocalStorage store of this task chain (created on first acquire).
69
+ const currentLockStore = () => {
70
+ let store = lockContext.getStore();
71
+ if (!store) {
72
+ store = new Set();
73
+ lockContext.enterWith(store);
74
+ }
75
+ return store;
76
+ };
77
+ /**
78
+ * Run `fn` with a per-logical-task lock-owner store on its AsyncLocalStorage
79
+ * context. Every await continuation below `fn` then shares that store, so
80
+ * nested/reentrant acquisitions (put/delete id->line recursion, journal
81
+ * recovery re-locks, transaction flows) see the SAME store the outermost
82
+ * acquisition recorded as owner.
83
+ *
84
+ * Do NOT replace this with an enterWith() from inside lock(): that store is
85
+ * created in lock()'s own frame and (a) never propagates back to the caller's
86
+ * await continuation — every recursion then waits on its OWN lock until the
87
+ * watchdog (LOCK_WAIT_TIMEOUT) — and (b) can leak into sibling tasks, which
88
+ * then pass the writer lock as "reentrant" and re-race the Sep-2026 commit
89
+ * interleave (ENOENT renames, duplicate ids). `run()` gives clean per-op
90
+ * isolation and correct same-chain propagation.
91
+ */
92
+ export const runWithLockStore = (fn) => {
93
+ if (lockContext.getStore())
94
+ return fn();
95
+ const store = new Set();
96
+ return lockContext.run(store, fn);
97
+ };
98
+ // True when this task already owns the lock (depth > 0 and the recorded owner
99
+ // context is this task's context). Bumps the depth and returns.
100
+ const isReentrant = (resolvedPath) => {
101
+ if (!lockDepths.get(resolvedPath))
102
+ return false;
103
+ if (lockOwners.get(resolvedPath) !== lockContext.getStore())
104
+ return false;
105
+ lockDepths.set(resolvedPath, lockDepths.get(resolvedPath) + 1);
106
+ return true;
107
+ };
108
+ const notifyLockReleased = (resolvedPath) => {
109
+ const waiters = lockReleaseWaiters.get(resolvedPath);
110
+ if (!waiters)
111
+ return;
112
+ lockReleaseWaiters.delete(resolvedPath);
113
+ for (const wake of waiters)
114
+ wake();
115
+ };
34
116
  const lockFilePathFor = (folderPath, prefix) => join(folderPath, `${prefix ?? ""}.locked`);
35
117
  const lockOwnerState = async (lockFilePath) => {
36
118
  try {
@@ -67,12 +149,16 @@ const stealableLock = async (lockFilePath, mtimeMs, ttl) => {
67
149
  export const lock = async (folderPath, prefix, ttl = DEFAULT_LOCK_TTL_MS) => {
68
150
  const lockFilePath = lockFilePathFor(folderPath, prefix);
69
151
  const resolvedPath = resolve(lockFilePath);
70
- const depth = lockDepths.get(resolvedPath);
71
- if (depth) {
72
- lockDepths.set(resolvedPath, depth + 1);
73
- return;
74
- }
75
152
  for (;;) {
153
+ // Same logical task already holds it -> reentrant depth bump.
154
+ if (isReentrant(resolvedPath))
155
+ return;
156
+ // A different in-process task holds it -> wait for the release, then
157
+ // re-race for the filesystem lock like any process.
158
+ if (lockDepths.get(resolvedPath)) {
159
+ await waitForLockRelease(resolvedPath);
160
+ continue;
161
+ }
76
162
  try {
77
163
  const lockFile = await open(lockFilePath, "wx");
78
164
  try {
@@ -100,6 +186,7 @@ export const lock = async (folderPath, prefix, ttl = DEFAULT_LOCK_TTL_MS) => {
100
186
  }
101
187
  }
102
188
  lockDepths.set(resolvedPath, 1);
189
+ lockOwners.set(resolvedPath, currentLockStore());
103
190
  return;
104
191
  }
105
192
  catch (error) {
@@ -129,11 +216,10 @@ export const lock = async (folderPath, prefix, ttl = DEFAULT_LOCK_TTL_MS) => {
129
216
  export const tryLock = async (folderPath, prefix, ttl = DEFAULT_LOCK_TTL_MS) => {
130
217
  const lockFilePath = lockFilePathFor(folderPath, prefix);
131
218
  const resolvedPath = resolve(lockFilePath);
132
- const depth = lockDepths.get(resolvedPath);
133
- if (depth) {
134
- lockDepths.set(resolvedPath, depth + 1);
219
+ if (isReentrant(resolvedPath))
135
220
  return true;
136
- }
221
+ if (lockDepths.get(resolvedPath))
222
+ return false; // held by another in-process task: non-blocking
137
223
  for (let attempt = 0; attempt < 2; attempt++) {
138
224
  try {
139
225
  const lockFile = await open(lockFilePath, "wx");
@@ -157,6 +243,7 @@ export const tryLock = async (folderPath, prefix, ttl = DEFAULT_LOCK_TTL_MS) =>
157
243
  }
158
244
  }
159
245
  lockDepths.set(resolvedPath, 1);
246
+ lockOwners.set(resolvedPath, currentLockStore());
160
247
  return true;
161
248
  }
162
249
  catch (error) {
@@ -183,12 +270,15 @@ export const unlock = async (folderPath, prefix) => {
183
270
  return;
184
271
  }
185
272
  lockDepths.delete(resolvedPath);
273
+ lockOwners.delete(resolvedPath);
186
274
  try {
187
275
  await unlink(lockFilePath);
188
276
  }
189
277
  catch {
190
278
  // Already released (stolen by a foreign-host stealer or cleaned up).
191
279
  }
280
+ // Wake in-process waiters so they re-race for the released lock.
281
+ notifyLockReleased(resolvedPath);
192
282
  };
193
283
  export const write = async (filePath, data) => {
194
284
  const handle = await open(filePath, "w");
@@ -242,6 +332,17 @@ export function escapeShellPath(filePath) {
242
332
  // Escape double quotes and special shell characters
243
333
  return `"${resolvedPath.replace(/(["\\$`])/g, "\\$1")}"`;
244
334
  }
335
+ /**
336
+ * Unique-per-operation staging path: concurrent writers must never share the
337
+ * `.tmp/<column>` file. The fixed name let sibling operations clobber each
338
+ * other's in-progress temp and publish a temp that only contained one writer's
339
+ * content (column files following different winners -> diverged line counts).
340
+ * A pid + per-process counter suffix keeps every temp private; each pair is
341
+ * carried through the rename list and the journal, so commit/rollback/recovery
342
+ * still know exactly which files to move.
343
+ */
344
+ let tempSeq = 0;
345
+ const tempPathFor = (filePath) => filePath.replace(/([^/]+)\/?$/, `.tmp/$1.${process.pid}-${(tempSeq++).toString(36)}`);
245
346
  const _pipeline = async (filePath, rl, writeStream, transform) => {
246
347
  if (filePath.endsWith(".gz"))
247
348
  await pipeline(rl, transform, createGzip(), writeStream);
@@ -505,7 +606,7 @@ export async function get(filePath, lineNumbers, field, readWholeFile = false) {
505
606
  * Note: If the file doesn't exist and replacements is an object, it creates a new file with the specified replacements.
506
607
  */
507
608
  export const replace = async (filePath, replacements, totalItems) => {
508
- const fileTempPath = filePath.replace(/([^/]+)\/?$/, ".tmp/$1");
609
+ const fileTempPath = tempPathFor(filePath);
509
610
  const isReplacementsObject = isObject(replacements);
510
611
  const isReplacementsLineNumbered = isReplacementsObject && !Number.isNaN(Number(Object.keys(replacements)[0]));
511
612
  if (await isExists(filePath)) {
@@ -581,19 +682,33 @@ export const replace = async (filePath, replacements, totalItems) => {
581
682
  const replacementsKeys = Object.keys(replacements)
582
683
  .map(Number)
583
684
  .toSorted((a, b) => a - b);
584
- await write(fileTempPath, `${"\n".repeat(replacementsKeys[0] - 1) +
585
- replacementsKeys
586
- .map((lineNumber, index) => index === 0 ||
587
- lineNumber - replacementsKeys[index - 1] - 1 === 0
588
- ? replacements[lineNumber]
589
- : "\n".repeat(lineNumber - replacementsKeys[index - 1] - 1) +
590
- replacements[lineNumber])
591
- .join("\n")}\n`);
685
+ await write(fileTempPath,
686
+ // First-write for a compressed column: see append() — the
687
+ // unique temp path hides the ".gz" suffix from write().
688
+ filePath.endsWith(".gz")
689
+ ? await gzip(`${"\n".repeat(replacementsKeys[0] - 1) +
690
+ replacementsKeys
691
+ .map((lineNumber, index) => index === 0 ||
692
+ lineNumber - replacementsKeys[index - 1] - 1 === 0
693
+ ? replacements[lineNumber]
694
+ : "\n".repeat(lineNumber - replacementsKeys[index - 1] - 1) + replacements[lineNumber])
695
+ .join("\n")}\n`)
696
+ : `${"\n".repeat(replacementsKeys[0] - 1) +
697
+ replacementsKeys
698
+ .map((lineNumber, index) => index === 0 ||
699
+ lineNumber - replacementsKeys[index - 1] - 1 === 0
700
+ ? replacements[lineNumber]
701
+ : "\n".repeat(lineNumber - replacementsKeys[index - 1] - 1) + replacements[lineNumber])
702
+ .join("\n")}\n`);
592
703
  }
593
704
  else {
594
705
  if (!totalItems)
595
706
  throw new Error("INVALID_PARAMETERS");
596
- await write(fileTempPath, `${`${replacements}\n`.repeat(totalItems)}\n`);
707
+ await write(fileTempPath,
708
+ // First-write for a compressed column: see append().
709
+ filePath.endsWith(".gz")
710
+ ? await gzip(`${`${replacements}\n`.repeat(totalItems)}\n`)
711
+ : `${`${replacements}\n`.repeat(totalItems)}\n`);
597
712
  }
598
713
  return [fileTempPath, filePath];
599
714
  }
@@ -612,7 +727,7 @@ export const replace = async (filePath, replacements, totalItems) => {
612
727
  *
613
728
  */
614
729
  export const append = async (filePath, data) => {
615
- const fileTempPath = filePath.replace(/([^/]+)\/?$/, ".tmp/$1");
730
+ const fileTempPath = tempPathFor(filePath);
616
731
  try {
617
732
  if (await isExists(filePath)) {
618
733
  await copyFile(filePath, fileTempPath);
@@ -627,7 +742,13 @@ export const append = async (filePath, data) => {
627
742
  }
628
743
  }
629
744
  else
630
- await write(fileTempPath, `${Array.isArray(data) ? data.join("\n") : data}\n`);
745
+ await write(fileTempPath,
746
+ // Unique temp paths no longer end with the original extension
747
+ // (.tmp/<col>.<pid>-<seq>), so write()'s suffix check can't
748
+ // gzip a first-write for a compressed column. Do it here.
749
+ filePath.endsWith(".gz")
750
+ ? await gzip(`${Array.isArray(data) ? data.join("\n") : data}\n`)
751
+ : `${Array.isArray(data) ? data.join("\n") : data}\n`);
631
752
  return [fileTempPath, filePath];
632
753
  }
633
754
  catch {
@@ -643,7 +764,7 @@ export const append = async (filePath, data) => {
643
764
  *
644
765
  */
645
766
  export const prepend = async (filePath, data) => {
646
- const fileTempPath = filePath.replace(/([^/]+)\/?$/, ".tmp/$1");
767
+ const fileTempPath = tempPathFor(filePath);
647
768
  if (await isExists(filePath)) {
648
769
  if (!filePath.endsWith(".gz")) {
649
770
  let fileHandle = null;
@@ -673,7 +794,7 @@ export const prepend = async (filePath, data) => {
673
794
  }
674
795
  }
675
796
  else {
676
- const fileChildTempPath = filePath.replace(/([^/]+)\/?$/, ".tmp/tmp_$1");
797
+ const fileChildTempPath = `${tempPathFor(filePath)}.child`;
677
798
  try {
678
799
  await write(fileChildTempPath, `${Array.isArray(data) ? data.join("\n") : data}\n`);
679
800
  const escapedFilePath = escapeShellPath(filePath);
@@ -691,7 +812,12 @@ export const prepend = async (filePath, data) => {
691
812
  }
692
813
  else {
693
814
  try {
694
- await write(fileTempPath, `${Array.isArray(data) ? data.join("\n") : data}\n`);
815
+ await write(fileTempPath,
816
+ // First-write for a compressed column: see append() — the
817
+ // unique temp path hides the ".gz" suffix from write().
818
+ filePath.endsWith(".gz")
819
+ ? await gzip(`${Array.isArray(data) ? data.join("\n") : data}\n`)
820
+ : `${Array.isArray(data) ? data.join("\n") : data}\n`);
695
821
  }
696
822
  catch {
697
823
  return [fileTempPath, null];
@@ -714,7 +840,7 @@ export const remove = async (filePath, linesToDelete) => {
714
840
  : [Number(linesToDelete)];
715
841
  if (linesToDelete.some(Number.isNaN))
716
842
  throw new Error("UNVALID_LINE_NUMBERS");
717
- const fileTempPath = filePath.replace(/([^/]+)\/?$/, ".tmp/$1");
843
+ const fileTempPath = tempPathFor(filePath);
718
844
  try {
719
845
  const escapedFilePath = escapeShellPath(filePath);
720
846
  const escapedFileTempPath = escapeShellPath(fileTempPath);
package/dist/index.d.ts CHANGED
@@ -155,6 +155,7 @@ export default class Inibase {
155
155
  * @param {TableConfig} [config]
156
156
  */
157
157
  createTable(tableName: string, schema?: Schema, config?: TableConfig): Promise<void>;
158
+ private _createTable;
158
159
  private replaceStringInFile;
159
160
  /**
160
161
  * Update table schema or config
@@ -166,6 +167,7 @@ export default class Inibase {
166
167
  updateTable(tableName: string, schema?: Schema, config?: TableConfig & {
167
168
  name?: string;
168
169
  }): Promise<void>;
170
+ private _updateTable;
169
171
  private updateTableLocked;
170
172
  /**
171
173
  * Get table schema and config
@@ -333,6 +335,7 @@ export default class Inibase {
333
335
  * locked on first touch, in first-touch order.
334
336
  */
335
337
  begin(tables?: string[]): Promise<void>;
338
+ private _begin;
336
339
  /**
337
340
  * Publish every staged mutation atomically: per table (sorted), the
338
341
  * pagination rename comes first (the atomic publication point readers
@@ -371,6 +374,7 @@ export default class Inibase {
371
374
  get<TData extends Record<string, any> & Partial<Data>>(tableName: string, where?: string | number | (string | number)[] | Criteria, options?: Options, onlyOne?: boolean, onlyLinesNumbers?: false, _whereIsLinesNumbers?: boolean): Promise<(Data & TData)[] | null>;
372
375
  get<_TData extends Record<string, any> & Partial<Data>>(tableName: string, where: string | number | (string | number)[] | Criteria | undefined, options: Options | undefined, onlyOne: false | undefined, onlyLinesNumbers: true, _whereIsLinesNumbers?: boolean): Promise<number[] | null>;
373
376
  get<_TData extends Record<string, any> & Partial<Data>>(tableName: string, where: string | number | (string | number)[] | Criteria | undefined, options: Options | undefined, onlyOne: true, onlyLinesNumbers: true, _whereIsLinesNumbers?: boolean): Promise<number | null>;
377
+ private _get;
374
378
  private getOnce;
375
379
  /**
376
380
  * Create new item(s) in a table
@@ -385,6 +389,7 @@ export default class Inibase {
385
389
  post<TData extends Record<string, any> & Partial<Data>>(tableName: string, data: (Data & TData)[], options?: Options, returnPostedData?: boolean): Promise<string[]>;
386
390
  post<TData extends Record<string, any> & Partial<Data>>(tableName: string, data: Data & TData, options: Options | undefined, returnPostedData: true): Promise<(Data & TData) | null>;
387
391
  post<TData extends Record<string, any> & Partial<Data>>(tableName: string, data: (Data & TData)[], options: Options | undefined, returnPostedData: true): Promise<(Data & TData)[] | null>;
392
+ private _post;
388
393
  /**
389
394
  * Update item(s) in a table
390
395
  *
@@ -399,6 +404,7 @@ export default class Inibase {
399
404
  put<TData extends Record<string, any> & Partial<Data>>(tableName: string, data: Data & TData, where: number | string | (number | string)[] | Criteria | undefined, options: Options | undefined, returnUpdatedData: true | boolean, _whereIsLinesNumbers?: boolean): Promise<(Data & TData) | null>;
400
405
  put<TData extends Record<string, any> & Partial<Data>>(tableName: string, data: (Data & TData)[], where: number | string | (number | string)[] | Criteria | undefined, options: Options | undefined, returnUpdatedData: true | boolean, _whereIsLinesNumbers?: boolean): Promise<(Data & TData)[] | null>;
401
406
  put<TData extends Record<string, any> & Partial<Data>>(tableName: string, data: (Data & TData) | (Data & TData)[], where: number | string | (number | string)[] | Criteria | undefined, options: Options | undefined, returnUpdatedData: true | boolean, _whereIsLinesNumbers?: boolean): Promise<(Data & TData) | (Data & TData)[] | null>;
407
+ private _put;
402
408
  /**
403
409
  * Delete item(s) in a table
404
410
  *
@@ -407,6 +413,7 @@ export default class Inibase {
407
413
  * @return {boolean | null} {(Promise<boolean | null>)}
408
414
  */
409
415
  delete(tableName: string, where?: number | string | (number | string)[] | Criteria, _whereIsLinesNumbers?: boolean, _cascadeGuard?: Set<string>): Promise<boolean | null>;
416
+ private _delete;
410
417
  /**
411
418
  * Cascade delete: remove rows in other tables whose `table`-typed schema
412
419
  * fields reference the given rows. Reference columns store the numeric
@@ -424,8 +431,8 @@ export default class Inibase {
424
431
  * @param {(number | string | (number | string)[] | Criteria)} [where]
425
432
  * @return {*} {Promise<number | Record<string, number>>}
426
433
  */
427
- sum(tableName: string, columns: string, where?: number | string | (number | string)[] | Criteria): Promise<number>;
428
- sum(tableName: string, columns: string[], where?: number | string | (number | string)[] | Criteria): Promise<Record<string, number>>;
434
+ sum(tableName: string, columns: string, where?: number | string | (number | string)[] | Criteria, options?: AggregateOptions): Promise<number>;
435
+ sum(tableName: string, columns: string[], where?: number | string | (number | string)[] | Criteria, options?: AggregateOptions): Promise<Record<string, number>>;
429
436
  /**
430
437
  * Element-wise aggregation over an array-of-objects column (`nested` mode).
431
438
  *
package/dist/index.js CHANGED
@@ -220,6 +220,9 @@ export default class Inibase {
220
220
  * @param {TableConfig} [config]
221
221
  */
222
222
  async createTable(tableName, schema, config) {
223
+ return File.runWithLockStore(() => this._createTable(tableName, schema, config));
224
+ }
225
+ async _createTable(tableName, schema, config) {
223
226
  this.validateName(tableName);
224
227
  // DDL does not participate in the write-ahead journal: schema surgery
225
228
  // inside a transaction would escape the atomic publish/rollback scope.
@@ -291,6 +294,9 @@ export default class Inibase {
291
294
  * @param {(TableConfig&{name?: string})} [config]
292
295
  */
293
296
  async updateTable(tableName, schema, config) {
297
+ return File.runWithLockStore(() => this._updateTable(tableName, schema, config));
298
+ }
299
+ async _updateTable(tableName, schema, config) {
294
300
  this.validateName(tableName);
295
301
  // DDL does not participate in the write-ahead journal: schema surgery
296
302
  // inside a transaction would escape the atomic publish/rollback scope.
@@ -2308,6 +2314,9 @@ export default class Inibase {
2308
2314
  * locked on first touch, in first-touch order.
2309
2315
  */
2310
2316
  async begin(tables = []) {
2317
+ return File.runWithLockStore(() => this._begin(tables));
2318
+ }
2319
+ async _begin(tables = []) {
2311
2320
  if (this.transaction)
2312
2321
  throw this.createError("INVALID_PARAMETERS");
2313
2322
  await this.ensureDatabaseTmpDir();
@@ -2459,6 +2468,12 @@ export default class Inibase {
2459
2468
  async get(tableName, where, options = {
2460
2469
  page: 1,
2461
2470
  perPage: 15,
2471
+ }, onlyOne, onlyLinesNumbers, _whereIsLinesNumbers) {
2472
+ return File.runWithLockStore(() => this._get(tableName, where, options, onlyOne, onlyLinesNumbers, _whereIsLinesNumbers));
2473
+ }
2474
+ async _get(tableName, where, options = {
2475
+ page: 1,
2476
+ perPage: 15,
2462
2477
  }, onlyOne, onlyLinesNumbers, _whereIsLinesNumbers) {
2463
2478
  this.validateName(tableName);
2464
2479
  await this.ensureTableRecovered(tableName);
@@ -2631,9 +2646,13 @@ export default class Inibase {
2631
2646
  }
2632
2647
  else if (((Array.isArray(where) && where.every(Utils.isNumber)) ||
2633
2648
  Utils.isNumber(where)) &&
2634
- (_whereIsLinesNumbers ||
2635
- !globalConfig[this.databasePath].tables?.get(tableName)?.config
2636
- .decodeID)) {
2649
+ // Numeric where is a line number only through the internal
2650
+ // recursion (flag set). An external numeric where is an id — even
2651
+ // on non-decodeID tables, where ids are NOT line numbers (prepend
2652
+ // layout / rows deleted); using them as lines edited the wrong row
2653
+ // and padded the column file (Sep-2026 rename bug). Ids are
2654
+ // resolved by the next branch.
2655
+ _whereIsLinesNumbers) {
2637
2656
  // "where" in this case, is the line(s) number(s) and not id(s)
2638
2657
  let lineNumbers = where;
2639
2658
  if (!Array.isArray(lineNumbers))
@@ -2647,8 +2666,6 @@ export default class Inibase {
2647
2666
  RETURN = RETURN[0];
2648
2667
  }
2649
2668
  else if ((!_whereIsLinesNumbers &&
2650
- globalConfig[this.databasePath].tables?.get(tableName)?.config
2651
- .decodeID &&
2652
2669
  ((Array.isArray(where) && where.every(Utils.isNumber)) ||
2653
2670
  Utils.isNumber(where))) ||
2654
2671
  (Array.isArray(where) && where.every(Utils.isValidID)) ||
@@ -2774,6 +2791,9 @@ export default class Inibase {
2774
2791
  return onlyOne && Array.isArray(RETURN) ? RETURN[0] : RETURN;
2775
2792
  }
2776
2793
  async post(tableName, data, options, returnPostedData) {
2794
+ return File.runWithLockStore(() => this._post(tableName, data, options, returnPostedData));
2795
+ }
2796
+ async _post(tableName, data, options, returnPostedData) {
2777
2797
  if (!options)
2778
2798
  options = {
2779
2799
  page: 1,
@@ -2871,7 +2891,7 @@ export default class Inibase {
2871
2891
  // No read-your-writes yet: return the formatted staged rows
2872
2892
  // (ids + defaults) instead of a committed-state read.
2873
2893
  return (Array.isArray(clonedData) ? clonedData : clonedData);
2874
- return this.get(tableName, globalConfig[this.databasePath].tables?.get(tableName)?.config.prepend
2894
+ return await this.get(tableName, globalConfig[this.databasePath].tables?.get(tableName)?.config.prepend
2875
2895
  ? Array.isArray(clonedData)
2876
2896
  ? clonedData.map((_, index) => index + 1).toReversed()
2877
2897
  : 1
@@ -2896,13 +2916,13 @@ export default class Inibase {
2896
2916
  // here so nothing leaks.
2897
2917
  if (!txnStaged && renameList.length)
2898
2918
  await Promise.allSettled(renameList
2899
- .filter((pair) => Boolean(pair[1]))
2919
+ .filter((pair) => Boolean(pair[0]))
2900
2920
  .map(async ([tempPath, _]) => unlink(tempPath)));
2901
2921
  }
2902
2922
  else {
2903
2923
  if (renameList.length)
2904
2924
  await Promise.allSettled(renameList
2905
- .filter((pair) => Boolean(pair[1]))
2925
+ .filter((pair) => Boolean(pair[0]))
2906
2926
  .map(async ([tempPath, _]) => unlink(tempPath)));
2907
2927
  await File.unlock(join(tablePath, ".tmp"));
2908
2928
  }
@@ -2911,6 +2931,12 @@ export default class Inibase {
2911
2931
  async put(tableName, data, where, options = {
2912
2932
  page: 1,
2913
2933
  perPage: 15,
2934
+ }, returnUpdatedData, _whereIsLinesNumbers) {
2935
+ return File.runWithLockStore(() => this._put(tableName, data, where, options, returnUpdatedData, _whereIsLinesNumbers));
2936
+ }
2937
+ async _put(tableName, data, where, options = {
2938
+ page: 1,
2939
+ perPage: 15,
2914
2940
  }, returnUpdatedData, _whereIsLinesNumbers) {
2915
2941
  const renameList = [];
2916
2942
  let txnStaged = false;
@@ -3001,13 +3027,13 @@ export default class Inibase {
3001
3027
  // owns them.
3002
3028
  if (!txnStaged && renameList.length)
3003
3029
  await Promise.allSettled(renameList
3004
- .filter((pair) => Boolean(pair[1]))
3030
+ .filter((pair) => Boolean(pair[0]))
3005
3031
  .map(async ([tempPath, _]) => unlink(tempPath)));
3006
3032
  }
3007
3033
  else {
3008
3034
  if (renameList.length)
3009
3035
  await Promise.allSettled(renameList
3010
- .filter((pair) => Boolean(pair[1]))
3036
+ .filter((pair) => Boolean(pair[0]))
3011
3037
  .map(async ([tempPath, _]) => unlink(tempPath)));
3012
3038
  await File.unlock(join(tablePath, ".tmp"));
3013
3039
  }
@@ -3015,9 +3041,13 @@ export default class Inibase {
3015
3041
  }
3016
3042
  else if (((Array.isArray(where) && where.every(Utils.isNumber)) ||
3017
3043
  Utils.isNumber(where)) &&
3018
- (_whereIsLinesNumbers ||
3019
- !globalConfig[this.databasePath].tables?.get(tableName)?.config
3020
- .decodeID)) {
3044
+ // Numeric where is a line number only through the internal
3045
+ // recursion (flag set). An external numeric where is an id — even
3046
+ // on non-decodeID tables, where ids are NOT line numbers (prepend
3047
+ // layout / rows deleted); using them as lines edited the wrong row
3048
+ // and padded the column file (Sep-2026 rename bug). Ids are
3049
+ // resolved by the next branch.
3050
+ _whereIsLinesNumbers) {
3021
3051
  // "where" in this case, is the line(s) number(s) and not id(s)
3022
3052
  await this.validateTableData(tableName, clonedData, true);
3023
3053
  clonedData = this.formatData(clonedData, globalConfig[this.databasePath].tables?.get(tableName)?.schema ?? [], true);
@@ -3080,42 +3110,63 @@ export default class Inibase {
3080
3110
  if (returnUpdatedData) {
3081
3111
  if (this.transaction)
3082
3112
  throw this.createError("INVALID_PARAMETERS");
3083
- return this.get(tableName, where, options, !Array.isArray(where), undefined, true);
3113
+ // AWAITED readback: must complete inside the writer lock. An
3114
+ // async-tail `return this.get(...)` lets the finally below
3115
+ // unlock before the readback's file reads resume.
3116
+ return await this.get(tableName, where, options, !Array.isArray(where), undefined, true);
3084
3117
  }
3085
3118
  }
3086
3119
  finally {
3087
3120
  if (this.transaction) {
3088
3121
  if (!txnStaged && renameList.length)
3089
3122
  await Promise.allSettled(renameList
3090
- .filter((pair) => Boolean(pair[1]))
3123
+ .filter((pair) => Boolean(pair[0]))
3091
3124
  .map(async ([tempPath, _]) => unlink(tempPath)));
3092
3125
  }
3093
3126
  else {
3094
3127
  if (renameList.length)
3095
3128
  await Promise.allSettled(renameList
3096
- .filter((pair) => Boolean(pair[1]))
3129
+ .filter((pair) => Boolean(pair[0]))
3097
3130
  .map(async ([tempPath, _]) => unlink(tempPath)));
3098
3131
  await File.unlock(join(tablePath, ".tmp"));
3099
3132
  }
3100
3133
  }
3101
3134
  }
3102
3135
  else if ((!_whereIsLinesNumbers &&
3103
- globalConfig[this.databasePath].tables?.get(tableName)?.config
3104
- .decodeID &&
3105
3136
  ((Array.isArray(where) && where.every(Utils.isNumber)) ||
3106
3137
  Utils.isNumber(where))) ||
3107
3138
  (Array.isArray(where) && where.every(Utils.isValidID)) ||
3108
3139
  Utils.isValidID(where)) {
3109
- const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
3110
- if (lineNumbers)
3111
- return this.put(tableName, clonedData,
3112
- // get() with onlyLinesNumbers always returns an array; a
3113
- // single-id update must keep the scalar so the recursive
3114
- // line-numbers branch returns a single row (matching the
3115
- // shape of get(singleId)) instead of a one-element array.
3116
- !Array.isArray(where) && Array.isArray(lineNumbers)
3117
- ? lineNumbers[0]
3118
- : lineNumbers, options, returnUpdatedData, true);
3140
+ // Resolve id -> line(s) WHILE holding the writer lock: a post
3141
+ // landing between the resolution and the write would shift every
3142
+ // line of a prepend table and the update would hit the wrong row.
3143
+ // The recursive call re-takes the lock reentrantly (same task), so
3144
+ // the lock is held continuously from resolution to publish.
3145
+ let inlineLock = false;
3146
+ try {
3147
+ if (this.transaction)
3148
+ await this.ensureTxnLock(tableName);
3149
+ else {
3150
+ await File.lock(join(tablePath, ".tmp"));
3151
+ inlineLock = true;
3152
+ }
3153
+ const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
3154
+ if (lineNumbers)
3155
+ return await this.put(tableName, clonedData,
3156
+ // get() with onlyLinesNumbers always returns an array; a
3157
+ // single-id update must keep the scalar so the recursive
3158
+ // line-numbers branch returns a single row (matching the
3159
+ // shape of get(singleId)) instead of a one-element array.
3160
+ !Array.isArray(where) && Array.isArray(lineNumbers)
3161
+ ? lineNumbers[0]
3162
+ : lineNumbers, options, returnUpdatedData, true);
3163
+ }
3164
+ finally {
3165
+ // The recursion released its own (reentrant) acquisition;
3166
+ // release ours too (transactions keep the lock until commit).
3167
+ if (inlineLock)
3168
+ await File.unlock(join(tablePath, ".tmp"));
3169
+ }
3119
3170
  }
3120
3171
  else if (Utils.isObject(where)) {
3121
3172
  const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
@@ -3133,6 +3184,9 @@ export default class Inibase {
3133
3184
  * @return {boolean | null} {(Promise<boolean | null>)}
3134
3185
  */
3135
3186
  async delete(tableName, where, _whereIsLinesNumbers, _cascadeGuard) {
3187
+ return File.runWithLockStore(() => this._delete(tableName, where, _whereIsLinesNumbers, _cascadeGuard));
3188
+ }
3189
+ async _delete(tableName, where, _whereIsLinesNumbers, _cascadeGuard) {
3136
3190
  this.validateName(tableName);
3137
3191
  if (!this.transaction)
3138
3192
  await this.ensureDatabaseRecovered();
@@ -3181,13 +3235,13 @@ export default class Inibase {
3181
3235
  if (this.transaction) {
3182
3236
  if (!txnStaged && renameList.length)
3183
3237
  await Promise.allSettled(renameList
3184
- .filter((pair) => Boolean(pair[1]))
3238
+ .filter((pair) => Boolean(pair[0]))
3185
3239
  .map(async ([tempPath, _]) => unlink(tempPath)));
3186
3240
  }
3187
3241
  else {
3188
3242
  if (renameList.length)
3189
3243
  await Promise.allSettled(renameList
3190
- .filter((pair) => Boolean(pair[1]))
3244
+ .filter((pair) => Boolean(pair[0]))
3191
3245
  .map(async ([tempPath, _]) => unlink(tempPath)));
3192
3246
  await File.unlock(join(tablePath, ".tmp"));
3193
3247
  }
@@ -3195,9 +3249,13 @@ export default class Inibase {
3195
3249
  }
3196
3250
  if (((Array.isArray(where) && where.every(Utils.isNumber)) ||
3197
3251
  Utils.isNumber(where)) &&
3198
- (_whereIsLinesNumbers ||
3199
- !globalConfig[this.databasePath].tables?.get(tableName)?.config
3200
- .decodeID)) {
3252
+ // Numeric where is a line number only through the internal
3253
+ // recursion (flag set). An external numeric where is an id — even
3254
+ // on non-decodeID tables, where ids are NOT line numbers (prepend
3255
+ // layout / rows deleted); using them as lines edited the wrong row
3256
+ // and padded the column file (Sep-2026 rename bug). Ids are
3257
+ // resolved by the next branch.
3258
+ _whereIsLinesNumbers) {
3201
3259
  // "where" in this case, is the line(s) number(s) and not id(s)
3202
3260
  const files = (await readdir(tablePath))?.filter((fileName) => fileName.endsWith(this.getFileExtension(tableName)));
3203
3261
  if (files.length) {
@@ -3263,13 +3321,13 @@ export default class Inibase {
3263
3321
  if (this.transaction) {
3264
3322
  if (!txnStaged && renameList.length)
3265
3323
  await Promise.allSettled(renameList
3266
- .filter((pair) => Boolean(pair[1]))
3324
+ .filter((pair) => Boolean(pair[0]))
3267
3325
  .map(async ([tempPath, _]) => unlink(tempPath)));
3268
3326
  }
3269
3327
  else {
3270
3328
  if (renameList.length)
3271
3329
  await Promise.allSettled(renameList
3272
- .filter((pair) => Boolean(pair[1]))
3330
+ .filter((pair) => Boolean(pair[0]))
3273
3331
  .map(async ([tempPath, _]) => unlink(tempPath)));
3274
3332
  await File.unlock(join(tablePath, ".tmp"));
3275
3333
  }
@@ -3277,20 +3335,34 @@ export default class Inibase {
3277
3335
  }
3278
3336
  }
3279
3337
  if ((!_whereIsLinesNumbers &&
3280
- globalConfig[this.databasePath].tables?.get(tableName)?.config
3281
- .decodeID &&
3282
3338
  ((Array.isArray(where) && where.every(Utils.isNumber)) ||
3283
3339
  Utils.isNumber(where))) ||
3284
3340
  (Array.isArray(where) && where.every(Utils.isValidID)) ||
3285
3341
  Utils.isValidID(where)) {
3286
- const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
3287
- // Deleting a non-existent id must not fall through to the
3288
- // "delete all rows" branch (this.delete(_, null, _) would truncate
3289
- // the whole table), so resolve the id to line numbers first and
3290
- // only delegate when something actually matched.
3291
- if (lineNumbers)
3292
- return this.delete(tableName, lineNumbers, true, _cascadeGuard ?? new Set());
3293
- return false;
3342
+ // Resolve id -> line(s) WHILE holding the writer lock (see put():
3343
+ // a concurrent post between resolution and delete would shift the
3344
+ // lines of a prepend table and remove the wrong row).
3345
+ let inlineLock = false;
3346
+ try {
3347
+ if (this.transaction)
3348
+ await this.ensureTxnLock(tableName);
3349
+ else {
3350
+ await File.lock(join(tablePath, ".tmp"));
3351
+ inlineLock = true;
3352
+ }
3353
+ const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
3354
+ // Deleting a non-existent id must not fall through to the
3355
+ // "delete all rows" branch (this.delete(_, null, _) would truncate
3356
+ // the whole table), so resolve the id to line numbers first and
3357
+ // only delegate when something actually matched.
3358
+ if (lineNumbers)
3359
+ return await this.delete(tableName, lineNumbers, true, _cascadeGuard ?? new Set());
3360
+ return false;
3361
+ }
3362
+ finally {
3363
+ if (inlineLock)
3364
+ await File.unlock(join(tablePath, ".tmp"));
3365
+ }
3294
3366
  }
3295
3367
  if (Utils.isObject(where)) {
3296
3368
  const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "inibase",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "type": "module",
5
5
  "author": {
6
6
  "name": "Karim Amahtil",
@@ -88,7 +88,7 @@
88
88
  "benchmark": "./benchmark/run.js",
89
89
  "benchmark:durability": "tsx ./benchmark/durability.ts",
90
90
  "benchmark:computed": "tsx ./benchmark/computed.ts",
91
- "test": "tsx ./tests/inibase.test.ts && tsx ./tests/inibase.advanced.test.ts && tsx ./tests/transaction.test.ts && tsx ./tests/durability.test.ts && tsx ./tests/expression.test.ts && tsx ./tests/utils.test.ts && tsx ./tests/sum.nested.test.ts && tsx ./tests/computed.child.test.ts",
91
+ "test": "tsx ./tests/inibase.test.ts && tsx ./tests/inibase.advanced.test.ts && tsx ./tests/transaction.test.ts && tsx ./tests/durability.test.ts && tsx ./tests/expression.test.ts && tsx ./tests/utils.test.ts && tsx ./tests/sum.nested.test.ts && tsx ./tests/computed.child.test.ts && tsx ./tests/hardening.test.ts",
92
92
  "test:durability": "tsx ./tests/durability.test.ts",
93
93
  "test:transaction": "tsx ./tests/transaction.test.ts",
94
94
  "test:utils": "tsx ./tests/utils.test.ts",