@harperfast/rocksdb-js 2.4.0 → 2.5.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/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { execSync } from "node:child_process";
3
3
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync } from "node:fs";
4
- import { dirname, join, resolve } from "node:path";
4
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { access, cp, mkdir, readdir, rm } from "node:fs/promises";
7
7
  import * as orderedBinary from "ordered-binary";
@@ -9,7 +9,7 @@ import { ExtendedIterable } from "@harperfast/extended-iterable";
9
9
  import { Encoder } from "msgpackr";
10
10
 
11
11
  //#region \0rolldown/runtime.js
12
- var __require = /* @__PURE__ */ createRequire(import.meta.url);
12
+ var __require = /* #__PURE__ */ (() => createRequire(import.meta.url))();
13
13
 
14
14
  //#endregion
15
15
  //#region src/load-binding.ts
@@ -90,13 +90,21 @@ const coolTransactionLogs = binding.coolTransactionLogs;
90
90
  */
91
91
  const transactionLogMapCount = binding.transactionLogMapCount;
92
92
  /**
93
+ * Test-only: force the next `count` transaction commits to fail with TryAgain (rolled back, so
94
+ * no data is committed), reproducing a stranded-snapshot conflict deterministically. Pass 0 to
95
+ * disarm. Used by the ERR_TRY_AGAIN retry regression test.
96
+ */
97
+ const forceTryAgainForTesting = binding.forceTryAgainForTesting;
98
+ /**
93
99
  * Creates a native file lock using the specified file path (`flock` on POSIX,
94
- * `LockFileEx` on Windows). Returns an opaque non-zero token to pass to
95
- * `fileLockRelease`, or `0` if another holder — in any process, container, or
96
- * worker thread — currently has it. Throws if `file` is missing or on a hard
97
- * error. The OS handle is owned entirely in native code (no fd crosses into
98
- * JS), and the kernel releases the lock when the handle closes, including on
99
- * process death.
100
+ * `LockFileEx` on Windows), creating the file and any missing parent
101
+ * directories. Exclusive by default; pass `shared` for a shared (reader) lock
102
+ * that coexists with other shared holders but conflicts with an exclusive
103
+ * holder in either direction. Returns an opaque non-zero token to pass to
104
+ * `fileLockRelease`, or `0` if a conflicting holder — in any process,
105
+ * container, or worker thread — currently has it. Throws on a hard error. The
106
+ * OS handle is owned entirely in native code (no fd crosses into JS), and the
107
+ * kernel releases the lock when the handle closes, including on process death.
100
108
  */
101
109
  const tryFileLock = binding.tryFileLock;
102
110
  /**
@@ -109,9 +117,51 @@ const nativeBackupList = binding.backupList;
109
117
  const nativeBackupDelete = binding.backupDelete;
110
118
  const nativeBackupPurge = binding.backupPurge;
111
119
  const nativeBackupVerify = binding.backupVerify;
120
+ const nativeValidateTransactionLog = binding.validateTransactionLog;
112
121
  const stats = binding.stats;
113
122
  const version = binding.version;
114
123
 
124
+ //#endregion
125
+ //#region src/validate-transaction-log.ts
126
+ /**
127
+ * Validates a transaction log store directory: every `<sequence>.txnlog` file
128
+ * is checked for a valid header (token, version) and intact v1 entry framing
129
+ * (using the same scan as open-time crash recovery), file-name and sequence
130
+ * continuity are checked, and the `txn.state` side file (when present) is
131
+ * checked for shape and a plausible flushed position. The result reports
132
+ * per-file entry counts, errors, and warnings; it does not throw when the
133
+ * store is invalid — check `result.valid`.
134
+ *
135
+ * Runs entirely in native code on a worker thread and does not require (or
136
+ * open) a database, so it works on the store of a closed database and on the
137
+ * per-backup snapshots under `<backupDir>/transaction_logs/<backupId>/` alike.
138
+ *
139
+ * Validating a store that is being actively appended to can spuriously report
140
+ * a torn tail for the current log file — the tail of an in-flight append is
141
+ * indistinguishable from a crash artifact.
142
+ *
143
+ * Rejects if `path` does not exist or is not a directory (a bad argument, as
144
+ * opposed to an invalid store).
145
+ *
146
+ * @param path The store directory, e.g. `<dbDir>/transaction_logs/<name>`.
147
+ * @param options Validation options.
148
+ *
149
+ * @example
150
+ * ```typescript
151
+ * import { validateTransactionLogStore } from '@harperfast/rocksdb-js';
152
+ *
153
+ * const result = await validateTransactionLogStore('/path/to/db/transaction_logs/mylog');
154
+ * if (!result.valid) {
155
+ * for (const file of result.files) {
156
+ * console.error(file.file, file.errors);
157
+ * }
158
+ * }
159
+ * ```
160
+ */
161
+ function validateTransactionLogStore(path, options) {
162
+ return new Promise((resolve, reject) => nativeValidateTransactionLog(resolve, reject, path, options?.strict ?? false));
163
+ }
164
+
115
165
  //#endregion
