@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.cjs CHANGED
@@ -115,13 +115,21 @@ const coolTransactionLogs = binding.coolTransactionLogs;
115
115
  */
116
116
  const transactionLogMapCount = binding.transactionLogMapCount;
117
117
  /**
118
+ * Test-only: force the next `count` transaction commits to fail with TryAgain (rolled back, so
119
+ * no data is committed), reproducing a stranded-snapshot conflict deterministically. Pass 0 to
120
+ * disarm. Used by the ERR_TRY_AGAIN retry regression test.
121
+ */
122
+ const forceTryAgainForTesting = binding.forceTryAgainForTesting;
123
+ /**
118
124
  * Creates a native file lock using the specified file path (`flock` on POSIX,
119
- * `LockFileEx` on Windows). Returns an opaque non-zero token to pass to
120
- * `fileLockRelease`, or `0` if another holder — in any process, container, or
121
- * worker thread — currently has it. Throws if `file` is missing or on a hard
122
- * error. The OS handle is owned entirely in native code (no fd crosses into
123
- * JS), and the kernel releases the lock when the handle closes, including on
124
- * process death.
125
+ * `LockFileEx` on Windows), creating the file and any missing parent
126
+ * directories. Exclusive by default; pass `shared` for a shared (reader) lock
127
+ * that coexists with other shared holders but conflicts with an exclusive
128
+ * holder in either direction. Returns an opaque non-zero token to pass to
129
+ * `fileLockRelease`, or `0` if a conflicting holder — in any process,
130
+ * container, or worker thread — currently has it. Throws on a hard error. The
131
+ * OS handle is owned entirely in native code (no fd crosses into JS), and the
132
+ * kernel releases the lock when the handle closes, including on process death.
125
133
  */
126
134
  const tryFileLock = binding.tryFileLock;
127
135
  /**
@@ -134,9 +142,51 @@ const nativeBackupList = binding.backupList;
134
142
  const nativeBackupDelete = binding.backupDelete;
135
143
  const nativeBackupPurge = binding.backupPurge;
136
144
  const nativeBackupVerify = binding.backupVerify;
145
+ const nativeValidateTransactionLog = binding.validateTransactionLog;
137
146
  const stats = binding.stats;
138
147
  const version = binding.version;
139
148
 
149
+ //#endregion
150
+ //#region src/validate-transaction-log.ts
151
+ /**
152
+ * Validates a transaction log store directory: every `<sequence>.txnlog` file
153
+ * is checked for a valid header (token, version) and intact v1 entry framing
154
+ * (using the same scan as open-time crash recovery), file-name and sequence
155
+ * continuity are checked, and the `txn.state` side file (when present) is
156
+ * checked for shape and a plausible flushed position. The result reports
157
+ * per-file entry counts, errors, and warnings; it does not throw when the
158
+ * store is invalid — check `result.valid`.
159
+ *
160
+ * Runs entirely in native code on a worker thread and does not require (or
161
+ * open) a database, so it works on the store of a closed database and on the
162
+ * per-backup snapshots under `<backupDir>/transaction_logs/<backupId>/` alike.
163
+ *
164
+ * Validating a store that is being actively appended to can spuriously report
165
+ * a torn tail for the current log file — the tail of an in-flight append is
166
+ * indistinguishable from a crash artifact.
167
+ *
168
+ * Rejects if `path` does not exist or is not a directory (a bad argument, as
169
+ * opposed to an invalid store).
170
+ *
171
+ * @param path The store directory, e.g. `<dbDir>/transaction_logs/<name>`.
172
+ * @param options Validation options.
173
+ *
174
+ * @example
175
+ * ```typescript
176
+ * import { validateTransactionLogStore } from '@harperfast/rocksdb-js';
177
+ *
178
+ * const result = await validateTransactionLogStore('/path/to/db/transaction_logs/mylog');
179
+ * if (!result.valid) {
180
+ * for (const file of result.files) {
181
+ * console.error(file.file, file.errors);
182
+ * }
183
+ * }
184
+ * ```
185
+ */
186
+ function validateTransactionLogStore(path, options) {
187
+ return new Promise((resolve, reject) => nativeValidateTransactionLog(resolve, reject, path, options?.strict ?? false));
188
+ }
189
+
140
190
  //#endregion
