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 +97 -2
- package/dist/file.d.ts +21 -1
- package/dist/file.js +225 -21
- package/dist/index.d.ts +124 -0
- package/dist/index.js +770 -153
- package/dist/journal.d.ts +109 -0
- package/dist/journal.js +263 -0
- package/dist/utils.d.ts +2 -1
- package/dist/utils.js +20 -4
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Inibase :pencil:
|
|
2
2
|
|
|
3
|
-
> A file-based & memory-efficient, serverless
|
|
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
|
[](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:
|
|
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
|
|
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,
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
+
}
|
|
117
|
+
await new Promise((resolvePromise) => setTimeout(() => resolvePromise(), LOCK_RETRY_MS));
|
|
26
118
|
}
|
|
27
119
|
}
|
|
28
|
-
|
|
29
|
-
|
|
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(
|
|
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
|
|
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")
|
|
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"
|
|
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
|
*
|