@harperfast/rocksdb-js 2.4.1 → 2.6.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
@@ -62,6 +62,15 @@ const removeGlobalListener = binding.removeListener;
62
62
  const globalListenerCount = binding.listenerCount;
63
63
  const globalNotify = binding.notify;
64
64
  const constants = binding.constants;
65
+ /**
66
+ * The friendly names of every compression algorithm compiled into the loaded
67
+ * RocksDB native binding, e.g. `['none', 'snappy', 'lz4', 'zstd']`. The set is
68
+ * fixed for a given binary (it depends on which compression libraries RocksDB
69
+ * was linked against), so this is a static list. `'none'` (no compression) is
70
+ * always present. Use it to validate a `compression` option or to pick an
71
+ * algorithm that is actually available at runtime.
72
+ */
73
+ const supportedCompression = Object.freeze(binding.supportedCompression);
65
74
  const NativeDatabase = binding.Database;
66
75
  const NativeIterator = binding.Iterator;
67
76
  const NativeTransaction = binding.Transaction;
@@ -90,13 +99,21 @@ const coolTransactionLogs = binding.coolTransactionLogs;
90
99
  */
91
100
  const transactionLogMapCount = binding.transactionLogMapCount;
92
101
  /**
102
+ * Test-only: force the next `count` transaction commits to fail with TryAgain (rolled back, so
103
+ * no data is committed), reproducing a stranded-snapshot conflict deterministically. Pass 0 to
104
+ * disarm. Used by the ERR_TRY_AGAIN retry regression test.
105
+ */
106
+ const forceTryAgainForTesting = binding.forceTryAgainForTesting;
107
+ /**
93
108
  * 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.
109
+ * `LockFileEx` on Windows), creating the file and any missing parent
110
+ * directories. Exclusive by default; pass `shared` for a shared (reader) lock
111
+ * that coexists with other shared holders but conflicts with an exclusive
112
+ * holder in either direction. Returns an opaque non-zero token to pass to
113
+ * `fileLockRelease`, or `0` if a conflicting holder — in any process,
114
+ * container, or worker thread — currently has it. Throws on a hard error. The
115
+ * OS handle is owned entirely in native code (no fd crosses into JS), and the
116
+ * kernel releases the lock when the handle closes, including on process death.
100
117
  */
101
118
  const tryFileLock = binding.tryFileLock;
102
119
  /**
@@ -109,9 +126,51 @@ const nativeBackupList = binding.backupList;
109
126
  const nativeBackupDelete = binding.backupDelete;
110
127
  const nativeBackupPurge = binding.backupPurge;
111
128
  const nativeBackupVerify = binding.backupVerify;
129
+ const nativeValidateTransactionLog = binding.validateTransactionLog;
112
130
  const stats = binding.stats;
113
131
  const version = binding.version;
114
132
 
133
+ //#endregion
134
+ //#region src/validate-transaction-log.ts
135
+ /**
136
+ * Validates a transaction log store directory: every `<sequence>.txnlog` file
137
+ * is checked for a valid header (token, version) and intact v1 entry framing
138
+ * (using the same scan as open-time crash recovery), file-name and sequence
139
+ * continuity are checked, and the `txn.state` side file (when present) is
140
+ * checked for shape and a plausible flushed position. The result reports
141
+ * per-file entry counts, errors, and warnings; it does not throw when the
142
+ * store is invalid — check `result.valid`.
143
+ *
144
+ * Runs entirely in native code on a worker thread and does not require (or
145
+ * open) a database, so it works on the store of a closed database and on the
146
+ * per-backup snapshots under `<backupDir>/transaction_logs/<backupId>/` alike.
147
+ *
148
+ * Validating a store that is being actively appended to can spuriously report
149
+ * a torn tail for the current log file — the tail of an in-flight append is
150
+ * indistinguishable from a crash artifact.
151
+ *
152
+ * Rejects if `path` does not exist or is not a directory (a bad argument, as
153
+ * opposed to an invalid store).
154
+ *
155
+ * @param path The store directory, e.g. `<dbDir>/transaction_logs/<name>`.
156
+ * @param options Validation options.
157
+ *
158
+ * @example
159
+ * ```typescript
160
+ * import { validateTransactionLogStore } from '@harperfast/rocksdb-js';
161
+ *
162
+ * const result = await validateTransactionLogStore('/path/to/db/transaction_logs/mylog');
163
+ * if (!result.valid) {
164
+ * for (const file of result.files) {
165
+ * console.error(file.file, file.errors);
166
+ * }
167
+ * }
168
+ * ```
169
+ */
170
+ function validateTransactionLogStore(path, options) {
171
+ return new Promise((resolve, reject) => nativeValidateTransactionLog(resolve, reject, path, options?.strict ?? false));
172
+ }
173
+
115
174
  //#endregion
