@harperfast/rocksdb-js 2.3.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,15 +1,15 @@
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
- import { mkdir } from "node:fs/promises";
6
+ import { access, cp, mkdir, readdir, rm } from "node:fs/promises";
7
7
  import * as orderedBinary from "ordered-binary";
8
8
  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
@@ -89,16 +89,248 @@ const coolTransactionLogs = binding.coolTransactionLogs;
89
89
  * the underlying mapping rather than leaving it retained.
90
90
  */
91
91
  const transactionLogMapCount = binding.transactionLogMapCount;
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
+ /**
99
+ * Creates a native file lock using the specified file path (`flock` on POSIX,
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.
108
+ */
109
+ const tryFileLock = binding.tryFileLock;
110
+ /**
111
+ * Releases a file lock acquired via `tryFileLock`. A no-op for
112
+ * token `0` or an unknown token.
113
+ */
114
+ const fileLockRelease = binding.fileLockRelease;
92
115
  const nativeBackupRestore = binding.backupRestore;
93
116
  const nativeBackupList = binding.backupList;
94
117
  const nativeBackupDelete = binding.backupDelete;
95
118
  const nativeBackupPurge = binding.backupPurge;
96
119
  const nativeBackupVerify = binding.backupVerify;
120
+ const nativeValidateTransactionLog = binding.validateTransactionLog;
97
121
  const stats = binding.stats;
98
122
  const version = binding.version;
99
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
+
100
165
  //#endregion
101
166
  //#region src/backup.ts