116
166
  //#region src/backup.ts
117
167
  /** Subdirectory (under a backup directory) holding per-backup transaction log snapshots. */
@@ -127,17 +177,29 @@ async function exists(path) {
127
177
  }
128
178
  /**
129
179
  * Runs `fn` while holding a native file lock for `backupDir`, releasing it when
130
- * `fn` settles. Used by the writable-engine operations `backups.delete` and
131
- * `backups.purge`; backup creation (`Store.backup`) takes the same lock on the
132
- * same file natively inside `Database::Backup` (see `runCreateBackup` in
180
+ * `fn` settles. The lock is exclusive by default — used by the writable-engine
181
+ * operations `backups.delete` and `backups.purge`; backup creation
182
+ * (`Store.backup`) takes the same exclusive lock on the same file natively
183
+ * inside `Database::Backup` (see `runCreateBackup` in
133
184
  * `src/binding/database/backup.cpp`), where the backup directory is also
134
- * created. Throws immediately (without running `fn`) if another writer holds
135
- * the directory lock.
185
+ * created. Throws immediately (without running `fn`) if the backup directory
186
+ * is missing — `tryFileLock` creates missing parents, but `delete`/`purge`/
187
+ * `restore` on a nonexistent directory is a caller error and must not conjure
188
+ * an empty one — or if a conflicting holder has the directory lock.
136
189
  *
137
- * Read-only operations (`list`, `verify`, and a restore's source read) use
138
- * `BackupEngineReadOnly` and are not locked: concurrent readers are safe.
139
- * Running a reader concurrently with a `delete`/`purge` on the same directory is
140
- * a caller-managed hazard, matching RocksDB's one-writable-engine-per-dir model.
190
+ * `backups.restore` holds the lock in `shared` mode for its source read:
191
+ * shared holders coexist (concurrent restores are safe), but a writer's
192
+ * exclusive acquisition rejects while a restore is in flight — and vice versa.
193
+ * Without this, a `purge`/`delete` racing a restore can delete the very files
194
+ * the restore is copying, after the restore's default `purgeAllFiles` mode has
195
+ * already wiped the destination — failing the restore with no fallback. A
196
+ * rejected writer, by contrast, just retries later.
197
+ *
198
+ * The remaining read-only operations (`list`, `verify`) use
199
+ * `BackupEngineReadOnly` and are not locked: concurrent readers are safe, and
200
+ * locking them would make cheap listings reject during a long backup. Running
201
+ * them concurrently with a `delete`/`purge` on the same directory is a
202
+ * caller-managed hazard, matching RocksDB's one-writable-engine-per-dir model.
141
203
  *
142
204
  * RocksDB only serializes work within a single `BackupEngine` instance and has
143
205
  * no lock on the backup directory itself. Two writable engines — in any
@@ -163,8 +225,9 @@ async function exists(path) {
163
225
  * The lock file is never deleted, only locked and unlocked; an unlocked, empty
164
226
  * `.backup.lock` at the directory root is the steady state.
165
227
  */