116
175
  //#region src/backup.ts
117
176
  /** Subdirectory (under a backup directory) holding per-backup transaction log snapshots. */
@@ -127,17 +186,29 @@ async function exists(path) {
127
186
  }
128
187
  /**
129
188
  * 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
189
+ * `fn` settles. The lock is exclusive by default — used by the writable-engine
190
+ * operations `backups.delete` and `backups.purge`; backup creation
191
+ * (`Store.backup`) takes the same exclusive lock on the same file natively
192
+ * inside `Database::Backup` (see `runCreateBackup` in
133
193
  * `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.
194
+ * created. Throws immediately (without running `fn`) if the backup directory
195
+ * is missing — `tryFileLock` creates missing parents, but `delete`/`purge`/
196
+ * `restore` on a nonexistent directory is a caller error and must not conjure
197
+ * an empty one — or if a conflicting holder has the directory lock.
136
198
  *
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.
199
+ * `backups.restore` holds the lock in `shared` mode for its source read:
200
+ * shared holders coexist (concurrent restores are safe), but a writer's
201
+ * exclusive acquisition rejects while a restore is in flight — and vice versa.
202
+ * Without this, a `purge`/`delete` racing a restore can delete the very files
203
+ * the restore is copying, after the restore's default `purgeAllFiles` mode has
204
+ * already wiped the destination — failing the restore with no fallback. A
205
+ * rejected writer, by contrast, just retries later.
206
+ *
207
+ * The remaining read-only operations (`list`, `verify`) use
208
+ * `BackupEngineReadOnly` and are not locked: concurrent readers are safe, and
209
+ * locking them would make cheap listings reject during a long backup. Running
210
+ * them concurrently with a `delete`/`purge` on the same directory is a
211
+ * caller-managed hazard, matching RocksDB's one-writable-engine-per-dir model.
141
212
  *
142
213
  * RocksDB only serializes work within a single `BackupEngine` instance and has
143
214
  * no lock on the backup directory itself. Two writable engines — in any
@@ -163,8 +234,9 @@ async function exists(path) {
163
234
  * The lock file is never deleted, only locked and unlocked; an unlocked, empty
164
235
  * `.backup.lock` at the directory root is the steady state.
165
236
  */
