inibase 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Inibase :pencil:
2
2
 
3
- > A file-based & memory-efficient, serverless, ACID compliant, relational database management system :fire:
3
+ > A file-based & memory-efficient, serverless relational database with **crash-atomic ACID: single-table DML + multi-table transactions** :fire: — per-table writer locks, write-ahead journal + crash recovery, fsync-backed durability, and a `begin`/`commit`/`rollback` transaction API (exact scope in [Durability & crash safety](#durability--crash-safety)).
4
4
 
5
5
  [![Inibase banner](./.github/assets/banner.jpg)](https://github.com/inicontent/inibase)
6
6
 
@@ -12,7 +12,7 @@
12
12
  - **Minimalist** :white_circle: (but powerful)
13
13
  - **100% TypeScript** :large_blue_diamond:
14
14
  - **Super-Fast** :zap: (built-in caching system)
15
- - **ATOMIC** :lock: File lock for writing
15
+ - **ATOMIC** :lock: Per-table writer locks, write-ahead journal + crash recovery, fsync-backed durability, and multi-table transactions (`begin`/`commit`/`rollback`) for atomic cascades (exact scope below)
16
16
  - **Built-in** form validation (+unique values :new: ) :sunglasses:
17
17
  - **Suitable for large data** :page_with_curl: (tested with 4M records)
18
18
  - **Support Compression** :eight_spoked_asterisk: (using built-in nodejs zlib)
@@ -75,6 +75,98 @@ const users = await db.get("user", { favoriteFoods: "![]Pizza,Burger" });
75
75
 
76
76
  This structure ensures efficient storage, retrieval, and updates, making our system scalable and high-performing for diverse datasets and applications.
77
77
 
78
+ ## Durability & crash safety
79
+
80
+ > [!IMPORTANT]
81
+ > **DML (post / put / delete / truncate) is crash-atomic per table.** A logical
82
+ > operation spans many column files plus a pagination-metadata file; all of
83
+ > those files are committed as one journaled unit, so a table is never observed
84
+ > half-applied. **Multi-table atomicity** is available explicitly through the
85
+ > transaction API (`begin` / `commit` / `rollback`, see below) — cascade
86
+ > deletes and multi-table writes are atomic *inside* a transaction, and
87
+ > best-effort without one.
88
+
89
+ ### How each property is provided
90
+
91
+ | Property | Mechanism |
92
+ |---|---|
93
+ | **A**tomicity | Write-ahead journal: one journal per table (`.tmp/journal.jsonl`) for single-table DML, plus a database journal (`<db>/.tmp/journal.jsonl`) that transactions append `op` entries to. Intent is fsynced *before* any live file changes; a `commit` marker is fsynced after publication. On crash: no commit marker → roll back (restore backups), commit present → roll forward (complete swaps, discard backups). Recovery runs automatically under the lock before any mutation/read. |
94
+ | **C**onsistency | Schema validation + uniqueness enforcement happen before anything is written; every operation is atomic, so a table is never observed half-applied. |
95
+ | **I**solation | **One writer lock per table** serializes every mutation (single-host and multi-host / NFS); a transaction holds the writer lock of every table it touches for its whole lifetime, plus a database lock to serialize transactions. Reads are **lock-free** with optimistic version-retry: each read snapshots the identity of every column + pagination file, and is re-run if anything changed mid-scan — a reader never sees torn rows. |
96
+ | **D**urability | Durability knob `INIBASE_DURABILITY=full` (default) fsyncs the temp file, the journal (begin/commit), and the affected directories (incl. shell `sed`/`gzip` temp paths and stream pipelines). DDL (create/update table, compression & prepend toggles) is fsynced too. `INIBASE_DURABILITY=none` skips **every** fsync while keeping the exact journal protocol — the data is process-crash safe (a crash leaves an in-flight journal that recovery rolls back or forward) but **not** power-loss safe, because the OS page cache can be lost. |
97
+
98
+ **Commit-point ordering:** the pagination metadata rename happens *first* (the atomic publication point — the row count flips in a single rename that lock-free readers observe), then column files are swapped `live → backup` + `tmp → live`.
99
+
100
+ ### Transactions (multi-table atomicity)
101
+
102
+ ```js
103
+ await db.begin(["orders", "invoices"]); // pre-lock tables in sorted order (deadlock-free)
104
+ try {
105
+ await db.post("orders", order);
106
+ await db.post("invoices", invoice);
107
+ await db.commit(); // publish everything crash-atomically
108
+ } catch {
109
+ await db.rollback(); // discard everything; no live file was touched
110
+ }
111
+ ```
112
+
113
+ - **Semantics:** mutations issued inside a transaction are *staged* — their
114
+ temps are fsynced and an `op` entry appended to the database journal — but no
115
+ live file changes until `commit()`. `commit()` publishes every staged
116
+ mutation (per table: pagination rename first, then column swaps) and ends
117
+ with a single fsynced `commit` marker, making the whole set atomic with
118
+ respect to crashes and process kills. `rollback()` removes the temps and the
119
+ journal without touching any live file.
120
+ - **Cascade:** a `delete` inside a transaction cascades into referencing tables
121
+ *through the same journal*, so the whole parent + children removal is atomic;
122
+ a failure during the cascade aborts the transaction.
123
+ - **Rules & limits (v1):**
124
+ - **No read-your-writes:** reads inside a transaction observe the last
125
+ committed state, not staged writes.
126
+ - **One mutation per table per transaction.** A second post/put/delete on a
127
+ table already staged in the same transaction throws `INVALID_PARAMETERS`
128
+ (composing same-table writes would require read-your-writes).
129
+ - **DDL is not allowed inside a transaction** (`createTable` / `updateTable`
130
+ throw `INVALID_PARAMETERS`).
131
+ - `begin` without a table list locks tables on first touch, in first-touch
132
+ order; list the tables up front to get sorted, cycle-free ordering.
133
+ - `returnPostedData` inside a transaction returns the formatted staged rows
134
+ (no joins); `returnUpdatedData` inside a transaction throws
135
+ `INVALID_PARAMETERS`.
136
+ - Transactions are excluded from structural lock-upgrade deadlocks, but two
137
+ long transactions that touch overlapping tables can only deadlock in the
138
+ first-touch-order case (pre-listing avoids it).
139
+
140
+ ### Scope & caveats
141
+
142
+ - **Best-effort without a transaction.** Cross-table cascade deletes outside a
143
+ transaction stay best-effort: referencing rows are removed *after* the
144
+ primary delete commits, and a crash in between can leave orphaned references.
145
+ Wrap them in `begin`/`commit` for atomicity.
146
+ - **NFS:** lock files use `O_EXCL` creation, which is advisory on some NFS
147
+ servers — two hosts may briefly both believe they hold a lock. Locks store
148
+ `{pid, host, startedAt}`: a same-host owner that is **provably dead**
149
+ (`kill(pid, 0)` fails) is stolen immediately; foreign/unknown owners fall
150
+ back to the TTL (`INIBASE_LOCK_TTL_MS`, default `60000`).
151
+ - **fsync guarantees:** durability assumes the OS/filesystem honours `fsync`.
152
+ Some disks and virtualized filesystems silently ignore it; directory `fsync`
153
+ is unsupported on a few platforms (best-effort there). `none` mode
154
+ deliberately gives up power-loss durability — that is the whole point of the
155
+ knob.
156
+ - **Cost:** at `full`, every mutation fsyncs the temp file, journal, and
157
+ directories — expect noticeably slower hot-path writes than pre-durability
158
+ builds (the benchmark compares `full` vs `none` side by side). The swap
159
+ protocol also transiently holds temp + backup + live copies (~2–3× a
160
+ column's size; worst case is a `put` with no `where` — a full-table rewrite).
161
+ - **Cache (`.cache`):** entries are derived, rebuildable artifacts versioned by
162
+ the row count; they detect staleness but never participate in the ACID
163
+ guarantee. Non-fsync'd.
164
+
165
+ Run `pnpm test:durability` for the journal-recovery, multi-process writer,
166
+ live-reader and stale-lock test suite, `pnpm test:transaction` for the
167
+ transaction/cascade/crash-recovery suite, and `pnpm benchmark:durability` for
168
+ the `full` vs `none` throughput comparison.
169
+
78
170
  ## Inibase CLI
79
171
 
80
172
  ```shell
@@ -793,6 +885,9 @@ await db.get("user", undefined, { sort: {age: -1, username: "asc"} });
793
885
  > Default testing uses a table with username, email, and password fields, ensuring password encryption is included in the process<br>
794
886
  > Results are measured on a default table plus dedicated tables with `prepend`, `compression`, and `decodeID` configs enabled<br>
795
887
  > To run benchmarks, install _typescript_ & _[tsx](https://github.com/privatenumber/tsx)_ globally and run `benchmark` by default bulk, for single use `benchmark --single|-s`
888
+ >
889
+ > > [!WARNING]
890
+ > > The numbers above were measured **before** always-on fsync + write-ahead journaling landed (they no longer reflect current hot-path write costs). Run `pnpm benchmark:durability` for the crash-atomic numbers.
796
891
 
797
892
  ## Roadmap
798
893
 
package/dist/file.d.ts CHANGED
@@ -1,7 +1,26 @@
1
1
  import { type ComparisonOperator, type Field } from "./index.js";
2
- export declare const lock: (folderPath: string, prefix?: string) => Promise<void>;
2
+ export declare const DURABLE: boolean;
3
+ export declare const lock: (folderPath: string, prefix?: string, ttl?: number) => Promise<void>;
4
+ /**
5
+ * Non-blocking lock acquisition (used by the read path and the open-time
6
+ * recovery sweep). Returns true when the lock was acquired (running crash
7
+ * recovery for prefix-less locks, exactly like `lock`), false when another
8
+ * process holds it. A single stale-lock steal (dead same-host owner, or aged
9
+ * foreign/unknown owner) is attempted so a crashed owner can't wedge readers
10
+ * behind it forever.
11
+ */
12
+ export declare const tryLock: (folderPath: string, prefix?: string, ttl?: number) => Promise<boolean>;
3
13
  export declare const unlock: (folderPath: string, prefix?: string) => Promise<void>;
4
14
  export declare const write: (filePath: string, data: any) => Promise<void>;
15
+ /**
16
+ * fsync an existing file. Used to flush temp files (written via streams or
17
+ * shell pipelines) before they are renamed into place.
18
+ */
19
+ export declare const syncFile: (filePath: string) => Promise<void>;
20
+ /**
21
+ * fsync a directory so that renames performed inside it are durable.
22
+ */
23
+ export declare const syncDir: (dirPath: string) => Promise<void>;
5
24
  export declare const read: (filePath: string) => Promise<string>;
6
25
  export declare function escapeShellPath(filePath: string): string;
7
26
  /**
@@ -113,6 +132,7 @@ export declare const search: (filePath: string, operator: ComparisonOperator | C
113
132
  databasePath?: string;
114
133
  }, limit?: number, offset?: number, readWholeFile?: boolean) => Promise<[Record<number, string | number | boolean | null | undefined | (string | number | boolean | null)[]> | null, number, Set<number> | null]>;
115
134
  export declare const sum: (fp: string, ln?: number | number[]) => Promise<number>;
135
+ export declare const avg: (fp: string, ln?: number | number[]) => Promise<number>;
116
136
  export declare const min: (fp: string, ln?: number | number[]) => Promise<number>;
117
137
  export declare const max: (fp: string, ln?: number | number[]) => Promise<number>;
118
138
  export declare const getFileDate: (path: string) => Promise<Date>;
package/dist/file.js CHANGED
@@ -1,4 +1,5 @@
1
- import { access, appendFile, copyFile, constants as fsConstants, open, readFile, stat, unlink, writeFile, } from "node:fs/promises";
1
+ import { access, appendFile, copyFile, constants as fsConstants, open, readFile, stat, unlink, } from "node:fs/promises";
2
+ import { hostname } from "node:os";
2
3
  import { join, resolve } from "node:path";
3
4
  import { createInterface } from "node:readline";
4
5
  import { Transform } from "node:stream";
@@ -6,37 +7,231 @@ import { pipeline } from "node:stream/promises";
6
7
  import { createGunzip, createGzip } from "node:zlib";
7
8
  import Inison from "inison";
8
9
  import { globalConfig, } from "./index.js";
10
+ import { recover } from "./journal.js";
9
11
  import { detectFieldType, isArrayOfObjects, isNumber, isObject, isStringified, isValidID, } from "./utils.js";
10
12
  import { compare, decodeID, encodeID, exec, gunzip, gzip, } from "./utils.server.js";
11
- // Locks older than this are assumed abandoned by a crashed/killed process, not a slow operation.
12
- const STALE_LOCK_MS = 30_000;
13
- export const lock = async (folderPath, prefix) => {
14
- let lockFile = null;
15
- const lockFilePath = join(folderPath, `${prefix ?? ""}.locked`);
13
+ // Locks older than this are candidates for removal. Same-host locks are only
14
+ // stolen when the recorded owner PID is provably dead; foreign-host locks fall
15
+ // back to this TTL (NFS has no reliable cross-host liveness check).
16
+ const DEFAULT_LOCK_TTL_MS = Number(process.env.INIBASE_LOCK_TTL_MS ?? 60_000);
17
+ // Durability knob: `full` (default) fsyncs every temp file, journal entry and
18
+ // directory touch before acknowledging a mutation. `none` skips all fsync
19
+ // calls but keeps the write-ahead journal protocol unchanged, so a *process*
20
+ // crash (page cache survives) still recovers atomically — only power-loss
21
+ // durability is lost. The ACID claim documented in the README holds at `full`.
22
+ const DURABILITY = process.env.INIBASE_DURABILITY ?? "full";
23
+ export const DURABLE = DURABILITY !== "none";
24
+ /** fsync a handle, or no-op when `INIBASE_DURABILITY=none` is set. */
25
+ const maybeSync = async (handle) => {
26
+ if (DURABLE)
27
+ await handle.sync();
28
+ };
29
+ const LOCK_RETRY_MS = 13;
30
+ // Per-process reentrancy: the same process may acquire the same lock file
31
+ // multiple times (e.g. post() -> get() -> sort-cache). Depth 1 is a real
32
+ // filesystem acquisition; deeper acquisitions just bump the counter.
33
+ const lockDepths = new Map();
34
+ const lockFilePathFor = (folderPath, prefix) => join(folderPath, `${prefix ?? ""}.locked`);
35
+ const lockOwnerState = async (lockFilePath) => {
16
36
  try {
17
- lockFile = await open(lockFilePath, "wx");
37
+ const metadata = JSON.parse(await readFile(lockFilePath, "utf8"));
38
+ if (typeof metadata.pid === "number" && metadata.host === hostname()) {
39
+ try {
40
+ process.kill(metadata.pid, 0);
41
+ }
42
+ catch (error) {
43
+ return error?.code === "ESRCH" ? "dead" : "unknown";
44
+ }
45
+ return "alive";
46
+ }
47
+ }
48
+ catch {
49
+ // No/invalid metadata (e.g. crash between create and metadata write).
50
+ }
51
+ return "unknown"; // foreign host or unparsable metadata
52
+ };
53
+ /**
54
+ * A lock may be stolen when its owner is provably dead on this host
55
+ * (immediately — a same-host crash must not wedge writers or block crash
56
+ * recovery) or, for foreign/unknown owners where no liveness check exists
57
+ * (e.g. NFS), once the recorded lock is older than the TTL.
58
+ */
59
+ const stealableLock = async (lockFilePath, mtimeMs, ttl) => {
60
+ const state = await lockOwnerState(lockFilePath);
61
+ if (state === "alive")
62
+ return false;
63
+ if (state === "dead")
64
+ return true;
65
+ return Date.now() - mtimeMs > ttl;
66
+ };
67
+ export const lock = async (folderPath, prefix, ttl = DEFAULT_LOCK_TTL_MS) => {
68
+ const lockFilePath = lockFilePathFor(folderPath, prefix);
69
+ const resolvedPath = resolve(lockFilePath);
70
+ const depth = lockDepths.get(resolvedPath);
71
+ if (depth) {
72
+ lockDepths.set(resolvedPath, depth + 1);
18
73
  return;
19
74
  }
20
- catch ({ message }) {
21
- if (message.split(":")[0] === "EEXIST") {
75
+ for (;;) {
76
+ try {
77
+ const lockFile = await open(lockFilePath, "wx");
78
+ try {
79
+ await lockFile.writeFile(JSON.stringify({
80
+ pid: process.pid,
81
+ host: hostname(),
82
+ startedAt: Date.now(),
83
+ }));
84
+ await maybeSync(lockFile);
85
+ }
86
+ finally {
87
+ await lockFile.close();
88
+ }
89
+ // The global (prefix-less) lock is the writer lock: run crash
90
+ // recovery for this table while we exclusively hold it. Locked
91
+ // calls with a prefix are read-side helpers (e.g. sort cache) and
92
+ // must never roll back an in-flight transaction.
93
+ if (!prefix) {
94
+ try {
95
+ await recover(folderPath);
96
+ }
97
+ catch {
98
+ // Recovery is best-effort at lock time; reads retry on
99
+ // torn state, writers re-check before mutating.
100
+ }
101
+ }
102
+ lockDepths.set(resolvedPath, 1);
103
+ return;
104
+ }
105
+ catch (error) {
106
+ const message = String(error?.message ?? error);
107
+ if (message.split(":")[0] !== "EEXIST")
108
+ throw error;
22
109
  const lockStat = await stat(lockFilePath).catch(() => null);
23
- if (lockStat && Date.now() - lockStat.mtimeMs > STALE_LOCK_MS)
110
+ // Someone else released the lock between our failed open and the
111
+ // stat: retry immediately instead of counting down the TTL.
112
+ if (!lockStat)
113
+ continue;
114
+ if (await stealableLock(lockFilePath, lockStat.mtimeMs, ttl)) {
24
115
  await unlink(lockFilePath).catch(() => { });
25
- return await new Promise((resolve) => setTimeout(() => resolve(lock(folderPath, prefix)), 13));
116
+ }
117
+ await new Promise((resolvePromise) => setTimeout(() => resolvePromise(), LOCK_RETRY_MS));
26
118
  }
27
119
  }
28
- finally {
29
- await lockFile?.close();
120
+ };
121
+ /**
122
+ * Non-blocking lock acquisition (used by the read path and the open-time
123
+ * recovery sweep). Returns true when the lock was acquired (running crash
124
+ * recovery for prefix-less locks, exactly like `lock`), false when another
125
+ * process holds it. A single stale-lock steal (dead same-host owner, or aged
126
+ * foreign/unknown owner) is attempted so a crashed owner can't wedge readers
127
+ * behind it forever.
128
+ */
129
+ export const tryLock = async (folderPath, prefix, ttl = DEFAULT_LOCK_TTL_MS) => {
130
+ const lockFilePath = lockFilePathFor(folderPath, prefix);
131
+ const resolvedPath = resolve(lockFilePath);
132
+ const depth = lockDepths.get(resolvedPath);
133
+ if (depth) {
134
+ lockDepths.set(resolvedPath, depth + 1);
135
+ return true;
136
+ }
137
+ for (let attempt = 0; attempt < 2; attempt++) {
138
+ try {
139
+ const lockFile = await open(lockFilePath, "wx");
140
+ try {
141
+ await lockFile.writeFile(JSON.stringify({
142
+ pid: process.pid,
143
+ host: hostname(),
144
+ startedAt: Date.now(),
145
+ }));
146
+ await maybeSync(lockFile);
147
+ }
148
+ finally {
149
+ await lockFile.close();
150
+ }
151
+ if (!prefix) {
152
+ try {
153
+ await recover(folderPath);
154
+ }
155
+ catch {
156
+ // Best-effort at lock time, mirroring `lock`.
157
+ }
158
+ }
159
+ lockDepths.set(resolvedPath, 1);
160
+ return true;
161
+ }
162
+ catch (error) {
163
+ if (String(error?.message ?? error).split(":")[0] !== "EEXIST")
164
+ throw error;
165
+ const lockStat = await stat(lockFilePath).catch(() => null);
166
+ if (!lockStat)
167
+ continue; // released between open and stat
168
+ if (attempt === 0 &&
169
+ (await stealableLock(lockFilePath, lockStat.mtimeMs, ttl)))
170
+ await unlink(lockFilePath).catch(() => { });
171
+ else
172
+ return false; // genuinely held -> don't wait
173
+ }
30
174
  }
175
+ return false;
31
176
  };
32
177
  export const unlock = async (folderPath, prefix) => {
178
+ const lockFilePath = lockFilePathFor(folderPath, prefix);
179
+ const resolvedPath = resolve(lockFilePath);
180
+ const depth = lockDepths.get(resolvedPath);
181
+ if (depth && depth > 1) {
182
+ lockDepths.set(resolvedPath, depth - 1);
183
+ return;
184
+ }
185
+ lockDepths.delete(resolvedPath);
33
186
  try {
34
- await unlink(join(folderPath, `${prefix ?? ""}.locked`));
187
+ await unlink(lockFilePath);
188
+ }
189
+ catch {
190
+ // Already released (stolen by a foreign-host stealer or cleaned up).
35
191
  }
36
- catch { }
37
192
  };
38
193
  export const write = async (filePath, data) => {
39
- await writeFile(filePath, filePath.endsWith(".gz") ? await gzip(data) : data);
194
+ const handle = await open(filePath, "w");
195
+ try {
196
+ await handle.writeFile(filePath.endsWith(".gz") ? await gzip(data) : data);
197
+ await maybeSync(handle);
198
+ }
199
+ finally {
200
+ await handle.close();
201
+ }
202
+ };
203
+ /**
204
+ * fsync an existing file. Used to flush temp files (written via streams or
205
+ * shell pipelines) before they are renamed into place.
206
+ */
207
+ export const syncFile = async (filePath) => {
208
+ let handle = null;
209
+ try {
210
+ handle = await open(filePath, "r+");
211
+ await maybeSync(handle);
212
+ }
213
+ catch {
214
+ // Unsupported filesystem/platform: best effort.
215
+ }
216
+ finally {
217
+ await handle?.close();
218
+ }
219
+ };
220
+ /**
221
+ * fsync a directory so that renames performed inside it are durable.
222
+ */
223
+ export const syncDir = async (dirPath) => {
224
+ let handle = null;
225
+ try {
226
+ handle = await open(dirPath, "r");
227
+ await maybeSync(handle);
228
+ }
229
+ catch {
230
+ // Directory fsync is not supported on every platform/filesystem.
231
+ }
232
+ finally {
233
+ await handle?.close();
234
+ }
40
235
  };
41
236
  export const read = async (filePath) => filePath.endsWith(".gz")
42
237
  ? (await gunzip(await readFile(filePath, "utf8"))).toString()
@@ -132,7 +327,9 @@ const unSecureString = (input) => {
132
327
  // Fast path: the common case has no `\n` escape sequence, so avoid
133
328
  // allocating a replacement string (and a fresh RegExp) per cell.
134
329
  if (typeof input === "string")
135
- return input.includes("\\n") ? input.replaceAll("\\n", "\n") || null : input;
330
+ return input.includes("\\n")
331
+ ? input.replaceAll("\\n", "\n") || null
332
+ : input;
136
333
  return null;
137
334
  };
138
335
  /**
@@ -831,11 +1028,11 @@ export const search = async (filePath, operator, comparedAtValue, logicalOperato
831
1028
  }
832
1029
  };
833
1030
  /**
834
- * Reads the file once and returns either the sum, min or max of the
1031
+ * Reads the file once and returns either the sum, average, min or max of the
835
1032
  * (optionally-selected) numeric lines.
836
1033
  *
837
1034
  * @param filePath Absolute path of the column file (may be .gz-compressed).
838
- * @param wanted Metric to compute: "sum" (default), "min" or "max".
1035
+ * @param wanted Metric to compute: "sum" (default), "avg", "min" or "max".
839
1036
  * @param lineNumbers Specific line-number(s) to restrict the scan to.
840
1037
  *
841
1038
  * @returns Promise<number> The requested metric, or 0 if no numeric value found.
@@ -864,7 +1061,7 @@ async function reduceNumbers(filePath, wanted = "sum", lineNumbers) {
864
1061
  if (Number.isNaN(num))
865
1062
  continue;
866
1063
  processed++;
867
- if (wanted === "sum") {
1064
+ if (wanted === "sum" || wanted === "avg") {
868
1065
  sum += num;
869
1066
  }
870
1067
  else if (wanted === "min") {
@@ -885,10 +1082,17 @@ async function reduceNumbers(filePath, wanted = "sum", lineNumbers) {
885
1082
  }
886
1083
  if (processed === 0)
887
1084
  return 0; // nothing numeric found
888
- return wanted === "sum" ? sum : wanted === "min" ? min : max;
1085
+ return wanted === "sum"
1086
+ ? sum
1087
+ : wanted === "avg"
1088
+ ? sum / processed
1089
+ : wanted === "min"
1090
+ ? min
1091
+ : max;
889
1092
  }
890
1093
  /* Optional convenience wrappers (signatures unchanged) */
891
1094
  export const sum = (fp, ln) => reduceNumbers(fp, "sum", ln);
1095
+ export const avg = (fp, ln) => reduceNumbers(fp, "avg", ln);
892
1096
  export const min = (fp, ln) => reduceNumbers(fp, "min", ln);
893
1097
  export const max = (fp, ln) => reduceNumbers(fp, "max", ln);
894
1098
  export const getFileDate = (path) => stat(path)
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import "dotenv/config";
2
+ import { type JournalFileOp } from "./journal.js";
2
3
  export interface Data {
3
4
  id?: string | number;
4
5
  [key: string]: any;
@@ -33,6 +34,27 @@ export interface TableObject {
33
34
  schema?: Schema;
34
35
  config: TableConfig;
35
36
  }
37
+ /**
38
+ * Per-table state maintained while a database transaction is open: the writer
39
+ * lock is held for the whole transaction, pagination state is staged in
40
+ * memory (the live files only change at commit()), and `staged` collects the
41
+ * journaled ops that commit() publishes (one per table per transaction).
42
+ */
43
+ export interface TxnTableEntry {
44
+ locked: boolean;
45
+ /** Live pagination path the first staged op builds on. */
46
+ paginationFrom: string;
47
+ /** Staged last-id / row count (chained across ops in the txn). */
48
+ lastId: number;
49
+ total: number;
50
+ staged: {
51
+ ops: JournalFileOp[];
52
+ pagination: {
53
+ from: string;
54
+ to: string;
55
+ } | null;
56
+ }[];
57
+ }
36
58
  export type ComparisonOperator = "=" | "!=" | ">" | "<" | ">=" | "<=" | "*" | "!*" | "[]" | "![]";
37
59
  export type pageInfo = {
38
60
  total?: number;
@@ -81,6 +103,13 @@ export default class Inibase {
81
103
  private databasePath;
82
104
  private uniqueMap;
83
105
  private schemaFileExtension;
106
+ /**
107
+ * Open database transaction (see begin/commit/rollback). Holds the
108
+ * database lock (`<db>/.tmp/.locked`) for its whole lifetime and the
109
+ * per-table writer lock of every table it mutates, so mutations stage into
110
+ * the database journal and publish only on commit().
111
+ */
112
+ private transaction;
84
113
  constructor(database: string, mainFolder?: string, language?: ErrorLang);
85
114
  createError(name: ErrorCode, variable?: string | number | (string | number)[]): Error;
86
115
  private validateName;
@@ -116,6 +145,7 @@ export default class Inibase {
116
145
  updateTable(tableName: string, schema?: Schema, config?: TableConfig & {
117
146
  name?: string;
118
147
  }): Promise<void>;
148
+ private updateTableLocked;
119
149
  /**
120
150
  * Get table schema and config
121
151
  *
@@ -162,6 +192,89 @@ export default class Inibase {
162
192
  * @param {string} tableName
163
193
  */
164
194
  clearCache(tableName: string): Promise<void>;
195
+ /**
196
+ * Commit a multi-file mutation crash-atomically:
197
+ * 1. fsync every freshly-written temp file;
198
+ * 2. write the journal `begin` entry and fsync it;
199
+ * 3. rename the pagination metadata file first (atomic publication point:
200
+ * the row count flips in a single rename, which is what lock-free
201
+ * readers observe) and then swap each live file aside (backup) and
202
+ * rename the temp into place;
203
+ * 4. write the journal `commit` marker and fsync it;
204
+ * 5. discard backups/temps + the journal, fsync the directories.
205
+ *
206
+ * On any failure before `commit`, the journal is rolled back so the table
207
+ * is left exactly as it was. `renameList` entries are [tempPath, livePath]
208
+ * pairs; a null tempPath means a pure removal (live file taken out).
209
+ */
210
+ private commitFiles;
211
+ /**
212
+ * Runs crash recovery for a table (and any crashed database transaction)
213
+ * before a read. Mutation paths get the same guarantee implicitly (the
214
+ * writer lock runs recovery on acquire); reads call this explicitly
215
+ * because they never take the table lock.
216
+ */
217
+ private ensureTableRecovered;
218
+ /**
219
+ * Blocking database-journal recovery, used by mutation paths (writers are
220
+ * serialized with live transactions on the database lock anyway).
221
+ */
222
+ private ensureDatabaseRecovered;
223
+ private ensureDatabaseTmpDir;
224
+ /** Staged per-table entry of the open transaction, or null when none. */
225
+ private txnTableEntry;
226
+ /** Lock a table for the open transaction (idempotent per transaction). */
227
+ private ensureTxnLock;
228
+ /**
229
+ * Resolve the pagination state a DML op should build on. Outside a
230
+ * transaction this reads the live pagination file (as before); inside a
231
+ * transaction the first touch reads it once and the entry keeps the staged
232
+ * id/count so chained ops (guarded to one per table) and commit() stay
233
+ * consistent without publishing anything early.
234
+ */
235
+ private resolvePagination;
236
+ /**
237
+ * Stage one table mutation into the open transaction: fsync its temps and
238
+ * append an `op` entry to the database journal (no live file is touched;
239
+ * commit() performs the actual renames). One staged mutation per table per
240
+ * transaction (multi-table atomicity; a second touch of the same table
241
+ * would need read-your-writes composition).
242
+ */
243
+ private stageTxnOp;
244
+ /**
245
+ * Begin a database transaction. Mutations issued while the transaction is
246
+ * open (post/put/delete, including cascade deletes) are staged into the
247
+ * database journal and published atomically at commit(); rollback()
248
+ * discards them without touching any live file.
249
+ *
250
+ * @param tables Optional table names to pre-lock at begin() in sorted
251
+ * order (the deadlock-free way to span tables). Tables not listed are
252
+ * locked on first touch, in first-touch order.
253
+ */
254
+ begin(tables?: string[]): Promise<void>;
255
+ /**
256
+ * Publish every staged mutation atomically: per table (sorted), the
257
+ * pagination rename comes first (the atomic publication point readers
258
+ * observe) and then live->backup + tmp->live swaps, before a single fsynced
259
+ * `commit` marker makes the whole transaction durable. A crash at any
260
+ * point is recovered by the journal rule (no marker -> roll back all
261
+ * tables, marker -> roll forward all tables).
262
+ */
263
+ commit(): Promise<void>;
264
+ /**
265
+ * Discard the open transaction: temps and the journal are removed and no
266
+ * live file is touched (nothing is published before commit()).
267
+ */
268
+ rollback(): Promise<void>;
269
+ /**
270
+ * Snapshot the identity (dev:inode:mtime:size) of every column file and
271
+ * the pagination file. Reading data and then re-verifying this snapshot
272
+ * lets lock-free readers detect an in-flight writer commit and retry
273
+ * instead of returning a torn row set.
274
+ */
275
+ private snapshotTableFiles;
276
+ /** True when every snapshotted file is still present and unchanged. */
277
+ private verifyTableFiles;
165
278
  /**
166
279
  * Retrieve item(s) from a table
167
280
  *
@@ -177,6 +290,7 @@ export default class Inibase {
177
290
  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>;
178
291
  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>;
179
292
  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>;
293
+ private getOnce;
180
294
  /**
181
295
  * Create new item(s) in a table
182
296
  *
@@ -231,6 +345,16 @@ export default class Inibase {
231
345
  */
232
346
  sum(tableName: string, columns: string, where?: number | string | (number | string)[] | Criteria): Promise<number>;
233
347
  sum(tableName: string, columns: string[], where?: number | string | (number | string)[] | Criteria): Promise<Record<string, number>>;
348
+ /**
349
+ * Generate average of column(s) in a table
350
+ *
351
+ * @param {string} tableName
352
+ * @param {string} columns
353
+ * @param {(number | string | (number | string)[] | Criteria)} [where]
354
+ * @return {*} {Promise<number | Record<string, number>>}
355
+ */
356
+ avg(tableName: string, columns: string, where?: number | string | (number | string)[] | Criteria): Promise<number>;
357
+ avg(tableName: string, columns: string[], where?: number | string | (number | string)[] | Criteria): Promise<Record<string, number>>;
234
358
  /**
235
359
  * Generate max of column(s) in a table
236
360
  *