167
+ /** Subdirectory (under a backup directory) holding per-backup transaction log snapshots. */
168
+ const TRANSACTION_LOGS_DIRNAME = "transaction_logs";
169
+ /** Non-blocking existence check (`fs.existsSync` would block the event loop). */
170
+ async function exists(path) {
171
+ try {
172
+ await access(path);
173
+ return true;
174
+ } catch {
175
+ return false;
176
+ }
177
+ }
178
+ /**
179
+ * Runs `fn` while holding a native file lock for `backupDir`, releasing it when
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
184
+ * `src/binding/database/backup.cpp`), where the backup directory is also
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.
189
+ *
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.
203
+ *
204
+ * RocksDB only serializes work within a single `BackupEngine` instance and has
205
+ * no lock on the backup directory itself. Two writable engines — in any
206
+ * processes — creating/deleting/purging backups in the same directory
207
+ * concurrently race on the per-backup staging directory and both fail,
208
+ * potentially leaving the directory with no usable backup. An in-memory lock
209
+ * cannot prevent this across processes, so the lock lives on disk: a kernel
210
+ * advisory lock (`flock` on POSIX, `LockFileEx` on Windows) held on a
211
+ * `.backup.lock` file at the directory root.
212
+ *
213
+ * The lock is taken entirely in native code (`tryFileLock`), which opens,
214
+ * locks, and later closes the file's OS handle without ever exposing a
215
+ * descriptor to JS — the addon statically links its own C runtime, so a
216
+ * Node/libuv fd is not usable across that boundary. The kernel owning the lock
217
+ * buys two properties a pidfile cannot:
218
+ *
219
+ * - No staleness heuristic. The lock is released when the holder's handle closes
220
+ * — normal release, crash, `kill -9`, container exit — so there is nothing to
221
+ * reclaim and no liveness check.
222
+ * - True cross-context exclusion. The lock conflicts across processes, containers
223
+ * sharing a volume (same kernel), and `worker_threads`.
224
+ *
225
+ * The lock file is never deleted, only locked and unlocked; an unlocked, empty
226
+ * `.backup.lock` at the directory root is the steady state.
227
+ */
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);
231
+ if (token === 0) throw new Error(`Backup directory is locked: ${backupDir}`);
232
+ try {
233
+ return await fn();
234
+ } finally {
235
+ fileLockRelease(token);
236
+ }
237
+ }
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
+ /**
273
+ * Removes `<backupDir>/transaction_logs/<id>` subtrees whose backup id no longer
274
+ * corresponds to a surviving backup. Called after a `purge` (which holds the
275
+ * backup-dir lock); a no-op when no transaction logs were backed up. Self-healing:
276
+ * it drops every log subtree without a live backup. Note the surviving set comes
277
+ * from `nativeBackupList`, which omits corrupt backups — a still-present but
278
+ * corrupt backup's logs may be pruned (acceptable: it cannot be restored anyway).
279
+ * This also sweeps `.staging-*` leftovers from a crashed backup process (a
280
+ * staging name never matches a live backup id); backup creation sweeps them
281
+ * natively as well, under the backup-dir lock.
282
+ */
283
+ async function pruneOrphanedTransactionLogs(backupDir) {
284
+ const logsRoot = join(backupDir, TRANSACTION_LOGS_DIRNAME);
285
+ let ids;
286
+ try {
287
+ ids = await readdir(logsRoot);
288
+ } catch (err) {
289
+ if (err.code === "ENOENT") return;
290
+ throw err;
291
+ }
292
+ const list = await new Promise((resolve, reject) => nativeBackupList(resolve, reject, backupDir));
293
+ const surviving = new Set(list.map((info) => String(info.backupId)));
294
+ await Promise.all(ids.filter((id) => !surviving.has(id)).map((id) => rm(join(logsRoot, id), {
295
+ recursive: true,
296
+ force: true
297
+ })));
298
+ }
299
+ /**
300
+ * Restores the transaction log snapshot for the restored backup into
301
+ * `<dbDir>/transaction_logs/`, wiping the destination first so that restoring an
302
+ * older backup over a newer one cannot leave stale (newer) log files behind.
303
+ *
304
+ * Only acts when THIS backup actually captured logs: if the restored id has no
305
+ * snapshot, the destination is left untouched. Wiping unconditionally would
306
+ * destroy on-disk logs that a mixed-mode (`transactionLogs:false`) backup never
307
+ * managed.
308
+ *
309
+ * Offline only: the files are picked up when the database is next opened. mtimes
310
+ * are preserved because the store derives file age (rotation/retention) from
311
+ * mtime — a fresh mtime would break retention.
312
+ */
313
+ async function restoreTransactionLogs(backupDir, dbDir, backupId) {
314
+ const logsRoot = join(backupDir, TRANSACTION_LOGS_DIRNAME);
315
+ if (!await exists(logsRoot)) return;
316
+ let id = backupId;
317
+ if (id === void 0) {
318
+ const list = await new Promise((resolve, reject) => nativeBackupList(resolve, reject, backupDir));
319
+ if (list.length === 0) return;
320
+ id = Math.max(...list.map((info) => info.backupId));
321
+ }
322
+ const logsSrc = join(logsRoot, String(id));
323
+ if (!await exists(logsSrc)) return;
324
+ const logsDest = join(dbDir, TRANSACTION_LOGS_DIRNAME);
325
+ await rm(logsDest, {
326
+ recursive: true,
327
+ force: true
328
+ });
329
+ await cp(logsSrc, logsDest, {
330
+ recursive: true,
331
+ preserveTimestamps: true
332
+ });
333
+ }
102
334
  /**
103
335
  * Backup management operations that act on a backup directory and do not
104
336
  * require an open database. To create a backup, use the `db.backup()` instance
@@ -118,17 +350,32 @@ const backups = {
118
350
  * Restores a database from a backup directory into a (closed) database
119
351
  * directory. The default mode purges the destination directory, so it must
120
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.
121
365
  */
122
366
  async restore(backupDir, dbDir, options) {
123
367
  if (resolve(backupDir) === resolve(dbDir)) throw new Error("Backup directory and database directory must be different");
124
- const walDir = options?.walDir ?? dbDir;
125
- await mkdir(dbDir, { recursive: true });
126
- if (walDir !== dbDir) await mkdir(walDir, { recursive: true });
127
- return new Promise((resolve, reject) => nativeBackupRestore(resolve, reject, backupDir, dbDir, walDir, {
128
- backupId: options?.backupId,
129
- keepLogFiles: options?.keepLogFiles,
130
- mode: options?.mode
131
- }));
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 });
132
379
  },