166
- async function withBackupDirLock(backupDir, fn) {
167
- const token = tryFileLock(join(backupDir, ".backup.lock"));
228
+ async function withBackupDirLock(backupDir, fn, options) {
229
+ if (!await exists(backupDir)) throw new Error(`Backup directory does not exist: ${backupDir}`);
230
+ const token = tryFileLock(join(backupDir, ".backup.lock"), options?.shared === true);
168
231
  if (token === 0) throw new Error(`Backup directory is locked: ${backupDir}`);
169
232
  try {
170
233
  return await fn();
@@ -173,6 +236,40 @@ async function withBackupDirLock(backupDir, fn) {
173
236
  }
174
237
  }
175
238
  /**
239
+ * Throws if `backupDir` resolves to the database directory itself or any path
240
+ * beneath it. Backing up into the live database directory would write backup
241
+ * files (the `.backup.lock`, `meta/`, `shared/`, `private/` subtrees) on top of
242
+ * RocksDB's own files, and every subsequent backup would recursively capture
243
+ * the prior ones. Both paths are resolved first so trailing slashes and
244
+ * relative/absolute variants of the same location cannot slip past the check.
245
+ *
246
+ * Only the "backup dir at or under the database dir" direction is rejected —
247
+ * the case the user hits by pointing a backup at `<db>` or `<db>/backups`. The
248
+ * reverse (a database nested under the backup dir) is left to the caller: a
249
+ * backup engine writes into named subtrees, not on top of arbitrary sibling
250
+ * files, so it does not clobber the database in place.
251
+ *
252
+ * On case-insensitive filesystems `<db>` and `<db>/../DB/backup` are the same
253
+ * directory on disk, but `relative()` compares case-sensitively and would call
254
+ * the second one "outside". We case-fold both paths on the platforms whose
255
+ * default filesystem is case-insensitive (macOS, Windows) so a casing
256
+ * difference cannot slip a backup into the database directory. This is a
257
+ * platform heuristic, not a per-volume probe (a case-sensitive macOS/Windows
258
+ * volume exists), so it can over-reject a legitimately differently-cased
259
+ * sibling there — the safe direction for a guard against clobbering the live
260
+ * database.
261
+ */
262
+ function assertBackupDirOutsideDatabase(dbPath, backupDir) {
263
+ let resolvedDb = resolve(dbPath);
264
+ let resolvedBackup = resolve(backupDir);
265
+ if (process.platform === "win32" || process.platform === "darwin") {
266
+ resolvedDb = resolvedDb.toLowerCase();
267
+ resolvedBackup = resolvedBackup.toLowerCase();
268
+ }
269
+ const rel = relative(resolvedDb, resolvedBackup);
270
+ if (!(rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))) throw new Error(`Backup directory must not be inside the database directory: ${backupDir} is at or within ${dbPath}`);
271
+ }
272
+ /**
176
273
  * Removes `<backupDir>/transaction_logs/<id>` subtrees whose backup id no longer
177
274
  * corresponds to a surviving backup. Called after a `purge` (which holds the
178
275
  * backup-dir lock); a no-op when no transaction logs were backed up. Self-healing:
@@ -253,18 +350,32 @@ const backups = {
253
350
  * Restores a database from a backup directory into a (closed) database
254
351
  * directory. The default mode purges the destination directory, so it must
255
352
  * not point at a live database.
353
+ *
354
+ * Holds the backup directory lock in shared mode for the duration: multiple
355
+ * restores can read concurrently, but a writer (`db.backup()`, `delete`,
356
+ * `purge`) rejects while a restore is in flight rather than deleting files
357
+ * out from under it — and a restore rejects while a writer holds the lock.
358
+ *
359
+ * The shared lock needs no write access to `backupDir`, so restoring from a
360
+ * read-only backup directory (immutable/WORM store, read-only-mounted volume)
361
+ * works: the lock is taken on an existing `.backup.lock` read-only, degrading
362
+ * to a no-op only when the media is read-only for every process (`EROFS`). A
363
+ * mere permission denial hard-fails instead — it doesn't prove a privileged
364
+ * writer isn't running.
256
365
  */
257
366
  async restore(backupDir, dbDir, options) {
258
367
  if (resolve(backupDir) === resolve(dbDir)) throw new Error("Backup directory and database directory must be different");
259
- const walDir = options?.walDir ?? dbDir;
260
- await mkdir(dbDir, { recursive: true });
261
- if (walDir !== dbDir) await mkdir(walDir, { recursive: true });
262
- await new Promise((resolve, reject) => nativeBackupRestore(resolve, reject, backupDir, dbDir, walDir, {
263
- backupId: options?.backupId,
264
- keepLogFiles: options?.keepLogFiles,
265
- mode: options?.mode
266
- }));
267
- await restoreTransactionLogs(backupDir, dbDir, options?.backupId);
368
+ return withBackupDirLock(backupDir, async () => {
369
+ const walDir = options?.walDir ?? dbDir;
370
+ await mkdir(dbDir, { recursive: true });
371
+ if (walDir !== dbDir) await mkdir(walDir, { recursive: true });
372
+ await new Promise((resolve, reject) => nativeBackupRestore(resolve, reject, backupDir, dbDir, walDir, {
373
+ backupId: options?.backupId,
374
+ keepLogFiles: options?.keepLogFiles,
375
+ mode: options?.mode
376
+ }));
377
+ await restoreTransactionLogs(backupDir, dbDir, options?.backupId);
378
+ }, { shared: true });
268
379
  },
269
380
  /**
270
381
  * Lists the non-corrupt backups in a backup directory, ordered by id.
@@ -301,9 +412,28 @@ const backups = {
301
412
  /**
302
413
  * Verifies a backup's file sizes, and optionally their checksums (which
303
414
  * requires reading all backed-up data).
304
- */
305
- verify(backupDir, backupId, options) {
306
- return new Promise((resolve, reject) => nativeBackupVerify(resolve, reject, backupDir, backupId, options?.verifyWithChecksum ?? false));
415
+ *
416
+ * When the backup was created with `transactionLogs: true`, the backup's
417
+ * transaction log snapshot (`<backupDir>/transaction_logs/<backupId>/`) is
418
+ * also validated — every log file's header and entry framing must be intact
419
+ * (snapshots are copied on committed entry boundaries, so even a torn tail
420
+ * is a verification failure). Set `verifyTransactionLogs: false` to skip.
421
+ */
422
+ async verify(backupDir, backupId, options) {
423
+ await new Promise((resolve, reject) => nativeBackupVerify(resolve, reject, backupDir, backupId, options?.verifyWithChecksum ?? false));
424
+ if (options?.verifyTransactionLogs === false) return;
425
+ const logsDir = join(backupDir, TRANSACTION_LOGS_DIRNAME, String(backupId));
426
+ if (!await exists(logsDir)) return;
427
+ const failures = [];
428
+ for (const entry of await readdir(logsDir, { withFileTypes: true })) {
429
+ if (!entry.isDirectory()) continue;
430
+ const result = await validateTransactionLogStore(join(logsDir, entry.name), { strict: true });
431
+ if (!result.valid) {
432
+ const details = [...result.errors, ...result.files.flatMap((file) => file.errors.map((error) => `${file.file}: ${error}`))];
433
+ failures.push(`${entry.name}: ${details.join("; ")}`);
434
+ }
435
+ }
436
+ if (failures.length > 0) throw new Error(`Backup ${backupId} transaction log verification failed: ${failures.join("\n")}`);
307
437
  }
308
438
  };
309
439
 
@@ -1132,6 +1262,16 @@ var Store = class {
1132
1262
  */
1133
1263
  maxKeySize;
1134
1264
  /**
1265
+ * The maximum number of table files RocksDB keeps open. `0` (the default)
1266
+ * derives a budget from the effective per-process open-file limit (an
1267
+ * eighth of the limit — several databases can share one process — clamped
1268
+ * to `[1024, 262144]`); `-1` holds every table file open (the RocksDB
1269
+ * default, which can exhaust the process file-descriptor limit when
1270
+ * compaction falls behind under sustained ingest); a positive `int32` is
1271
+ * an explicit cap.
1272
+ */
1273
+ maxOpenFiles;
1274
+ /**
1135
1275
  * The maximum number of memtables that can be queued per column family
1136
1276
  * before writes stall. Higher values absorb write bursts while flushes catch
1137
1277
  * up, at the cost of memory.
@@ -1252,6 +1392,7 @@ var Store = class {
1252
1392
  this.keyBuffer = KEY_BUFFER;
1253
1393
  this.keyEncoding = keyEncoding;
1254
1394
  this.maxKeySize = options?.maxKeySize ?? MAX_KEY_SIZE;
1395
+ this.maxOpenFiles = options?.maxOpenFiles;
1255
1396
  this.maxWriteBufferNumber = options?.maxWriteBufferNumber;
1256
1397
  this.maxWriteBufferSizeToMaintain = options?.maxWriteBufferSizeToMaintain;
1257
1398
  this.name = options?.name ?? "default";
@@ -1303,6 +1444,7 @@ var Store = class {
1303
1444
  }
1304
1445
  async backup(target, options) {
1305
1446
  if (typeof target !== "string" && typeof target?.getWriter === "function") return backupToStream(this.db, target, options);
1447
+ assertBackupDirOutsideDatabase(this.path, target);
1306
1448
  return new Promise((resolve, reject) => this.db.backup(resolve, reject, target, options));
1307
1449
  }
1308
1450
  /**
@@ -1543,6 +1685,7 @@ var Store = class {
1543
1685
  dbWriteBufferSize: this.dbWriteBufferSize,
1544
1686
  disableWAL: this.disableWAL,
1545
1687
  enableStats: this.enableStats,
1688
+ maxOpenFiles: this.maxOpenFiles,
1546
1689
  maxWriteBufferNumber: this.maxWriteBufferNumber,
1547
1690
  maxWriteBufferSizeToMaintain: this.maxWriteBufferSizeToMaintain,
1548
1691
  mode: this.pessimistic ? "pessimistic" : "optimistic",
@@ -1661,8 +1804,18 @@ const RETRY_NOW = constants.RETRY_NOW_VALUE;
1661
1804
  var TransactionAlreadyAbortedError = class extends Error {
1662
1805
  code = "ERR_ALREADY_ABORTED";
1663
1806
  };
1664
- var TransactionIsBusyError = class extends Error {
1665
- code = "ERR_BUSY";
1807
+ /**
1808
+ * Base for commit conflicts the native layer has already recovered from by resetting the
1809
+ * transaction in place (fresh RocksDB snapshot; `committedPosition` preserved, so a staged
1810
+ * transaction-log batch stays write-once): the caller should re-run the transaction body
1811
+ * and retry the commit — the handling is identical for every subclass. `hasLog` reports
1812
+ * whether the transaction wrote to a transaction log (drives the default retry decision).
1813
+ *
1814
+ * {@link TransactionAbandonedError} shares this field shape but deliberately does NOT
1815
+ * extend this class — an abandoned transaction is not retryable, and retryability is the
1816
+ * boundary this hierarchy expresses.
1817
+ */
1818
+ var TransactionRetryableError = class extends Error {
1666
1819
  hasLog;
1667
1820
  txn;
1668
1821
  constructor(error, txn) {
@@ -1671,6 +1824,21 @@ var TransactionIsBusyError = class extends Error {
1671
1824
  this.txn = txn;
1672
1825
  }
1673
1826
  };
1827
+ /**
1828
+ * An optimistic commit detected a write conflict with a concurrently committed
1829
+ * transaction (RocksDB `Busy`).
1830
+ */
1831
+ var TransactionIsBusyError = class extends TransactionRetryableError {
1832
+ code = "ERR_BUSY";
1833
+ };
1834
+ /**
1835
+ * An optimistic commit could not be validated: the transaction's snapshot was stranded
1836
+ * outside the memtable window (its sequence history was flushed away — RocksDB
1837
+ * `TryAgain`), so the conflict check had nothing to validate against.
1838
+ */
1839
+ var TransactionTryAgainError = class extends TransactionRetryableError {
1840
+ code = "ERR_TRY_AGAIN";
1841
+ };
1674
1842
  var TransactionAbandonedError = class extends Error {
1675
1843
  code = "ERR_TRANSACTION_ABANDONED";
1676
1844
  hasLog;
@@ -1765,6 +1933,7 @@ var Transaction = class extends DBI {
1765
1933
  if (err instanceof Error && "code" in err) {
1766
1934
  if (err.code === "ERR_ALREADY_ABORTED") return new TransactionAlreadyAbortedError(err.message);
1767
1935
  if (err.code === "ERR_BUSY") return new TransactionIsBusyError(err, this);
1936
+ if (err.code === "ERR_TRY_AGAIN") return new TransactionTryAgainError(err, this);
1768
1937
  }
1769
1938
  return err;
1770
1939
  }
@@ -2345,7 +2514,7 @@ var RocksDatabase = class RocksDatabase extends DBI {
2345
2514
  commitResult = await txn.commit();
2346
2515
  } catch (commitErr) {
2347
2516
  if (commitErr instanceof TransactionAlreadyAbortedError) return;
2348
- if (commitErr instanceof TransactionIsBusyError && (options?.retryOnBusy ?? commitErr.hasLog) && attempt < maxRetries) continue;
2517
+ if (commitErr instanceof TransactionRetryableError && (options?.retryOnBusy ?? commitErr.hasLog) && attempt < maxRetries) continue;
2349
2518
  this.#abandonTransaction(txn, commitErr);
2350
2519
  return;
2351
2520
  }
@@ -2377,7 +2546,7 @@ var RocksDatabase = class RocksDatabase extends DBI {
2377
2546
  if (typeof callback !== "function") throw new TypeError("Callback must be a function");
2378
2547
  const maxRetries = options?.maxRetries ?? 3;
2379
2548
  const isRetryable = (err, attempt) => {
2380
- return err instanceof TransactionIsBusyError && (options?.retryOnBusy ?? err.hasLog) && attempt <= maxRetries;
2549
+ return err instanceof TransactionRetryableError && (options?.retryOnBusy ?? err.hasLog) && attempt <= maxRetries;
2381
2550
  };
2382
2551
  const runAttempt = (attempt) => {
2383
2552
  const txn = new Transaction(this.store, options);
@@ -2573,7 +2742,7 @@ function parseTransactionLog(path, options = {}) {
2573
2742
 
2574
2743
  //#endregion
2575
2744
  //#region src/transaction-log-reader.ts
2576
- const FLOAT_TO_UINT32 = new Float64Array(1);
2745
+ const FLOAT_TO_UINT32 = /* @__PURE__ */ new Float64Array(1);
2577
2746
  const UINT32_FROM_FLOAT = new Uint32Array(FLOAT_TO_UINT32.buffer);
2578
2747
  const { TRANSACTION_LOG_FILE_HEADER_SIZE, TRANSACTION_LOG_ENTRY_HEADER_SIZE } = constants;
2579
2748
  /**
@@ -2759,9 +2928,9 @@ function loadLastPosition(transactionLog, readUncommitted) {
2759
2928
  //#region src/index.ts
2760
2929
  const versions = {
2761
2930
  rocksdb: version,
2762
- "rocksdb-js": "2.4.0"
2931
+ "rocksdb-js": "2.5.0"
2763
2932
  };
2764
2933
 
2765
2934
  //#endregion
2766
- export { DBI, DBIterator, RocksDatabase, Store, Transaction, TransactionLog, backups, constants, coolTransactionLogs, currentThreadId, fileLockRelease, parseTransactionLog, registryStatus, shutdown, stats, tryFileLock, versions };
2935
+ export { DBI, DBIterator, RocksDatabase, Store, Transaction, TransactionLog, backups, constants, coolTransactionLogs, currentThreadId, fileLockRelease, parseTransactionLog, registryStatus, shutdown, stats, tryFileLock, validateTransactionLogStore, versions };
2767
2936
  //# sourceMappingURL=index.mjs.map