166
- async function withBackupDirLock(backupDir, fn) {
167
- const token = tryFileLock(join(backupDir, ".backup.lock"));
237
+ async function withBackupDirLock(backupDir, fn, options) {
238
+ if (!await exists(backupDir)) throw new Error(`Backup directory does not exist: ${backupDir}`);
239
+ const token = tryFileLock(join(backupDir, ".backup.lock"), options?.shared === true);
168
240
  if (token === 0) throw new Error(`Backup directory is locked: ${backupDir}`);
169
241
  try {
170
242
  return await fn();
@@ -173,6 +245,40 @@ async function withBackupDirLock(backupDir, fn) {
173
245
  }
174
246
  }
175
247
  /**
248
+ * Throws if `backupDir` resolves to the database directory itself or any path
249
+ * beneath it. Backing up into the live database directory would write backup
250
+ * files (the `.backup.lock`, `meta/`, `shared/`, `private/` subtrees) on top of
251
+ * RocksDB's own files, and every subsequent backup would recursively capture
252
+ * the prior ones. Both paths are resolved first so trailing slashes and
253
+ * relative/absolute variants of the same location cannot slip past the check.
254
+ *
255
+ * Only the "backup dir at or under the database dir" direction is rejected —
256
+ * the case the user hits by pointing a backup at `<db>` or `<db>/backups`. The
257
+ * reverse (a database nested under the backup dir) is left to the caller: a
258
+ * backup engine writes into named subtrees, not on top of arbitrary sibling
259
+ * files, so it does not clobber the database in place.
260
+ *
261
+ * On case-insensitive filesystems `<db>` and `<db>/../DB/backup` are the same
262
+ * directory on disk, but `relative()` compares case-sensitively and would call
263
+ * the second one "outside". We case-fold both paths on the platforms whose
264
+ * default filesystem is case-insensitive (macOS, Windows) so a casing
265
+ * difference cannot slip a backup into the database directory. This is a
266
+ * platform heuristic, not a per-volume probe (a case-sensitive macOS/Windows
267
+ * volume exists), so it can over-reject a legitimately differently-cased
268
+ * sibling there — the safe direction for a guard against clobbering the live
269
+ * database.
270
+ */
271
+ function assertBackupDirOutsideDatabase(dbPath, backupDir) {
272
+ let resolvedDb = resolve(dbPath);
273
+ let resolvedBackup = resolve(backupDir);
274
+ if (process.platform === "win32" || process.platform === "darwin") {
275
+ resolvedDb = resolvedDb.toLowerCase();
276
+ resolvedBackup = resolvedBackup.toLowerCase();
277
+ }
278
+ const rel = relative(resolvedDb, resolvedBackup);
279
+ 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}`);
280
+ }
281
+ /**
176
282
  * Removes `<backupDir>/transaction_logs/<id>` subtrees whose backup id no longer
177
283
  * corresponds to a surviving backup. Called after a `purge` (which holds the
178
284
  * backup-dir lock); a no-op when no transaction logs were backed up. Self-healing:
@@ -253,18 +359,32 @@ const backups = {
253
359
  * Restores a database from a backup directory into a (closed) database
254
360
  * directory. The default mode purges the destination directory, so it must
255
361
  * not point at a live database.
362
+ *
363
+ * Holds the backup directory lock in shared mode for the duration: multiple
364
+ * restores can read concurrently, but a writer (`db.backup()`, `delete`,
365
+ * `purge`) rejects while a restore is in flight rather than deleting files
366
+ * out from under it — and a restore rejects while a writer holds the lock.
367
+ *
368
+ * The shared lock needs no write access to `backupDir`, so restoring from a
369
+ * read-only backup directory (immutable/WORM store, read-only-mounted volume)
370
+ * works: the lock is taken on an existing `.backup.lock` read-only, degrading
371
+ * to a no-op only when the media is read-only for every process (`EROFS`). A
372
+ * mere permission denial hard-fails instead — it doesn't prove a privileged
373
+ * writer isn't running.
256
374
  */
257
375
  async restore(backupDir, dbDir, options) {
258
376
  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);
377
+ return withBackupDirLock(backupDir, async () => {
378
+ const walDir = options?.walDir ?? dbDir;
379
+ await mkdir(dbDir, { recursive: true });
380
+ if (walDir !== dbDir) await mkdir(walDir, { recursive: true });
381
+ await new Promise((resolve, reject) => nativeBackupRestore(resolve, reject, backupDir, dbDir, walDir, {
382
+ backupId: options?.backupId,
383
+ keepLogFiles: options?.keepLogFiles,
384
+ mode: options?.mode
385
+ }));
386
+ await restoreTransactionLogs(backupDir, dbDir, options?.backupId);
387
+ }, { shared: true });
268
388
  },
269
389
  /**
270
390
  * Lists the non-corrupt backups in a backup directory, ordered by id.
@@ -301,9 +421,28 @@ const backups = {
301
421
  /**
302
422
  * Verifies a backup's file sizes, and optionally their checksums (which
303
423
  * 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));
424
+ *
425
+ * When the backup was created with `transactionLogs: true`, the backup's
426
+ * transaction log snapshot (`<backupDir>/transaction_logs/<backupId>/`) is
427
+ * also validated — every log file's header and entry framing must be intact
428
+ * (snapshots are copied on committed entry boundaries, so even a torn tail
429
+ * is a verification failure). Set `verifyTransactionLogs: false` to skip.
430
+ */
431
+ async verify(backupDir, backupId, options) {
432
+ await new Promise((resolve, reject) => nativeBackupVerify(resolve, reject, backupDir, backupId, options?.verifyWithChecksum ?? false));
433
+ if (options?.verifyTransactionLogs === false) return;
434
+ const logsDir = join(backupDir, TRANSACTION_LOGS_DIRNAME, String(backupId));
435
+ if (!await exists(logsDir)) return;
436
+ const failures = [];
437
+ for (const entry of await readdir(logsDir, { withFileTypes: true })) {
438
+ if (!entry.isDirectory()) continue;
439
+ const result = await validateTransactionLogStore(join(logsDir, entry.name), { strict: true });
440
+ if (!result.valid) {
441
+ const details = [...result.errors, ...result.files.flatMap((file) => file.errors.map((error) => `${file.file}: ${error}`))];
442
+ failures.push(`${entry.name}: ${details.join("; ")}`);
443
+ }
444
+ }
445
+ if (failures.length > 0) throw new Error(`Backup ${backupId} transaction log verification failed: ${failures.join("\n")}`);
307
446
  }
308
447
  };
309
448
 
@@ -1073,13 +1212,50 @@ const ITERATOR_STATE = new Uint32Array(ITERATOR_STATE_BUFFER.buffer, ITERATOR_ST
1073
1212
  const MAX_KEY_SIZE = 1024 * 1024;
1074
1213
  const SAVE_BUFFER_SIZE = 8192;
1075
1214
  /**
1215
+ * Normalizes the public `compression` option into the primitive fields the
1216
+ * native layer expects. When `option` is omitted, returns an empty object and
1217
+ * lets the native layer apply the default (LZ4 when the build supports it, else
1218
+ * RocksDB's own default). Throws a `TypeError` for a malformed option and an
1219
+ * `Error` for an algorithm that isn't supported by this build.
1220
+ */
1221
+ function normalizeCompression(option) {
1222
+ if (option === void 0 || option === null) return {};
1223
+ let algorithm;
1224
+ let level;
1225
+ if (typeof option === "string") algorithm = option;
1226
+ else if (typeof option === "object" && option.algorithm !== void 0) {
1227
+ algorithm = option.algorithm;
1228
+ if (option.level !== void 0 && option.level !== null) {
1229
+ const raw = option.level;
1230
+ const coerced = typeof raw === "number" ? raw : typeof raw === "string" && raw.trim() !== "" ? Number(raw) : NaN;
1231
+ if (!Number.isInteger(coerced) || coerced < -2147483648 || coerced > 2147483647) throw new TypeError(`compression level must be a 32-bit integer, got ${JSON.stringify(option.level)}`);
1232
+ level = coerced;
1233
+ }
1234
+ } else throw new TypeError("compression must be an algorithm name or an { algorithm, level } object");
1235
+ if (!supportedCompression.includes(algorithm)) throw new Error(`Unsupported compression algorithm "${algorithm}". This build supports: ${supportedCompression.join(", ")}`);
1236
+ return {
1237
+ compression: algorithm,
1238
+ compressionLevel: level
1239
+ };
1240
+ }
1241
+ /**
1076
1242
  * A store wraps the `NativeDatabase` binding and database settings so that a
1077
1243
  * single database instance can be shared between the main `RocksDatabase`
1078
1244
  * instance and the `Transaction` instance.
1079
1245
  *
1080
- * This store should not be shared between `RocksDatabase` instances.
1246
+ * A store is bound to a single `RocksDatabase` instance and must not be shared
1247
+ * between `RocksDatabase` instances. Each `RocksDatabase` claims its store on
1248
+ * construction (see `claim()`); passing a store that another `RocksDatabase`
1249
+ * already owns throws. (Sharing with the owning database's `Transaction`
1250
+ * instances is expected and allowed.)
1081
1251
  */
1082
1252
  var Store = class {
1253
+ /**
1254
+ * Whether a `RocksDatabase` has claimed this store. Durable across
1255
+ * close/reopen (unlike the handle's open state), so a store reused after its
1256
+ * owner temporarily closed is still recognized as in-use. See `claim()`.
1257
+ */
1258
+ #claimed = false;
1083
1259
  /**
1084
1260
  * The database instance.
1085
1261
  */
@@ -1094,6 +1270,11 @@ var Store = class {
1094
1270
  */
1095
1271
  decoderCopies = false;
1096
1272
  /**
1273
+ * The compression algorithm (or `{ algorithm, level }`) requested for this
1274
+ * store's column family. Normalized and applied when the database opens.
1275
+ */
1276
+ compression;
1277
+ /**
1097
1278
  * Whether to disable the write ahead log.
1098
1279
  */
1099
1280
  disableWAL;
@@ -1132,6 +1313,16 @@ var Store = class {
1132
1313
  */
1133
1314
  maxKeySize;
1134
1315
  /**
1316
+ * The maximum number of table files RocksDB keeps open. `0` (the default)
1317
+ * derives a budget from the effective per-process open-file limit (an
1318
+ * eighth of the limit — several databases can share one process — clamped
1319
+ * to `[1024, 262144]`); `-1` holds every table file open (the RocksDB
1320
+ * default, which can exhaust the process file-descriptor limit when
1321
+ * compaction falls behind under sustained ingest); a positive `int32` is
1322
+ * an explicit cap.
1323
+ */
1324
+ maxOpenFiles;
1325
+ /**
1135
1326
  * The maximum number of memtables that can be queued per column family
1136
1327
  * before writes stall. Higher values absorb write bursts while flushes catch
1137
1328
  * up, at the cost of memory.
@@ -1241,6 +1432,7 @@ var Store = class {
1241
1432
  if (options !== void 0 && options !== null && typeof options !== "object") throw new TypeError("Database options must be an object");
1242
1433
  const { keyEncoding, readKey, writeKey } = initKeyEncoder(options?.keyEncoding, options?.keyEncoder);
1243
1434
  this.db = new NativeDatabase();
1435
+ this.compression = options?.compression;
1244
1436
  this.dbWriteBufferSize = options?.dbWriteBufferSize;
1245
1437
  this.decoder = options?.decoder ?? null;
1246
1438
  this.disableWAL = options?.disableWAL ?? false;
@@ -1252,6 +1444,7 @@ var Store = class {
1252
1444
  this.keyBuffer = KEY_BUFFER;
1253
1445
  this.keyEncoding = keyEncoding;
1254
1446
  this.maxKeySize = options?.maxKeySize ?? MAX_KEY_SIZE;
1447
+ this.maxOpenFiles = options?.maxOpenFiles;
1255
1448
  this.maxWriteBufferNumber = options?.maxWriteBufferNumber;
1256
1449
  this.maxWriteBufferSizeToMaintain = options?.maxWriteBufferSizeToMaintain;
1257
1450
  this.name = options?.name ?? "default";
@@ -1279,6 +1472,16 @@ var Store = class {
1279
1472
  this.db.close();
1280
1473
  }
1281
1474
  /**
1475
+ * Claims this store for a `RocksDatabase`. A store is bound to a single
1476
+ * database instance, so claiming a store that another `RocksDatabase` has
1477
+ * already claimed throws. The claim is durable across close/reopen, so a
1478
+ * store reused after its owner temporarily closed still throws.
1479
+ */
1480
+ claim() {
1481
+ if (this.#claimed) throw new Error("Store is already in use by another RocksDatabase instance");
1482
+ this.#claimed = true;
1483
+ }
1484
+ /**
1282
1485
  * Compacts the entire key range of the database asynchronously.
1283
1486
  * This triggers manual compaction which removes tombstones and reclaims space.
1284
1487
  *
@@ -1303,6 +1506,7 @@ var Store = class {
1303
1506
  }
1304
1507
  async backup(target, options) {
1305
1508
  if (typeof target !== "string" && typeof target?.getWriter === "function") return backupToStream(this.db, target, options);
1509
+ assertBackupDirOutsideDatabase(this.path, target);
1306
1510
  return new Promise((resolve, reject) => this.db.backup(resolve, reject, target, options));
1307
1511
  }
1308
1512
  /**
@@ -1539,10 +1743,14 @@ var Store = class {
1539
1743
  */
1540
1744
  open() {
1541
1745
  if (this.db.opened) return true;
1746
+ const { compression, compressionLevel } = normalizeCompression(this.compression);
1542
1747
  this.db.open(this.path, {
1748
+ compression,
1749
+ compressionLevel,
1543
1750
  dbWriteBufferSize: this.dbWriteBufferSize,
1544
1751
  disableWAL: this.disableWAL,
1545
1752
  enableStats: this.enableStats,
1753
+ maxOpenFiles: this.maxOpenFiles,
1546
1754
  maxWriteBufferNumber: this.maxWriteBufferNumber,
1547
1755
  maxWriteBufferSizeToMaintain: this.maxWriteBufferSizeToMaintain,
1548
1756
  mode: this.pessimistic ? "pessimistic" : "optimistic",
@@ -1661,8 +1869,18 @@ const RETRY_NOW = constants.RETRY_NOW_VALUE;
1661
1869
  var TransactionAlreadyAbortedError = class extends Error {
1662
1870
  code = "ERR_ALREADY_ABORTED";
1663
1871
  };
1664
- var TransactionIsBusyError = class extends Error {
1665
- code = "ERR_BUSY";
1872
+ /**
1873
+ * Base for commit conflicts the native layer has already recovered from by resetting the
1874
+ * transaction in place (fresh RocksDB snapshot; `committedPosition` preserved, so a staged
1875
+ * transaction-log batch stays write-once): the caller should re-run the transaction body
1876
+ * and retry the commit — the handling is identical for every subclass. `hasLog` reports
1877
+ * whether the transaction wrote to a transaction log (drives the default retry decision).
1878
+ *
1879
+ * {@link TransactionAbandonedError} shares this field shape but deliberately does NOT
1880
+ * extend this class — an abandoned transaction is not retryable, and retryability is the
1881
+ * boundary this hierarchy expresses.
1882
+ */
1883
+ var TransactionRetryableError = class extends Error {
1666
1884
  hasLog;
1667
1885
  txn;
1668
1886
  constructor(error, txn) {
@@ -1671,6 +1889,21 @@ var TransactionIsBusyError = class extends Error {
1671
1889
  this.txn = txn;
1672
1890
  }
1673
1891
  };
1892
+ /**
1893
+ * An optimistic commit detected a write conflict with a concurrently committed
1894
+ * transaction (RocksDB `Busy`).
1895
+ */
1896
+ var TransactionIsBusyError = class extends TransactionRetryableError {
1897
+ code = "ERR_BUSY";
1898
+ };
1899
+ /**
1900
+ * An optimistic commit could not be validated: the transaction's snapshot was stranded
1901
+ * outside the memtable window (its sequence history was flushed away — RocksDB
1902
+ * `TryAgain`), so the conflict check had nothing to validate against.
1903
+ */
1904
+ var TransactionTryAgainError = class extends TransactionRetryableError {
1905
+ code = "ERR_TRY_AGAIN";
1906
+ };
1674
1907
  var TransactionAbandonedError = class extends Error {
1675
1908
  code = "ERR_TRANSACTION_ABANDONED";
1676
1909
  hasLog;
@@ -1765,6 +1998,7 @@ var Transaction = class extends DBI {
1765
1998
  if (err instanceof Error && "code" in err) {
1766
1999
  if (err.code === "ERR_ALREADY_ABORTED") return new TransactionAlreadyAbortedError(err.message);
1767
2000
  if (err.code === "ERR_BUSY") return new TransactionIsBusyError(err, this);
2001
+ if (err.code === "ERR_TRY_AGAIN") return new TransactionTryAgainError(err, this);
1768
2002
  }
1769
2003
  return err;
1770
2004
  }
@@ -1814,9 +2048,12 @@ var RocksDatabase = class RocksDatabase extends DBI {
1814
2048
  */
1815
2049
  #name;
1816
2050
  constructor(pathOrStore, options) {
1817
- if (typeof pathOrStore === "string") super(new Store(pathOrStore, options));
1818
- else if (pathOrStore instanceof Store) super(pathOrStore);
2051
+ let store;
2052
+ if (typeof pathOrStore === "string") store = new Store(pathOrStore, options);
2053
+ else if (pathOrStore instanceof Store) store = pathOrStore;
1819
2054
  else throw new TypeError("Invalid database path or store");
2055
+ store.claim();
2056
+ super(store);
1820
2057
  this.#name = options?.name ?? "default";
1821
2058
  }
1822
2059
  /**
@@ -1930,6 +2167,22 @@ var RocksDatabase = class RocksDatabase extends DBI {
1930
2167
  return this.store.db.columns;
1931
2168
  }
1932
2169
  /**
2170
+ * The compression currently in effect for this database's column family, read
2171
+ * live from RocksDB, as `{ algorithm, level? }`. `algorithm` is a friendly
2172
+ * name (e.g. `'lz4'`, `'zstd'`, `'none'`); `level` is present only when a
2173
+ * non-default compression level is set. The database must be open. Defaults to
2174
+ * LZ4 when the native build supports it.
2175
+ *
2176
+ * @example
2177
+ * ```typescript
2178
+ * const db = RocksDatabase.open('/path/to/db', { compression: { algorithm: 'zstd', level: 3 } });
2179
+ * db.compression; // { algorithm: 'zstd', level: 3 }
2180
+ * ```
2181
+ */
2182
+ get compression() {
2183
+ return this.store.db.getCompression();
2184
+ }
2185
+ /**
1933
2186
  * Set global database settings.
1934
2187
  *
1935
2188
  * @param options - The options for the database.
@@ -2345,7 +2598,7 @@ var RocksDatabase = class RocksDatabase extends DBI {
2345
2598
  commitResult = await txn.commit();
2346
2599
  } catch (commitErr) {
2347
2600
  if (commitErr instanceof TransactionAlreadyAbortedError) return;
2348
- if (commitErr instanceof TransactionIsBusyError && (options?.retryOnBusy ?? commitErr.hasLog) && attempt < maxRetries) continue;
2601
+ if (commitErr instanceof TransactionRetryableError && (options?.retryOnBusy ?? commitErr.hasLog) && attempt < maxRetries) continue;
2349
2602
  this.#abandonTransaction(txn, commitErr);
2350
2603
  return;
2351
2604
  }
@@ -2377,7 +2630,7 @@ var RocksDatabase = class RocksDatabase extends DBI {
2377
2630
  if (typeof callback !== "function") throw new TypeError("Callback must be a function");
2378
2631
  const maxRetries = options?.maxRetries ?? 3;
2379
2632
  const isRetryable = (err, attempt) => {
2380
- return err instanceof TransactionIsBusyError && (options?.retryOnBusy ?? err.hasLog) && attempt <= maxRetries;
2633
+ return err instanceof TransactionRetryableError && (options?.retryOnBusy ?? err.hasLog) && attempt <= maxRetries;
2381
2634
  };
2382
2635
  const runAttempt = (attempt) => {
2383
2636
  const txn = new Transaction(this.store, options);
@@ -2573,7 +2826,7 @@ function parseTransactionLog(path, options = {}) {
2573
2826
 
2574
2827
  //#endregion
2575
2828
  //#region src/transaction-log-reader.ts
2576
- const FLOAT_TO_UINT32 = new Float64Array(1);
2829
+ const FLOAT_TO_UINT32 = /* @__PURE__ */ new Float64Array(1);
2577
2830
  const UINT32_FROM_FLOAT = new Uint32Array(FLOAT_TO_UINT32.buffer);
2578
2831
  const { TRANSACTION_LOG_FILE_HEADER_SIZE, TRANSACTION_LOG_ENTRY_HEADER_SIZE } = constants;
2579
2832
  /**
@@ -2759,9 +3012,9 @@ function loadLastPosition(transactionLog, readUncommitted) {
2759
3012
  //#region src/index.ts
2760
3013
  const versions = {
2761
3014
  rocksdb: version,
2762
- "rocksdb-js": "2.4.1"
3015
+ "rocksdb-js": "2.6.0"
2763
3016
  };
2764
3017
 
2765
3018
  //#endregion
2766
- export { DBI, DBIterator, RocksDatabase, Store, Transaction, TransactionLog, backups, constants, coolTransactionLogs, currentThreadId, fileLockRelease, parseTransactionLog, registryStatus, shutdown, stats, tryFileLock, versions };
3019
+ export { DBI, DBIterator, RocksDatabase, Store, Transaction, TransactionLog, backups, constants, coolTransactionLogs, currentThreadId, fileLockRelease, parseTransactionLog, registryStatus, shutdown, stats, supportedCompression, tryFileLock, validateTransactionLogStore, versions };
2767
3020
  //# sourceMappingURL=index.mjs.map