133
380
  /**
134
381
  * Lists the non-corrupt backups in a backup directory, ordered by id.
@@ -140,21 +387,53 @@ const backups = {
140
387
  * Deletes a specific backup. Shared files are reference-counted and only
141
388
  * removed once no remaining backup references them.
142
389
  */
143
- delete(backupDir, backupId) {
144
- return new Promise((resolve, reject) => nativeBackupDelete(resolve, reject, backupDir, backupId));
390
+ async delete(backupDir, backupId) {
391
+ return withBackupDirLock(backupDir, async () => {
392
+ await new Promise((resolve, reject) => nativeBackupDelete(resolve, reject, backupDir, backupId));
393
+ try {
394
+ await rm(join(backupDir, TRANSACTION_LOGS_DIRNAME, String(backupId)), {
395
+ recursive: true,
396
+ force: true
397
+ });
398
+ } catch {}
399
+ });
145
400
  },
146
401
  /**
147
402
  * Deletes all but the newest `keepCount` backups.
148
403
  */
149
- purge(backupDir, keepCount) {
150
- return new Promise((resolve, reject) => nativeBackupPurge(resolve, reject, backupDir, keepCount));
404
+ async purge(backupDir, keepCount) {
405
+ return withBackupDirLock(backupDir, async () => {
406
+ await new Promise((resolve, reject) => nativeBackupPurge(resolve, reject, backupDir, keepCount));
407
+ try {
408
+ await pruneOrphanedTransactionLogs(backupDir);
409
+ } catch {}
410
+ });
151
411
  },
152
412
  /**
153
413
  * Verifies a backup's file sizes, and optionally their checksums (which
154
414
  * requires reading all backed-up data).
155
- */
156
- verify(backupDir, backupId, options) {
157
- 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")}`);
158
437
  }
159
438
  };
160
439
 
@@ -518,6 +797,258 @@ var DBI = class DBI {
518
797
  }
519
798
  };
520
799
 