141
191
  //#region src/backup.ts
142
192
  /** Subdirectory (under a backup directory) holding per-backup transaction log snapshots. */
@@ -152,17 +202,29 @@ async function exists(path) {
152
202
  }
153
203
  /**
154
204
  * Runs `fn` while holding a native file lock for `backupDir`, releasing it when
155
- * `fn` settles. Used by the writable-engine operations `backups.delete` and
156
- * `backups.purge`; backup creation (`Store.backup`) takes the same lock on the
157
- * same file natively inside `Database::Backup` (see `runCreateBackup` in
205
+ * `fn` settles. The lock is exclusive by default — used by the writable-engine
206
+ * operations `backups.delete` and `backups.purge`; backup creation
207
+ * (`Store.backup`) takes the same exclusive lock on the same file natively
208
+ * inside `Database::Backup` (see `runCreateBackup` in
158
209
  * `src/binding/database/backup.cpp`), where the backup directory is also
159
- * created. Throws immediately (without running `fn`) if another writer holds
160
- * the directory lock.
210
+ * created. Throws immediately (without running `fn`) if the backup directory
211
+ * is missing — `tryFileLock` creates missing parents, but `delete`/`purge`/
212
+ * `restore` on a nonexistent directory is a caller error and must not conjure
213
+ * an empty one — or if a conflicting holder has the directory lock.
161
214
  *
162
- * Read-only operations (`list`, `verify`, and a restore's source read) use
163
- * `BackupEngineReadOnly` and are not locked: concurrent readers are safe.
164
- * Running a reader concurrently with a `delete`/`purge` on the same directory is
165
- * a caller-managed hazard, matching RocksDB's one-writable-engine-per-dir model.
215
+ * `backups.restore` holds the lock in `shared` mode for its source read:
216
+ * shared holders coexist (concurrent restores are safe), but a writer's
217
+ * exclusive acquisition rejects while a restore is in flight — and vice versa.
218
+ * Without this, a `purge`/`delete` racing a restore can delete the very files
219
+ * the restore is copying, after the restore's default `purgeAllFiles` mode has
220
+ * already wiped the destination — failing the restore with no fallback. A
221
+ * rejected writer, by contrast, just retries later.
222
+ *
223
+ * The remaining read-only operations (`list`, `verify`) use
224
+ * `BackupEngineReadOnly` and are not locked: concurrent readers are safe, and
225
+ * locking them would make cheap listings reject during a long backup. Running
226
+ * them concurrently with a `delete`/`purge` on the same directory is a
227
+ * caller-managed hazard, matching RocksDB's one-writable-engine-per-dir model.
166
228
  *
167
229
  * RocksDB only serializes work within a single `BackupEngine` instance and has
168
230
  * no lock on the backup directory itself. Two writable engines — in any
@@ -188,8 +250,9 @@ async function exists(path) {
188
250
  * The lock file is never deleted, only locked and unlocked; an unlocked, empty
189
251
  * `.backup.lock` at the directory root is the steady state.
190
252
  */
