inibase 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -206,7 +206,7 @@ interface {
206
206
 
207
207
  ```ts
208
208
  interface Field {
209
- id: number; // stored as a Number but displayed as a hashed ID
209
+ id: number; // position in the schema; 0 = the row id, -1 = createdAt, -2 = updatedAt
210
210
  key: string;
211
211
  required?: boolean;
212
212
  unique?: boolean | string; // boolean for simple uniqueness, string for grouped uniqueness
@@ -402,6 +402,30 @@ const userTableSchema = [
402
402
  await db.createTable("user", userTableSchema, userTableConfig);
403
403
  ```
404
404
 
405
+ #### `decodeID`
406
+
407
+ By default a row's `id` is stored as a number but only ever handed back as an
408
+ encrypted (`aes-256-cbc`, hex) string, so ids can't be guessed by incrementing.
409
+ Set `decodeID: true` and the id is exposed as the **plain number** everywhere —
410
+ `get` rows, `get`/`put`/`delete` by id, relation lookups, and the id returned by
411
+ `post`:
412
+
413
+ ```js
414
+ const db = new Inibase("/databaseName");
415
+ await db.createTable("item", [{ key: "name", type: "string" }], {
416
+ decodeID: true,
417
+ });
418
+
419
+ const id = await db.post("item", { name: "widget" }); // => 1 (a number)
420
+ await db.get("item", 1); // works with the raw number
421
+ ```
422
+
423
+ So `post` returns a `number` on a `decodeID` table and an encrypted `string` on
424
+ every other table — in TypeScript its return type is `string | number`
425
+ (`(string | number)[]` for a bulk post). Encrypted ids stay valid inputs on a
426
+ `decodeID` table too, so existing clients that round-trip an id keep working
427
+ either way. `decodeID` can be toggled later with `updateTable`.
428
+
405
429
  </blockquote>
406
430
  </details>
407
431
 
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
@@ -380,11 +384,16 @@ export default class Inibase {
380
384
  * @param {Options} [options] Pagination options, useful when the returnPostedData param is true
381
385
  * @param {boolean} [returnPostedData] By default function returns void, if you want to get the posted data, set this param to true
382
386
  * @return {*} {Promise<Data | Data[] | null | void>}
387
+ *
388
+ * @remarks Without `returnPostedData` the return value is the new id: a
389
+ * `number` on a table created with `decodeID: true`, an encrypted
390
+ * `string` on every other table.
383
391
  */
384
- post<TData extends Record<string, any> & Partial<Data>>(tableName: string, data: Data & TData, options?: Options, returnPostedData?: boolean): Promise<string>;
385
- post<TData extends Record<string, any> & Partial<Data>>(tableName: string, data: (Data & TData)[], options?: Options, returnPostedData?: boolean): Promise<string[]>;
392
+ post<TData extends Record<string, any> & Partial<Data>>(tableName: string, data: Data & TData, options?: Options, returnPostedData?: boolean): Promise<string | number>;
393
+ post<TData extends Record<string, any> & Partial<Data>>(tableName: string, data: (Data & TData)[], options?: Options, returnPostedData?: boolean): Promise<(string | number)[]>;
386
394
  post<TData extends Record<string, any> & Partial<Data>>(tableName: string, data: Data & TData, options: Options | undefined, returnPostedData: true): Promise<(Data & TData) | null>;
387
395
  post<TData extends Record<string, any> & Partial<Data>>(tableName: string, data: (Data & TData)[], options: Options | undefined, returnPostedData: true): Promise<(Data & TData)[] | null>;
396
+ private _post;
388
397
  /**
389
398
  * Update item(s) in a table
390
399
  *
@@ -399,6 +408,7 @@ export default class Inibase {
399
408
  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
409
  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
410
  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>;
411
+ private _put;
402
412
  /**
403
413
  * Delete item(s) in a table
404
414
  *
@@ -407,6 +417,7 @@ export default class Inibase {
407
417
  * @return {boolean | null} {(Promise<boolean | null>)}
408
418
  */
409
419
  delete(tableName: string, where?: number | string | (number | string)[] | Criteria, _whereIsLinesNumbers?: boolean, _cascadeGuard?: Set<string>): Promise<boolean | null>;
420
+ private _delete;
410
421
  /**
411
422
  * Cascade delete: remove rows in other tables whose `table`-typed schema
412
423
  * fields reference the given rows. Reference columns store the numeric
@@ -424,8 +435,8 @@ export default class Inibase {
424
435
  * @param {(number | string | (number | string)[] | Criteria)} [where]
425
436
  * @return {*} {Promise<number | Record<string, number>>}
426
437
  */
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>>;
438
+ sum(tableName: string, columns: string, where?: number | string | (number | string)[] | Criteria, options?: AggregateOptions): Promise<number>;
439
+ sum(tableName: string, columns: string[], where?: number | string | (number | string)[] | Criteria, options?: AggregateOptions): Promise<Record<string, number>>;
429
440
  /**
430
441
  * Element-wise aggregation over an array-of-objects column (`nested` mode).
431
442
  *
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
@@ -2882,12 +2902,18 @@ export default class Inibase {
2882
2902
  : this.totalItems.get(`${tableName}-*`), options, !Utils.isArrayOfObjects(clonedData), // return only one item if data is not array of objects
2883
2903
  undefined, true);
2884
2904
  }
2905
+ // On a decodeID table the id *is* the plain number — that is the
2906
+ // whole point of the config: get(), put(), delete() and the
2907
+ // returnPostedData read all hand back raw numbers, so post() must
2908
+ // not encode it here. Every other table keeps returning the
2909
+ // encrypted id.
2910
+ const tableConfig = globalConfig[this.databasePath].tables?.get(tableName)?.config;
2911
+ const postedID = (id) => tableConfig?.decodeID === true
2912
+ ? Number(id)
2913
+ : UtilsServer.encodeID(id);
2885
2914
  return Array.isArray(clonedData)
2886
- ? (globalConfig[this.databasePath].tables?.get(tableName)?.config
2887
- .prepend
2888
- ? clonedData.toReversed()
2889
- : clonedData).map(({ id }) => UtilsServer.encodeID(id))
2890
- : UtilsServer.encodeID(clonedData.id);
2915
+ ? (tableConfig?.prepend ? clonedData.toReversed() : clonedData).map(({ id }) => postedID(id))
2916
+ : postedID(clonedData.id);
2891
2917
  }
2892
2918
  finally {
2893
2919
  if (this.transaction) {
@@ -2896,13 +2922,13 @@ export default class Inibase {
2896
2922
  // here so nothing leaks.
2897
2923
  if (!txnStaged && renameList.length)
2898
2924
  await Promise.allSettled(renameList
2899
- .filter((pair) => Boolean(pair[1]))
2925
+ .filter((pair) => Boolean(pair[0]))
2900
2926
  .map(async ([tempPath, _]) => unlink(tempPath)));
2901
2927
  }
2902
2928
  else {
2903
2929
  if (renameList.length)
2904
2930
  await Promise.allSettled(renameList
2905
- .filter((pair) => Boolean(pair[1]))
2931
+ .filter((pair) => Boolean(pair[0]))
2906
2932
  .map(async ([tempPath, _]) => unlink(tempPath)));
2907
2933
  await File.unlock(join(tablePath, ".tmp"));
2908
2934
  }
@@ -2911,6 +2937,12 @@ export default class Inibase {
2911
2937
  async put(tableName, data, where, options = {
2912
2938
  page: 1,
2913
2939
  perPage: 15,
2940
+ }, returnUpdatedData, _whereIsLinesNumbers) {
2941
+ return File.runWithLockStore(() => this._put(tableName, data, where, options, returnUpdatedData, _whereIsLinesNumbers));
2942
+ }
2943
+ async _put(tableName, data, where, options = {
2944
+ page: 1,
2945
+ perPage: 15,
2914
2946
  }, returnUpdatedData, _whereIsLinesNumbers) {
2915
2947
  const renameList = [];
2916
2948
  let txnStaged = false;
@@ -3001,13 +3033,13 @@ export default class Inibase {
3001
3033
  // owns them.
3002
3034
  if (!txnStaged && renameList.length)
3003
3035
  await Promise.allSettled(renameList
3004
- .filter((pair) => Boolean(pair[1]))
3036
+ .filter((pair) => Boolean(pair[0]))
3005
3037
  .map(async ([tempPath, _]) => unlink(tempPath)));
3006
3038
  }
3007
3039
  else {
3008
3040
  if (renameList.length)
3009
3041
  await Promise.allSettled(renameList
3010
- .filter((pair) => Boolean(pair[1]))
3042
+ .filter((pair) => Boolean(pair[0]))
3011
3043
  .map(async ([tempPath, _]) => unlink(tempPath)));
3012
3044
  await File.unlock(join(tablePath, ".tmp"));
3013
3045
  }
@@ -3015,9 +3047,13 @@ export default class Inibase {
3015
3047
  }
3016
3048
  else if (((Array.isArray(where) && where.every(Utils.isNumber)) ||
3017
3049
  Utils.isNumber(where)) &&
3018
- (_whereIsLinesNumbers ||
3019
- !globalConfig[this.databasePath].tables?.get(tableName)?.config
3020
- .decodeID)) {
3050
+ // Numeric where is a line number only through the internal
3051
+ // recursion (flag set). An external numeric where is an id — even
3052
+ // on non-decodeID tables, where ids are NOT line numbers (prepend
3053
+ // layout / rows deleted); using them as lines edited the wrong row
3054
+ // and padded the column file (Sep-2026 rename bug). Ids are
3055
+ // resolved by the next branch.
3056
+ _whereIsLinesNumbers) {
3021
3057
  // "where" in this case, is the line(s) number(s) and not id(s)
3022
3058
  await this.validateTableData(tableName, clonedData, true);
3023
3059
  clonedData = this.formatData(clonedData, globalConfig[this.databasePath].tables?.get(tableName)?.schema ?? [], true);
@@ -3080,42 +3116,63 @@ export default class Inibase {
3080
3116
  if (returnUpdatedData) {
3081
3117
  if (this.transaction)
3082
3118
  throw this.createError("INVALID_PARAMETERS");
3083
- return this.get(tableName, where, options, !Array.isArray(where), undefined, true);
3119
+ // AWAITED readback: must complete inside the writer lock. An
3120
+ // async-tail `return this.get(...)` lets the finally below
3121
+ // unlock before the readback's file reads resume.
3122
+ return await this.get(tableName, where, options, !Array.isArray(where), undefined, true);
3084
3123
  }
3085
3124
  }
3086
3125
  finally {
3087
3126
  if (this.transaction) {
3088
3127
  if (!txnStaged && renameList.length)
3089
3128
  await Promise.allSettled(renameList
3090
- .filter((pair) => Boolean(pair[1]))
3129
+ .filter((pair) => Boolean(pair[0]))
3091
3130
  .map(async ([tempPath, _]) => unlink(tempPath)));
3092
3131
  }
3093
3132
  else {
3094
3133
  if (renameList.length)
3095
3134
  await Promise.allSettled(renameList
3096
- .filter((pair) => Boolean(pair[1]))
3135
+ .filter((pair) => Boolean(pair[0]))
3097
3136
  .map(async ([tempPath, _]) => unlink(tempPath)));
3098
3137
  await File.unlock(join(tablePath, ".tmp"));
3099
3138
  }
3100
3139
  }
3101
3140
  }
3102
3141
  else if ((!_whereIsLinesNumbers &&
3103
- globalConfig[this.databasePath].tables?.get(tableName)?.config
3104
- .decodeID &&
3105
3142
  ((Array.isArray(where) && where.every(Utils.isNumber)) ||
3106
3143
  Utils.isNumber(where))) ||
3107
3144
  (Array.isArray(where) && where.every(Utils.isValidID)) ||
3108
3145
  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);
3146
+ // Resolve id -> line(s) WHILE holding the writer lock: a post
3147
+ // landing between the resolution and the write would shift every
3148
+ // line of a prepend table and the update would hit the wrong row.
3149
+ // The recursive call re-takes the lock reentrantly (same task), so
3150
+ // the lock is held continuously from resolution to publish.
3151
+ let inlineLock = false;
3152
+ try {
3153
+ if (this.transaction)
3154
+ await this.ensureTxnLock(tableName);
3155
+ else {
3156
+ await File.lock(join(tablePath, ".tmp"));
3157
+ inlineLock = true;
3158
+ }
3159
+ const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
3160
+ if (lineNumbers)
3161
+ return await this.put(tableName, clonedData,
3162
+ // get() with onlyLinesNumbers always returns an array; a
3163
+ // single-id update must keep the scalar so the recursive
3164
+ // line-numbers branch returns a single row (matching the
3165
+ // shape of get(singleId)) instead of a one-element array.
3166
+ !Array.isArray(where) && Array.isArray(lineNumbers)
3167
+ ? lineNumbers[0]
3168
+ : lineNumbers, options, returnUpdatedData, true);
3169
+ }
3170
+ finally {
3171
+ // The recursion released its own (reentrant) acquisition;
3172
+ // release ours too (transactions keep the lock until commit).
3173
+ if (inlineLock)
3174
+ await File.unlock(join(tablePath, ".tmp"));
3175
+ }
3119
3176
  }
3120
3177
  else if (Utils.isObject(where)) {
3121
3178
  const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
@@ -3133,6 +3190,9 @@ export default class Inibase {
3133
3190
  * @return {boolean | null} {(Promise<boolean | null>)}
3134
3191
  */
3135
3192
  async delete(tableName, where, _whereIsLinesNumbers, _cascadeGuard) {
3193
+ return File.runWithLockStore(() => this._delete(tableName, where, _whereIsLinesNumbers, _cascadeGuard));
3194
+ }
3195
+ async _delete(tableName, where, _whereIsLinesNumbers, _cascadeGuard) {
3136
3196
  this.validateName(tableName);
3137
3197
  if (!this.transaction)
3138
3198
  await this.ensureDatabaseRecovered();
@@ -3181,13 +3241,13 @@ export default class Inibase {
3181
3241
  if (this.transaction) {
3182
3242
  if (!txnStaged && renameList.length)
3183
3243
  await Promise.allSettled(renameList
3184
- .filter((pair) => Boolean(pair[1]))
3244
+ .filter((pair) => Boolean(pair[0]))
3185
3245
  .map(async ([tempPath, _]) => unlink(tempPath)));
3186
3246
  }
3187
3247
  else {
3188
3248
  if (renameList.length)
3189
3249
  await Promise.allSettled(renameList
3190
- .filter((pair) => Boolean(pair[1]))
3250
+ .filter((pair) => Boolean(pair[0]))
3191
3251
  .map(async ([tempPath, _]) => unlink(tempPath)));
3192
3252
  await File.unlock(join(tablePath, ".tmp"));
3193
3253
  }
@@ -3195,9 +3255,13 @@ export default class Inibase {
3195
3255
  }
3196
3256
  if (((Array.isArray(where) && where.every(Utils.isNumber)) ||
3197
3257
  Utils.isNumber(where)) &&
3198
- (_whereIsLinesNumbers ||
3199
- !globalConfig[this.databasePath].tables?.get(tableName)?.config
3200
- .decodeID)) {
3258
+ // Numeric where is a line number only through the internal
3259
+ // recursion (flag set). An external numeric where is an id — even
3260
+ // on non-decodeID tables, where ids are NOT line numbers (prepend
3261
+ // layout / rows deleted); using them as lines edited the wrong row
3262
+ // and padded the column file (Sep-2026 rename bug). Ids are
3263
+ // resolved by the next branch.
3264
+ _whereIsLinesNumbers) {
3201
3265
  // "where" in this case, is the line(s) number(s) and not id(s)
3202
3266
  const files = (await readdir(tablePath))?.filter((fileName) => fileName.endsWith(this.getFileExtension(tableName)));
3203
3267
  if (files.length) {
@@ -3263,13 +3327,13 @@ export default class Inibase {
3263
3327
  if (this.transaction) {
3264
3328
  if (!txnStaged && renameList.length)
3265
3329
  await Promise.allSettled(renameList
3266
- .filter((pair) => Boolean(pair[1]))
3330
+ .filter((pair) => Boolean(pair[0]))
3267
3331
  .map(async ([tempPath, _]) => unlink(tempPath)));
3268
3332
  }
3269
3333
  else {
3270
3334
  if (renameList.length)
3271
3335
  await Promise.allSettled(renameList
3272
- .filter((pair) => Boolean(pair[1]))
3336
+ .filter((pair) => Boolean(pair[0]))
3273
3337
  .map(async ([tempPath, _]) => unlink(tempPath)));
3274
3338
  await File.unlock(join(tablePath, ".tmp"));
3275
3339
  }
@@ -3277,20 +3341,34 @@ export default class Inibase {
3277
3341
  }
3278
3342
  }
3279
3343
  if ((!_whereIsLinesNumbers &&
3280
- globalConfig[this.databasePath].tables?.get(tableName)?.config
3281
- .decodeID &&
3282
3344
  ((Array.isArray(where) && where.every(Utils.isNumber)) ||
3283
3345
  Utils.isNumber(where))) ||
3284
3346
  (Array.isArray(where) && where.every(Utils.isValidID)) ||
3285
3347
  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;
3348
+ // Resolve id -> line(s) WHILE holding the writer lock (see put():
3349
+ // a concurrent post between resolution and delete would shift the
3350
+ // lines of a prepend table and remove the wrong row).
3351
+ let inlineLock = false;
3352
+ try {
3353
+ if (this.transaction)
3354
+ await this.ensureTxnLock(tableName);
3355
+ else {
3356
+ await File.lock(join(tablePath, ".tmp"));
3357
+ inlineLock = true;
3358
+ }
3359
+ const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
3360
+ // Deleting a non-existent id must not fall through to the
3361
+ // "delete all rows" branch (this.delete(_, null, _) would truncate
3362
+ // the whole table), so resolve the id to line numbers first and
3363
+ // only delegate when something actually matched.
3364
+ if (lineNumbers)
3365
+ return await this.delete(tableName, lineNumbers, true, _cascadeGuard ?? new Set());
3366
+ return false;
3367
+ }
3368
+ finally {
3369
+ if (inlineLock)
3370
+ await File.unlock(join(tablePath, ".tmp"));
3371
+ }
3294
3372
  }
3295
3373
  if (Utils.isObject(where)) {
3296
3374
  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.4.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",