800
+ //#endregion
801
+ //#region src/tar.ts
802
+ /**
803
+ * A minimal, dependency-free streaming USTAR (POSIX tar) encoder.
804
+ *
805
+ * Built for streaming whole-database backups: the set of live RocksDB files
806
+ * (SSTs, blobs, MANIFEST, CURRENT, OPTIONS, WAL) is serialized into a single
807
+ * tar byte stream that can be piped to any destination and unpacked with
808
+ * standard tools (`tar -xf`). The encoder never buffers a whole file — each
809
+ * entry's payload is streamed through in chunks — so peak memory stays flat
810
+ * regardless of database size.
811
+ *
812
+ * Backpressure is the caller's lever: every method `await`s the {@link TarSink},
813
+ * so wiring the sink to a stream writer's `write()` naturally paces production
814
+ * to the consumer.
815
+ *
816
+ * Only what a RocksDB backup needs is implemented: regular files. Paths longer
817
+ * than USTAR's 100-byte `name` field are split across the `name` and `prefix`
818
+ * fields (~255 bytes total), which covers `transaction_logs/<store>/<file>`
819
+ * entries; GNU/pax long-name extensions are not implemented, so a path that
820
+ * still doesn't fit throws rather than being silently truncated. File sizes
821
+ * larger than the octal `size` field can hold (~8 GiB) fall back to the GNU
822
+ * base-256 encoding.
823
+ */
824
+ /** Size of a tar block. Every header and the archive length are multiples of this. */
825
+ const BLOCK_SIZE = 512;
826
+ /** Maximum bytes for the USTAR `name` field. */
827
+ const MAX_NAME_LENGTH = 100;
828
+ /** Maximum bytes for the USTAR `prefix` field (holds the leading directories). */
829
+ const MAX_PREFIX_LENGTH = 155;
830
+ const textEncoder = new TextEncoder();
831
+ const textDecoder = new TextDecoder();
832
+ /**
833
+ * Writes a UTF-8 string into a fixed-width header field, left-justified and
834
+ * NUL-padded (the block is already zero-filled, so nothing else is needed).
835
+ * Throws if the encoded value does not fit — tar fields are silently lossy
836
+ * otherwise, which corrupts the archive.
837
+ */
838
+ function writeField(block, value, offset, length) {
839
+ const bytes = textEncoder.encode(value);
840
+ if (bytes.length > length) throw new RangeError(`tar field value ${JSON.stringify(value)} exceeds ${length} bytes`);
841
+ block.set(bytes, offset);
842
+ }
843
+ /**
844
+ * Writes a non-negative integer as a NUL-terminated, zero-padded octal string
845
+ * into a `length`-byte field (so `length - 1` octal digits). Values too large
846
+ * for octal fall back to the GNU base-256 extension, where the high bit of the
847
+ * first byte is set and the magnitude is stored big-endian in the remainder.
848
+ */
849
+ function writeOctal(block, value, offset, length) {
850
+ const digits = length - 1;
851
+ if (value > 8 ** digits - 1) {
852
+ let v = value;
853
+ for (let i = offset + length - 1; i > offset; i--) {
854
+ block[i] = v & 255;
855
+ v = Math.floor(v / 256);
856
+ }
857
+ block[offset] = 128;
858
+ return;
859
+ }
860
+ const str = value.toString(8).padStart(digits, "0");
861
+ for (let i = 0; i < digits; i++) block[offset + i] = str.charCodeAt(i);
862
+ }
863
+ /**
864
+ * Computes and writes the USTAR header checksum: the unsigned sum of all 512
865
+ * header bytes with the checksum field itself taken as ASCII spaces. Stored as
866
+ * six octal digits, a NUL, then a space (the conventional encoding).
867
+ */
868
+ function writeChecksum(block) {
869
+ for (let i = 148; i < 156; i++) block[i] = 32;
870
+ let sum = 0;
871
+ for (let i = 0; i < BLOCK_SIZE; i++) sum += block[i];
872
+ const str = sum.toString(8).padStart(6, "0");
873
+ for (let i = 0; i < 6; i++) block[148 + i] = str.charCodeAt(i);
874
+ block[154] = 0;
875
+ block[155] = 32;
876
+ }
877
+ /**
878
+ * Splits a path into USTAR `name` (<=100 bytes) and `prefix` (<=155 bytes)
879
+ * fields at a `/` boundary; a reader rejoins them as `prefix + "/" + name`.
880
+ * Returns an empty prefix when the whole path fits in `name`. Throws when the
881
+ * path cannot be represented — a trailing component over 100 bytes, or leading
882
+ * directories that don't fit in 155. RocksDB and transaction-log filenames never
883
+ * hit this; only a pathological log-store name would.
884
+ */
885
+ function splitTarName(path) {
886
+ const encoded = textEncoder.encode(path);
887
+ if (encoded.length <= MAX_NAME_LENGTH) return {
888
+ name: path,
889
+ prefix: ""
890
+ };
891
+ let best = -1;
892
+ for (let i = 0; i < encoded.length; i++) {
893
+ if (encoded[i] !== 47) continue;
894
+ const prefixLength = i;
895
+ const nameLength = encoded.length - i - 1;
896
+ if (nameLength > 0 && nameLength <= MAX_NAME_LENGTH && prefixLength <= MAX_PREFIX_LENGTH) best = i;
897
+ }
898
+ if (best === -1) throw new RangeError(`tar entry name ${JSON.stringify(path)} does not fit the USTAR name (<=${MAX_NAME_LENGTH}) + prefix (<=${MAX_PREFIX_LENGTH}) fields`);
899
+ return {
900
+ name: textDecoder.decode(encoded.subarray(best + 1)),
901
+ prefix: textDecoder.decode(encoded.subarray(0, best))
902
+ };
903
+ }
904
+ /**
905
+ * Builds a single 512-byte USTAR header block for a regular file. Long paths are
906
+ * split across the `name` and `prefix` fields (see {@link splitTarName}).
907
+ */
908
+ function buildHeader(name, size, mode, mtime) {
909
+ const block = new Uint8Array(BLOCK_SIZE);
910
+ const split = splitTarName(name);
911
+ writeField(block, split.name, 0, MAX_NAME_LENGTH);
912
+ writeOctal(block, mode & 4095, 100, 8);
913
+ writeOctal(block, 0, 108, 8);
914
+ writeOctal(block, 0, 116, 8);
915
+ writeOctal(block, size, 124, 12);
916
+ writeOctal(block, Math.floor(mtime), 136, 12);
917
+ block[156] = 48;
918
+ writeField(block, "ustar", 257, 6);
919
+ block[263] = 48;
920
+ block[264] = 48;
921
+ if (split.prefix.length > 0) writeField(block, split.prefix, 345, MAX_PREFIX_LENGTH);
922
+ writeChecksum(block);
923
+ return block;
924
+ }
925
+ /**
926
+ * Streaming USTAR encoder. Usage is a strict sequence per file —
927
+ * {@link TarEncoder.addFile} once, then {@link TarEncoder.writeData} until the
928
+ * declared size is reached — followed by a single {@link TarEncoder.finalize}:
929
+ *
930
+ * ```ts
931
+ * const tar = new TarEncoder((chunk) => writer.write(chunk));
932
+ * await tar.addFile('CURRENT', current.length);
933
+ * await tar.writeData(current);
934
+ * await tar.addFile('000123.sst', sstSize);
935
+ * for await (const chunk of sstChunks) await tar.writeData(chunk);
936
+ * await tar.finalize();
937
+ * ```
938
+ *
939
+ * The size passed to `addFile` is authoritative: tar records it in the header
940
+ * before any payload, so it must be known up front (it is — RocksDB's
941
+ * `GetLiveFilesStorageInfo` returns each file's exact size). Writing more or
942
+ * fewer bytes than declared throws.
943
+ */
944
+ var TarEncoder = class {
945
+ #sink;
946
+ /** Payload bytes still expected for the current entry. */
947
+ #remaining = 0;
948
+ /** Zero-padding bytes owed after the current entry's payload reaches a block boundary. */
949
+ #pad = 0;
950
+ #finalized = false;
951
+ constructor(sink) {
952
+ this.#sink = sink;
953
+ }
954
+ /**
955
+ * Emits the header for a new file entry. Must be preceded by completing any
956
+ * prior entry's payload. After this, exactly `size` bytes must be supplied
957
+ * via {@link TarEncoder.writeData} before the next `addFile` or `finalize`.
958
+ */
959
+ async addFile(name, size, options) {
960
+ if (this.#finalized) throw new Error("TarEncoder already finalized");
961
+ if (this.#remaining !== 0) throw new Error(`previous tar entry incomplete: ${this.#remaining} byte(s) unwritten`);
962
+ if (!Number.isInteger(size) || size < 0) throw new RangeError("tar entry size must be a non-negative integer");
963
+ const header = buildHeader(name, size, options?.mode ?? 420, options?.mtime ?? 0);
964
+ this.#remaining = size;
965
+ this.#pad = (BLOCK_SIZE - size % BLOCK_SIZE) % BLOCK_SIZE;
966
+ await this.#sink(header);
967
+ }
968
+ /**
969
+ * Streams a chunk of the current entry's payload. Chunks may be any size; the
970
+ * total across all `writeData` calls for an entry must equal the size passed
971
+ * to {@link TarEncoder.addFile}. Block padding is emitted automatically once
972
+ * the final byte of an entry is written.
973
+ */
974
+ async writeData(chunk) {
975
+ if (this.#finalized) throw new Error("TarEncoder already finalized");
976
+ if (chunk.length === 0) return;
977
+ if (chunk.length > this.#remaining) throw new RangeError(`tar data overruns entry size by ${chunk.length - this.#remaining} byte(s)`);
978
+ this.#remaining -= chunk.length;
979
+ await this.#sink(chunk);
980
+ if (this.#remaining === 0 && this.#pad > 0) {
981
+ const pad = this.#pad;
982
+ this.#pad = 0;
983
+ await this.#sink(new Uint8Array(pad));
984
+ }
985
+ }
986
+ /**
987
+ * Convenience for an entry whose contents are already fully in memory (e.g.
988
+ * the small `CURRENT`/`OPTIONS` files RocksDB returns inline).
989
+ */
990
+ async addFileData(name, data, options) {
991
+ await this.addFile(name, data.length, options);
992
+ await this.writeData(data);
993
+ }
994
+ /**
995
+ * Writes the two zero-filled blocks that mark end-of-archive. The encoder is
996
+ * unusable afterward. Throws if the current entry is not fully written, so a
997
+ * truncated stream cannot be silently finalized into a valid-looking archive.
998
+ */
999
+ async finalize() {
1000
+ if (this.#finalized) throw new Error("TarEncoder already finalized");
1001
+ if (this.#remaining !== 0) throw new Error(`cannot finalize: current tar entry has ${this.#remaining} byte(s) unwritten`);
1002
+ this.#finalized = true;
1003
+ await this.#sink(new Uint8Array(BLOCK_SIZE * 2));
1004
+ }
1005
+ };
1006
+
1007
+ //#endregion
1008
+ //#region src/backup-stream.ts
1009
+ /** Native event discriminator: a new file header (vs. a payload chunk). */
1010
+ const EVENT_FILE = 0;
1011
+ /**
1012
+ * Streams a consistent snapshot of the database to a `WritableStream` as a tar
1013
+ * archive, with no intermediate copy on disk. The set of live RocksDB files is
1014
+ * enumerated and read natively; each file header and payload chunk is framed
1015
+ * into the {@link TarEncoder} here in JS and written to `stream`.
1016
+ *
1017
+ * Backpressure flows end to end: the encoder awaits each `writer.write()`, and
1018
+ * the native producer awaits each `emit()` before reading the next chunk, so a
1019
+ * slow consumer paces the entire pipeline rather than buffering in memory.
1020
+ *
1021
+ * The resulting archive can be unpacked with any tar tool (`tar -xf`) into a
1022
+ * directory that opens directly as a RocksDB database.
1023
+ */
1024
+ async function backupToStream(db, stream, options) {
1025
+ let tarDestination;
1026
+ let piped;
1027
+ if (options?.gzip) {
1028
+ const gzip = new CompressionStream("gzip");
1029
+ piped = gzip.readable.pipeTo(stream);
1030
+ tarDestination = gzip.writable;
1031
+ } else tarDestination = stream;
1032
+ const writer = tarDestination.getWriter();
1033
+ const tar = new TarEncoder((chunk) => writer.write(chunk));
1034
+ const emit = async (kind, data, size, mtime) => {
1035
+ if (kind === EVENT_FILE) await tar.addFile(data, size, { mtime });
1036
+ else await tar.writeData(data);
1037
+ };
1038
+ try {
1039
+ await new Promise((resolve, reject) => {
1040
+ db.backupStream(resolve, reject, emit, options);
1041
+ });
1042
+ await tar.finalize();
1043
+ await writer.close();
1044
+ if (piped) await piped;
1045
+ } catch (err) {
1046
+ await writer.abort(err).catch(() => {});
1047
+ if (piped) await piped.catch(() => {});
1048
+ throw err;
1049
+ }
1050
+ }
1051
+
521
1052
  //#endregion
522
1053
  //#region src/dbi-iterator.ts
523
1054
  const { ITERATOR_RESULT_DONE, ITERATOR_RESULT_FAST } = constants;
@@ -731,6 +1262,16 @@ var Store = class {
731
1262
  */
732
1263
  maxKeySize;
733
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
+ /**
734
1275
  * The maximum number of memtables that can be queued per column family
735
1276
  * before writes stall. Higher values absorb write bursts while flushes catch
736
1277
  * up, at the cost of memory.
@@ -851,6 +1392,7 @@ var Store = class {
851
1392
  this.keyBuffer = KEY_BUFFER;
852
1393
  this.keyEncoding = keyEncoding;
853
1394
  this.maxKeySize = options?.maxKeySize ?? MAX_KEY_SIZE;
1395
+ this.maxOpenFiles = options?.maxOpenFiles;
854
1396
  this.maxWriteBufferNumber = options?.maxWriteBufferNumber;
855
1397
  this.maxWriteBufferSizeToMaintain = options?.maxWriteBufferSizeToMaintain;
856
1398
  this.name = options?.name ?? "default";
@@ -900,19 +1442,10 @@ var Store = class {
900
1442
  }
901
1443
  return new Promise((resolve, reject) => this.db.compact(resolve, reject, startBuffer, endBuffer));
902
1444
  }
903
- /**
904
- * Creates a backup of the entire database (all column families) into the
905
- * given directory and resolves with the new backup id. Parent directories
906
- * are created as needed. See `backups` for restore and management.
907
- *
908
- * @example
909
- * ```typescript
910
- * const id = await db.backup('/path/to/backups');
911
- * ```
912
- */
913
- async backup(backupDir, options) {
914
- await mkdir(backupDir, { recursive: true });
915
- return new Promise((resolve, reject) => this.db.backup(resolve, reject, backupDir, options));
1445
+ async backup(target, options) {
1446
+ if (typeof target !== "string" && typeof target?.getWriter === "function") return backupToStream(this.db, target, options);
1447
+ assertBackupDirOutsideDatabase(this.path, target);
1448
+ return new Promise((resolve, reject) => this.db.backup(resolve, reject, target, options));
916
1449
  }
917
1450
  /**
918
1451
  * Compacts the entire key range of the database synchronously.
@@ -1152,6 +1685,7 @@ var Store = class {
1152
1685
  dbWriteBufferSize: this.dbWriteBufferSize,
1153
1686
  disableWAL: this.disableWAL,
1154
1687
  enableStats: this.enableStats,
1688
+ maxOpenFiles: this.maxOpenFiles,
1155
1689
  maxWriteBufferNumber: this.maxWriteBufferNumber,
1156
1690
  maxWriteBufferSizeToMaintain: this.maxWriteBufferSizeToMaintain,
1157
1691
  mode: this.pessimistic ? "pessimistic" : "optimistic",
@@ -1270,8 +1804,18 @@ const RETRY_NOW = constants.RETRY_NOW_VALUE;
1270
1804
  var TransactionAlreadyAbortedError = class extends Error {
1271
1805
  code = "ERR_ALREADY_ABORTED";
1272
1806
  };
1273
- var TransactionIsBusyError = class extends Error {
1274
- 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 {
1275
1819
  hasLog;
1276
1820
  txn;
1277
1821
  constructor(error, txn) {
@@ -1280,6 +1824,21 @@ var TransactionIsBusyError = class extends Error {
1280
1824
  this.txn = txn;
1281
1825
  }
1282
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
+ };
1283
1842
  var TransactionAbandonedError = class extends Error {
1284
1843
  code = "ERR_TRANSACTION_ABANDONED";
1285
1844
  hasLog;
@@ -1374,6 +1933,7 @@ var Transaction = class extends DBI {
1374
1933
  if (err instanceof Error && "code" in err) {
1375
1934
  if (err.code === "ERR_ALREADY_ABORTED") return new TransactionAlreadyAbortedError(err.message);
1376
1935
  if (err.code === "ERR_BUSY") return new TransactionIsBusyError(err, this);
1936
+ if (err.code === "ERR_TRY_AGAIN") return new TransactionTryAgainError(err, this);
1377
1937
  }
1378
1938
  return err;
1379
1939
  }
@@ -1481,25 +2041,9 @@ var RocksDatabase = class RocksDatabase extends DBI {
1481
2041
  compact(options) {
1482
2042
  return this.store.compact(options);
1483
2043
  }
1484
- /**
1485
- * Creates a backup of the entire database (all column families) into the
1486
- * given directory, creating parent directories as needed, and resolves with
1487
- * the new backup id. Use the `backups` namespace to restore, list, delete,
1488
- * purge, or verify backups.
1489
- *
1490
- * When the database was opened with `disableWAL`, the memtable is flushed
1491
- * before the backup by default so unflushed data is not lost.
1492
- *
1493
- * @example
1494
- * ```typescript
1495
- * import { backups } from '@harperfast/rocksdb-js';
1496
- *
1497
- * const db = RocksDatabase.open('/path/to/database');
1498
- * const id = await db.backup('/path/to/backups');
1499
- * ```
1500
- */
1501
- backup(backupDir, options) {
1502
- return this.store.backup(backupDir, options);
2044
+ backup(target, options) {
2045
+ if (typeof target === "string") return this.store.backup(target, options);
2046
+ return this.store.backup(target, options);
1503
2047
  }
1504
2048
  /**
1505
2049
  * Creates a hardlinked, point-in-time, fully independent copy of the entire
@@ -1970,7 +2514,7 @@ var RocksDatabase = class RocksDatabase extends DBI {
1970
2514
  commitResult = await txn.commit();
1971
2515
  } catch (commitErr) {
1972
2516
  if (commitErr instanceof TransactionAlreadyAbortedError) return;
1973
- if (commitErr instanceof TransactionIsBusyError && (options?.retryOnBusy ?? commitErr.hasLog) && attempt < maxRetries) continue;
2517
+ if (commitErr instanceof TransactionRetryableError && (options?.retryOnBusy ?? commitErr.hasLog) && attempt < maxRetries) continue;
1974
2518
  this.#abandonTransaction(txn, commitErr);
1975
2519
  return;
1976
2520
  }
@@ -2002,7 +2546,7 @@ var RocksDatabase = class RocksDatabase extends DBI {
2002
2546
  if (typeof callback !== "function") throw new TypeError("Callback must be a function");
2003
2547
  const maxRetries = options?.maxRetries ?? 3;
2004
2548
  const isRetryable = (err, attempt) => {
2005
- return err instanceof TransactionIsBusyError && (options?.retryOnBusy ?? err.hasLog) && attempt <= maxRetries;
2549
+ return err instanceof TransactionRetryableError && (options?.retryOnBusy ?? err.hasLog) && attempt <= maxRetries;
2006
2550
  };
2007
2551
  const runAttempt = (attempt) => {
2008
2552
  const txn = new Transaction(this.store, options);
@@ -2198,7 +2742,7 @@ function parseTransactionLog(path, options = {}) {
2198
2742
 
2199
2743
  //#endregion
2200
2744
  //#region src/transaction-log-reader.ts
2201
- const FLOAT_TO_UINT32 = new Float64Array(1);
2745
+ const FLOAT_TO_UINT32 = /* @__PURE__ */ new Float64Array(1);
2202
2746
  const UINT32_FROM_FLOAT = new Uint32Array(FLOAT_TO_UINT32.buffer);
2203
2747
  const { TRANSACTION_LOG_FILE_HEADER_SIZE, TRANSACTION_LOG_ENTRY_HEADER_SIZE } = constants;
2204
2748
  /**
@@ -2384,9 +2928,9 @@ function loadLastPosition(transactionLog, readUncommitted) {
2384
2928
  //#region src/index.ts
2385
2929
  const versions = {
2386
2930
  rocksdb: version,
2387
- "rocksdb-js": "2.3.0"
2931
+ "rocksdb-js": "2.5.0"
2388
2932
  };
2389
2933
 
2390
2934
  //#endregion
2391
- export { DBI, DBIterator, RocksDatabase, Store, Transaction, TransactionLog, backups, constants, coolTransactionLogs, currentThreadId, parseTransactionLog, registryStatus, shutdown, stats, versions };
2935
+ export { DBI, DBIterator, RocksDatabase, Store, Transaction, TransactionLog, backups, constants, coolTransactionLogs, currentThreadId, fileLockRelease, parseTransactionLog, registryStatus, shutdown, stats, tryFileLock, validateTransactionLogStore, versions };
2392
2936
  //# sourceMappingURL=index.mjs.map