191
- async function withBackupDirLock(backupDir, fn) {
192
- const token = tryFileLock((0, node_path.join)(backupDir, ".backup.lock"));
253
+ async function withBackupDirLock(backupDir, fn, options) {
254
+ if (!await exists(backupDir)) throw new Error(`Backup directory does not exist: ${backupDir}`);
255
+ const token = tryFileLock((0, node_path.join)(backupDir, ".backup.lock"), options?.shared === true);
193
256
  if (token === 0) throw new Error(`Backup directory is locked: ${backupDir}`);
194
257
  try {
195
258
  return await fn();
@@ -198,6 +261,40 @@ async function withBackupDirLock(backupDir, fn) {
198
261
  }
199
262
  }
200
263
  /**
264
+ * Throws if `backupDir` resolves to the database directory itself or any path
265
+ * beneath it. Backing up into the live database directory would write backup
266
+ * files (the `.backup.lock`, `meta/`, `shared/`, `private/` subtrees) on top of
267
+ * RocksDB's own files, and every subsequent backup would recursively capture
268
+ * the prior ones. Both paths are resolved first so trailing slashes and
269
+ * relative/absolute variants of the same location cannot slip past the check.
270
+ *
271
+ * Only the "backup dir at or under the database dir" direction is rejected —
272
+ * the case the user hits by pointing a backup at `<db>` or `<db>/backups`. The
273
+ * reverse (a database nested under the backup dir) is left to the caller: a
274
+ * backup engine writes into named subtrees, not on top of arbitrary sibling
275
+ * files, so it does not clobber the database in place.
276
+ *
277
+ * On case-insensitive filesystems `<db>` and `<db>/../DB/backup` are the same
278
+ * directory on disk, but `relative()` compares case-sensitively and would call
279
+ * the second one "outside". We case-fold both paths on the platforms whose
280
+ * default filesystem is case-insensitive (macOS, Windows) so a casing
281
+ * difference cannot slip a backup into the database directory. This is a
282
+ * platform heuristic, not a per-volume probe (a case-sensitive macOS/Windows
283
+ * volume exists), so it can over-reject a legitimately differently-cased
284
+ * sibling there — the safe direction for a guard against clobbering the live
285
+ * database.
286
+ */
287
+ function assertBackupDirOutsideDatabase(dbPath, backupDir) {
288
+ let resolvedDb = (0, node_path.resolve)(dbPath);
289
+ let resolvedBackup = (0, node_path.resolve)(backupDir);
290
+ if (process.platform === "win32" || process.platform === "darwin") {
291
+ resolvedDb = resolvedDb.toLowerCase();
292
+ resolvedBackup = resolvedBackup.toLowerCase();
293
+ }
294
+ const rel = (0, node_path.relative)(resolvedDb, resolvedBackup);
295
+ if (!(rel === ".." || rel.startsWith(`..${node_path.sep}`) || (0, node_path.isAbsolute)(rel))) throw new Error(`Backup directory must not be inside the database directory: ${backupDir} is at or within ${dbPath}`);
296
+ }
297
+ /**
201
298
  * Removes `<backupDir>/transaction_logs/<id>` subtrees whose backup id no longer
202
299
  * corresponds to a surviving backup. Called after a `purge` (which holds the
203
300
  * backup-dir lock); a no-op when no transaction logs were backed up. Self-healing:
@@ -278,18 +375,32 @@ const backups = {
278
375
  * Restores a database from a backup directory into a (closed) database
279
376
  * directory. The default mode purges the destination directory, so it must
280
377
  * not point at a live database.
378
+ *
379
+ * Holds the backup directory lock in shared mode for the duration: multiple
380
+ * restores can read concurrently, but a writer (`db.backup()`, `delete`,
381
+ * `purge`) rejects while a restore is in flight rather than deleting files
382
+ * out from under it — and a restore rejects while a writer holds the lock.
383
+ *
384
+ * The shared lock needs no write access to `backupDir`, so restoring from a
385
+ * read-only backup directory (immutable/WORM store, read-only-mounted volume)
386
+ * works: the lock is taken on an existing `.backup.lock` read-only, degrading
387
+ * to a no-op only when the media is read-only for every process (`EROFS`). A
388
+ * mere permission denial hard-fails instead — it doesn't prove a privileged
389
+ * writer isn't running.
281
390
  */
282
391
  async restore(backupDir, dbDir, options) {
283
392
  if ((0, node_path.resolve)(backupDir) === (0, node_path.resolve)(dbDir)) throw new Error("Backup directory and database directory must be different");
284
- const walDir = options?.walDir ?? dbDir;
285
- await (0, node_fs_promises.mkdir)(dbDir, { recursive: true });
286
- if (walDir !== dbDir) await (0, node_fs_promises.mkdir)(walDir, { recursive: true });
287
- await new Promise((resolve, reject) => nativeBackupRestore(resolve, reject, backupDir, dbDir, walDir, {
288
- backupId: options?.backupId,
289
- keepLogFiles: options?.keepLogFiles,
290
- mode: options?.mode
291
- }));
292
- await restoreTransactionLogs(backupDir, dbDir, options?.backupId);
393
+ return withBackupDirLock(backupDir, async () => {
394
+ const walDir = options?.walDir ?? dbDir;
395
+ await (0, node_fs_promises.mkdir)(dbDir, { recursive: true });
396
+ if (walDir !== dbDir) await (0, node_fs_promises.mkdir)(walDir, { recursive: true });
397
+ await new Promise((resolve, reject) => nativeBackupRestore(resolve, reject, backupDir, dbDir, walDir, {
398
+ backupId: options?.backupId,
399
+ keepLogFiles: options?.keepLogFiles,
400
+ mode: options?.mode
401
+ }));
402
+ await restoreTransactionLogs(backupDir, dbDir, options?.backupId);
403
+ }, { shared: true });
293
404
  },
294
405
  /**
295
406
  * Lists the non-corrupt backups in a backup directory, ordered by id.
@@ -326,9 +437,28 @@ const backups = {
326
437
  /**
327
438
  * Verifies a backup's file sizes, and optionally their checksums (which
328
439
  * requires reading all backed-up data).
329
- */
330
- verify(backupDir, backupId, options) {
331
- return new Promise((resolve, reject) => nativeBackupVerify(resolve, reject, backupDir, backupId, options?.verifyWithChecksum ?? false));
440
+ *
441
+ * When the backup was created with `transactionLogs: true`, the backup's
442
+ * transaction log snapshot (`<backupDir>/transaction_logs/<backupId>/`) is
443
+ * also validated — every log file's header and entry framing must be intact
444
+ * (snapshots are copied on committed entry boundaries, so even a torn tail
445
+ * is a verification failure). Set `verifyTransactionLogs: false` to skip.
446
+ */
447
+ async verify(backupDir, backupId, options) {
448
+ await new Promise((resolve, reject) => nativeBackupVerify(resolve, reject, backupDir, backupId, options?.verifyWithChecksum ?? false));
449
+ if (options?.verifyTransactionLogs === false) return;
450
+ const logsDir = (0, node_path.join)(backupDir, TRANSACTION_LOGS_DIRNAME, String(backupId));
451
+ if (!await exists(logsDir)) return;
452
+ const failures = [];
453
+ for (const entry of await (0, node_fs_promises.readdir)(logsDir, { withFileTypes: true })) {
454
+ if (!entry.isDirectory()) continue;
455
+ const result = await validateTransactionLogStore((0, node_path.join)(logsDir, entry.name), { strict: true });
456
+ if (!result.valid) {
457
+ const details = [...result.errors, ...result.files.flatMap((file) => file.errors.map((error) => `${file.file}: ${error}`))];
458
+ failures.push(`${entry.name}: ${details.join("; ")}`);
459
+ }
460
+ }
461
+ if (failures.length > 0) throw new Error(`Backup ${backupId} transaction log verification failed: ${failures.join("\n")}`);
332
462
  }
333
463
  };
334
464
 
@@ -1157,6 +1287,16 @@ var Store = class {
1157
1287
  */
1158
1288
  maxKeySize;
1159
1289
  /**
1290
+ * The maximum number of table files RocksDB keeps open. `0` (the default)
1291
+ * derives a budget from the effective per-process open-file limit (an
1292
+ * eighth of the limit — several databases can share one process — clamped
1293
+ * to `[1024, 262144]`); `-1` holds every table file open (the RocksDB
1294
+ * default, which can exhaust the process file-descriptor limit when
1295
+ * compaction falls behind under sustained ingest); a positive `int32` is
1296
+ * an explicit cap.
1297
+ */
1298
+ maxOpenFiles;
1299
+ /**
1160
1300
  * The maximum number of memtables that can be queued per column family
1161
1301
  * before writes stall. Higher values absorb write bursts while flushes catch
1162
1302
  * up, at the cost of memory.
@@ -1277,6 +1417,7 @@ var Store = class {
1277
1417
  this.keyBuffer = KEY_BUFFER;
1278
1418
  this.keyEncoding = keyEncoding;
1279
1419
  this.maxKeySize = options?.maxKeySize ?? MAX_KEY_SIZE;
1420
+ this.maxOpenFiles = options?.maxOpenFiles;
1280
1421
  this.maxWriteBufferNumber = options?.maxWriteBufferNumber;
1281
1422
  this.maxWriteBufferSizeToMaintain = options?.maxWriteBufferSizeToMaintain;
1282
1423
  this.name = options?.name ?? "default";
@@ -1328,6 +1469,7 @@ var Store = class {
1328
1469
  }
1329
1470
  async backup(target, options) {
1330
1471
  if (typeof target !== "string" && typeof target?.getWriter === "function") return backupToStream(this.db, target, options);
1472
+ assertBackupDirOutsideDatabase(this.path, target);
1331
1473
  return new Promise((resolve, reject) => this.db.backup(resolve, reject, target, options));
1332
1474
  }
1333
1475
  /**
@@ -1568,6 +1710,7 @@ var Store = class {
1568
1710
  dbWriteBufferSize: this.dbWriteBufferSize,
1569
1711
  disableWAL: this.disableWAL,
1570
1712
  enableStats: this.enableStats,
1713
+ maxOpenFiles: this.maxOpenFiles,
1571
1714
  maxWriteBufferNumber: this.maxWriteBufferNumber,
1572
1715
  maxWriteBufferSizeToMaintain: this.maxWriteBufferSizeToMaintain,
1573
1716
  mode: this.pessimistic ? "pessimistic" : "optimistic",
@@ -1686,8 +1829,18 @@ const RETRY_NOW = constants.RETRY_NOW_VALUE;
1686
1829
  var TransactionAlreadyAbortedError = class extends Error {
1687
1830
  code = "ERR_ALREADY_ABORTED";
1688
1831
  };
1689
- var TransactionIsBusyError = class extends Error {
1690
- code = "ERR_BUSY";
1832
+ /**
1833
+ * Base for commit conflicts the native layer has already recovered from by resetting the
1834
+ * transaction in place (fresh RocksDB snapshot; `committedPosition` preserved, so a staged
1835
+ * transaction-log batch stays write-once): the caller should re-run the transaction body
1836
+ * and retry the commit — the handling is identical for every subclass. `hasLog` reports
1837
+ * whether the transaction wrote to a transaction log (drives the default retry decision).
1838
+ *
1839
+ * {@link TransactionAbandonedError} shares this field shape but deliberately does NOT
1840
+ * extend this class — an abandoned transaction is not retryable, and retryability is the
1841
+ * boundary this hierarchy expresses.
1842
+ */
1843
+ var TransactionRetryableError = class extends Error {
1691
1844
  hasLog;
1692
1845
  txn;
1693
1846
  constructor(error, txn) {
@@ -1696,6 +1849,21 @@ var TransactionIsBusyError = class extends Error {
1696
1849
  this.txn = txn;
1697
1850
  }
1698
1851
  };
1852
+ /**
1853
+ * An optimistic commit detected a write conflict with a concurrently committed
1854
+ * transaction (RocksDB `Busy`).
1855
+ */
1856
+ var TransactionIsBusyError = class extends TransactionRetryableError {
1857
+ code = "ERR_BUSY";
1858
+ };
1859
+ /**
1860
+ * An optimistic commit could not be validated: the transaction's snapshot was stranded
1861
+ * outside the memtable window (its sequence history was flushed away — RocksDB
1862
+ * `TryAgain`), so the conflict check had nothing to validate against.
1863
+ */
1864
+ var TransactionTryAgainError = class extends TransactionRetryableError {
1865
+ code = "ERR_TRY_AGAIN";
1866
+ };
1699
1867
  var TransactionAbandonedError = class extends Error {
1700
1868
  code = "ERR_TRANSACTION_ABANDONED";
1701
1869
  hasLog;
@@ -1790,6 +1958,7 @@ var Transaction = class extends DBI {
1790
1958
  if (err instanceof Error && "code" in err) {
1791
1959
  if (err.code === "ERR_ALREADY_ABORTED") return new TransactionAlreadyAbortedError(err.message);
1792
1960
  if (err.code === "ERR_BUSY") return new TransactionIsBusyError(err, this);
1961
+ if (err.code === "ERR_TRY_AGAIN") return new TransactionTryAgainError(err, this);
1793
1962
  }
1794
1963
  return err;
1795
1964
  }
@@ -2370,7 +2539,7 @@ var RocksDatabase = class RocksDatabase extends DBI {
2370
2539
  commitResult = await txn.commit();
2371
2540
  } catch (commitErr) {
2372
2541
  if (commitErr instanceof TransactionAlreadyAbortedError) return;
2373
- if (commitErr instanceof TransactionIsBusyError && (options?.retryOnBusy ?? commitErr.hasLog) && attempt < maxRetries) continue;
2542
+ if (commitErr instanceof TransactionRetryableError && (options?.retryOnBusy ?? commitErr.hasLog) && attempt < maxRetries) continue;
2374
2543
  this.#abandonTransaction(txn, commitErr);
2375
2544
  return;
2376
2545
  }
@@ -2402,7 +2571,7 @@ var RocksDatabase = class RocksDatabase extends DBI {
2402
2571
  if (typeof callback !== "function") throw new TypeError("Callback must be a function");
2403
2572
  const maxRetries = options?.maxRetries ?? 3;
2404
2573
  const isRetryable = (err, attempt) => {
2405
- return err instanceof TransactionIsBusyError && (options?.retryOnBusy ?? err.hasLog) && attempt <= maxRetries;
2574
+ return err instanceof TransactionRetryableError && (options?.retryOnBusy ?? err.hasLog) && attempt <= maxRetries;
2406
2575
  };
2407
2576
  const runAttempt = (attempt) => {
2408
2577
  const txn = new Transaction(this.store, options);
@@ -2598,7 +2767,7 @@ function parseTransactionLog(path, options = {}) {
2598
2767
 
2599
2768
  //#endregion
2600
2769
  //#region src/transaction-log-reader.ts
2601
- const FLOAT_TO_UINT32 = new Float64Array(1);
2770
+ const FLOAT_TO_UINT32 = /* @__PURE__ */ new Float64Array(1);
2602
2771
  const UINT32_FROM_FLOAT = new Uint32Array(FLOAT_TO_UINT32.buffer);
2603
2772
  const { TRANSACTION_LOG_FILE_HEADER_SIZE, TRANSACTION_LOG_ENTRY_HEADER_SIZE } = constants;
2604
2773
  /**
@@ -2784,7 +2953,7 @@ function loadLastPosition(transactionLog, readUncommitted) {
2784
2953
  //#region src/index.ts
2785
2954
  const versions = {
2786
2955
  rocksdb: version,
2787
- "rocksdb-js": "2.4.0"
2956
+ "rocksdb-js": "2.5.0"
2788
2957
  };
2789
2958
 
2790
2959
  //#endregion
@@ -2804,5 +2973,6 @@ exports.registryStatus = registryStatus;
2804
2973
  exports.shutdown = shutdown;
2805
2974
  exports.stats = stats;
2806
2975
  exports.tryFileLock = tryFileLock;
2976
+ exports.validateTransactionLogStore = validateTransactionLogStore;
2807
2977
  exports.versions = versions;
2808
2978
  //# sourceMappingURL=index.cjs.map