inibase 2.0.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/file.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()
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import "dotenv/config";
2
+ import { type ComputedFieldSpec } from "./expression.js";
3
+ import { type JournalFileOp } from "./journal.js";
2
4
  export interface Data {
3
5
  id?: string | number;
4
6
  [key: string]: any;
@@ -15,6 +17,10 @@ export type Field = {
15
17
  unique?: boolean | number | string;
16
18
  children?: FieldType | FieldType[] | Schema;
17
19
  regex?: string;
20
+ /** Computed-fields expression (see the README). Either a raw expression
21
+ * string, or the persisted `{ expr, ast }` spec written by Inibase once
22
+ * the expression has been compiled. */
23
+ computed?: string | ComputedFieldSpec;
18
24
  };
19
25
  export type Schema = Field[];
20
26
  export interface Options {
@@ -33,6 +39,27 @@ export interface TableObject {
33
39
  schema?: Schema;
34
40
  config: TableConfig;
35
41
  }
42
+ /**
43
+ * Per-table state maintained while a database transaction is open: the writer
44
+ * lock is held for the whole transaction, pagination state is staged in
45
+ * memory (the live files only change at commit()), and `staged` collects the
46
+ * journaled ops that commit() publishes (one per table per transaction).
47
+ */
48
+ export interface TxnTableEntry {
49
+ locked: boolean;
50
+ /** Live pagination path the first staged op builds on. */
51
+ paginationFrom: string;
52
+ /** Staged last-id / row count (chained across ops in the txn). */
53
+ lastId: number;
54
+ total: number;
55
+ staged: {
56
+ ops: JournalFileOp[];
57
+ pagination: {
58
+ from: string;
59
+ to: string;
60
+ } | null;
61
+ }[];
62
+ }
36
63
  export type ComparisonOperator = "=" | "!=" | ">" | "<" | ">=" | "<=" | "*" | "!*" | "[]" | "![]";
37
64
  export type pageInfo = {
38
65
  total?: number;
@@ -51,7 +78,7 @@ declare global {
51
78
  entries<T extends object>(o: T): Entries<T>;
52
79
  }
53
80
  }
54
- export declare const ERROR_CODES: readonly ["GROUP_UNIQUE", "FIELD_UNIQUE", "FIELD_REQUIRED", "NO_SCHEMA", "TABLE_EMPTY", "INVALID_ID", "INVALID_TYPE", "INVALID_PARAMETERS", "NO_ENV", "TABLE_EXISTS", "TABLE_NOT_EXISTS", "INVALID_REGEX_MATCH", "INVALID_NAME"];
81
+ export declare const ERROR_CODES: readonly ["GROUP_UNIQUE", "FIELD_UNIQUE", "FIELD_REQUIRED", "NO_SCHEMA", "TABLE_EMPTY", "INVALID_ID", "INVALID_TYPE", "INVALID_PARAMETERS", "NO_ENV", "TABLE_EXISTS", "TABLE_NOT_EXISTS", "INVALID_REGEX_MATCH", "INVALID_NAME", "COMPUTED_FIELD_SYNTAX", "COMPUTED_FIELD_UNKNOWN_FIELD", "COMPUTED_FIELD_INVALID_LINK", "COMPUTED_FIELD_INVALID_TARGET", "COMPUTED_FIELD_CONFLICT", "COMPUTED_FIELD_CYCLE", "COMPUTED_FIELD_SETTABLE", "COMPUTED_FIELD_DANGLING_LINK", "COMPUTED_FIELD_ARITHMETIC"];
55
82
  export type ErrorCode = (typeof ERROR_CODES)[number];
56
83
  export type ErrorLang = "en" | "ar" | "fr" | "es";
57
84
  export declare const globalConfig: {
@@ -78,9 +105,22 @@ export default class Inibase {
78
105
  * resolve numeric ids to line numbers arithmetically instead of scanning
79
106
  * the id file. Set false by any partial row deletion. */
80
107
  private idDensity;
108
+ /** Per-table computed-plan cache (null = table has no computed fields).
109
+ * A plan depends only on the table's persisted schema (compiled ASTs are
110
+ * id-based and rename-proof), so it is rebuilt only when the schema
111
+ * changes: see the invalidation in `getTable`'s reload branch,
112
+ * `createTable` and `updateTableLocked`. Bounded by the number of tables. */
113
+ private readonly computedPlanCache;
81
114
  private databasePath;
82
115
  private uniqueMap;
83
116
  private schemaFileExtension;
117
+ /**
118
+ * Open database transaction (see begin/commit/rollback). Holds the
119
+ * database lock (`<db>/.tmp/.locked`) for its whole lifetime and the
120
+ * per-table writer lock of every table it mutates, so mutations stage into
121
+ * the database journal and publish only on commit().
122
+ */
123
+ private transaction;
84
124
  constructor(database: string, mainFolder?: string, language?: ErrorLang);
85
125
  createError(name: ErrorCode, variable?: string | number | (string | number)[]): Error;
86
126
  private validateName;
@@ -116,6 +156,7 @@ export default class Inibase {
116
156
  updateTable(tableName: string, schema?: Schema, config?: TableConfig & {
117
157
  name?: string;
118
158
  }): Promise<void>;
159
+ private updateTableLocked;
119
160
  /**
120
161
  * Get table schema and config
121
162
  *
@@ -137,6 +178,57 @@ export default class Inibase {
137
178
  private joinPathesContents;
138
179
  private _processSchemaDataHelper;
139
180
  private processSchemaData;
181
+ /**
182
+ * Extract the raw expression from a field's `computed` property (string or
183
+ * persisted `{ expr, ast }` spec).
184
+ */
185
+ private computedExprOf;
186
+ /**
187
+ * Compile every `computed` expression of a schema (which must already have
188
+ * ids assigned) into its persisted `{ expr, ast }` form, in dependency
189
+ * order. Throws `COMPUTED_FIELD_CYCLE` on cyclic fields. Used by DDL so the
190
+ * on-disk schema always carries compiled ASTs.
191
+ */
192
+ private compileComputedFields;
193
+ /**
194
+ * Build the evaluation plan (topological order + id index) for a table's
195
+ * computed fields. Throws on cycles or unresolvable expressions.
196
+ */
197
+ private buildComputedPlan;
198
+ /** Id index of a table's schema (link targets are re-resolved at write
199
+ * time, so renames never retarget a compiled expression). */
200
+ private tableFieldIndex;
201
+ /**
202
+ * Evaluate a batch of (merged) rows against the table's computed fields.
203
+ * Returns `lineNo -> { computedKey -> value }` in dependency order.
204
+ *
205
+ * Batched link-hop reads: when the plan contains any link hop, a dry
206
+ * collection pass records every (table, column, id) triple the rows'
207
+ * expressions need (no file I/O), each distinct triple is then resolved
208
+ * exactly once — deduplicated across rows and fields — and a final pass
209
+ * evaluates against the warm cache. Plans without hops run the single
210
+ * evaluation pass unchanged.
211
+ */
212
+ private evaluateComputedRows;
213
+ private createLinkReader;
214
+ /** Evaluate every computed field of one row (topological order) and merge
215
+ * the results back into the row so dependent fields see them. */
216
+ private evaluateRowComputed;
217
+ private evaluateNode;
218
+ /** Evaluate a path (`ids`, dot-separated link hops) against the current
219
+ * frame. Returns `null` when an intermediate link value is missing. */
220
+ private evaluatePath;
221
+ /** Id index lookup with a shared per-evaluation cache. */
222
+ private indexFor;
223
+ /** Read a single column of one linked row via `get`, or null when the
224
+ * row does not exist (dangling link). */
225
+ private readLinkedRow;
226
+ private coerceNumber;
227
+ private applyBinaryOp;
228
+ private aggregate;
229
+ /** Merge evaluated per-line computed values into a `pathesContents` map
230
+ * as line-numbered replace records (encoded cells). */
231
+ private mergeComputedLineRecords;
140
232
  private isSimpleField;
141
233
  private processSimpleField;
142
234
  /**
@@ -162,6 +254,89 @@ export default class Inibase {
162
254
  * @param {string} tableName
163
255
  */
164
256
  clearCache(tableName: string): Promise<void>;
257
+ /**
258
+ * Commit a multi-file mutation crash-atomically:
259
+ * 1. fsync every freshly-written temp file;
260
+ * 2. write the journal `begin` entry and fsync it;
261
+ * 3. rename the pagination metadata file first (atomic publication point:
262
+ * the row count flips in a single rename, which is what lock-free
263
+ * readers observe) and then swap each live file aside (backup) and
264
+ * rename the temp into place;
265
+ * 4. write the journal `commit` marker and fsync it;
266
+ * 5. discard backups/temps + the journal, fsync the directories.
267
+ *
268
+ * On any failure before `commit`, the journal is rolled back so the table
269
+ * is left exactly as it was. `renameList` entries are [tempPath, livePath]
270
+ * pairs; a null tempPath means a pure removal (live file taken out).
271
+ */
272
+ private commitFiles;
273
+ /**
274
+ * Runs crash recovery for a table (and any crashed database transaction)
275
+ * before a read. Mutation paths get the same guarantee implicitly (the
276
+ * writer lock runs recovery on acquire); reads call this explicitly
277
+ * because they never take the table lock.
278
+ */
279
+ private ensureTableRecovered;
280
+ /**
281
+ * Blocking database-journal recovery, used by mutation paths (writers are
282
+ * serialized with live transactions on the database lock anyway).
283
+ */
284
+ private ensureDatabaseRecovered;
285
+ private ensureDatabaseTmpDir;
286
+ /** Staged per-table entry of the open transaction, or null when none. */
287
+ private txnTableEntry;
288
+ /** Lock a table for the open transaction (idempotent per transaction). */
289
+ private ensureTxnLock;
290
+ /**
291
+ * Resolve the pagination state a DML op should build on. Outside a
292
+ * transaction this reads the live pagination file (as before); inside a
293
+ * transaction the first touch reads it once and the entry keeps the staged
294
+ * id/count so chained ops (guarded to one per table) and commit() stay
295
+ * consistent without publishing anything early.
296
+ */
297
+ private resolvePagination;
298
+ /**
299
+ * Stage one table mutation into the open transaction: fsync its temps and
300
+ * append an `op` entry to the database journal (no live file is touched;
301
+ * commit() performs the actual renames). One staged mutation per table per
302
+ * transaction (multi-table atomicity; a second touch of the same table
303
+ * would need read-your-writes composition).
304
+ */
305
+ private stageTxnOp;
306
+ /**
307
+ * Begin a database transaction. Mutations issued while the transaction is
308
+ * open (post/put/delete, including cascade deletes) are staged into the
309
+ * database journal and published atomically at commit(); rollback()
310
+ * discards them without touching any live file.
311
+ *
312
+ * @param tables Optional table names to pre-lock at begin() in sorted
313
+ * order (the deadlock-free way to span tables). Tables not listed are
314
+ * locked on first touch, in first-touch order.
315
+ */
316
+ begin(tables?: string[]): Promise<void>;
317
+ /**
318
+ * Publish every staged mutation atomically: per table (sorted), the
319
+ * pagination rename comes first (the atomic publication point readers
320
+ * observe) and then live->backup + tmp->live swaps, before a single fsynced
321
+ * `commit` marker makes the whole transaction durable. A crash at any
322
+ * point is recovered by the journal rule (no marker -> roll back all
323
+ * tables, marker -> roll forward all tables).
324
+ */
325
+ commit(): Promise<void>;
326
+ /**
327
+ * Discard the open transaction: temps and the journal are removed and no
328
+ * live file is touched (nothing is published before commit()).
329
+ */
330
+ rollback(): Promise<void>;
331
+ /**
332
+ * Snapshot the identity (dev:inode:mtime:size) of every column file and
333
+ * the pagination file. Reading data and then re-verifying this snapshot
334
+ * lets lock-free readers detect an in-flight writer commit and retry
335
+ * instead of returning a torn row set.
336
+ */
337
+ private snapshotTableFiles;
338
+ /** True when every snapshotted file is still present and unchanged. */
339
+ private verifyTableFiles;
165
340
  /**
166
341
  * Retrieve item(s) from a table
167
342
  *
@@ -177,6 +352,7 @@ export default class Inibase {
177
352
  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
353
  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
354
  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>;
355
+ private getOnce;
180
356
  /**
181
357
  * Create new item(s) in a table
182
358
  *