@harperfast/rocksdb-js 2.9.1 → 2.11.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/README.md CHANGED
@@ -169,10 +169,25 @@ Creates a new database instance.
169
169
  - `transactionLogMaxSize: number` The target maximum size of a transaction log file. Transactions
170
170
  are never split across files: if the complete transaction does not fit, the log rotates before
171
171
  writing it. A transaction written to an empty file may exceed the target. Defaults to 16 MB.
172
+ `0` is rejected: it formerly meant unlimited segment size, which a reader's fixed-size mapping
173
+ cannot cover. A store configured with `0` must omit the option or set a size before opening;
174
+ once it rotates by size, rotated segments older than `transactionLogRetention` are purged, so
175
+ raise the retention to keep more history.
172
176
  - `transactionLogRetention: string | number` The number of minutes to retain transaction logs
173
177
  before purging. Defaults to `'3d'` (3 days).
174
178
  - `transactionLogsPath: string` The path to store transaction logs. Defaults to
175
179
  `"${db.path}/transaction_logs"`.
180
+ - `timestampFloorLog: string` The name of the transaction log whose batch keys this process
181
+ originates. At open, the process-wide monotonic clock is raised above every batch key still
182
+ durable in that log, so a backward wall-clock step between runs cannot reissue a transaction
183
+ timestamp that is already a key in it. See
184
+ [Timestamp floor at open](#timestamp-floor-at-open). Unset by default, which leaves the clock
185
+ alone. Its first use in a process must be on a writable primary open; read-only and secondary
186
+ handles can reuse an already-established seed for the same log but cannot establish one.
187
+ If that first seed warned because the log was absent, the log may be created fresh, but a
188
+ nonempty directory that reappears before first use is refused until every handle closes and the
189
+ path reopens with this option. The name must be a single path component that does not start with
190
+ `.`: startup discovery skips such directories, so the log could never be scanned again.
176
191
  - `verificationTable: boolean` When `true`, this column family participates in the process-global
177
192
  [Verification Table](#verification-table): transaction writes to this column family invalidate
178
193
  the verification slot for each written key. Enable this only for column families whose records
@@ -185,7 +200,13 @@ Creates a new database instance.
185
200
  ### `db.close()`
186
201
 
187
202
  Closes a database. This function can be called multiple times and will only close an opened
188
- database. A database instance can be reopened once its closed.
203
+ database. A database instance can be reopened once it is closed. A flush failure leaves the native
204
+ database quarantined so `shutdown()` can retry without losing unflushed data; an explicit
205
+ `destroy()` can instead delete it. A failure while waiting for compaction to settle is reported
206
+ after native teardown completes; the optional `compactOnClose` pass itself is best-effort and its
207
+ errors do not fail the close, since a skipped compaction loses no data. All native close errors
208
+ emit `database:closeFailed`. The quarantine applies to both writable and read-only opens because
209
+ both modes share the physical path lifecycle.
189
210
 
190
211
  ```typescript
191
212
  const db = RocksDatabase.open('foo');
@@ -212,12 +233,21 @@ when a non-default compression level is set. The database must be open. See
212
233
  [Compression](#compression).
213
234
 
214
235
  ```typescript
215
- const db = RocksDatabase.open('path/to/db', {
216
- compression: { algorithm: 'zstd', level: 3 },
217
- });
236
+ const db = RocksDatabase.open('path/to/db', { compression: { algorithm: 'zstd', level: 3 } });
218
237
  console.log(db.compression); // { algorithm: 'zstd', level: 3 }
219
238
  ```
220
239
 
240
+ ### `db.setCompression(compression)`
241
+
242
+ Dynamically changes the compression algorithm (and optional level) for this database's column
243
+ family on an already-open database — no close, no reopen. See [Compression](#compression) for full
244
+ semantics and caveats.
245
+
246
+ ```typescript
247
+ const db = RocksDatabase.open('path/to/db', { compression: 'none' });
248
+ db.setCompression({ algorithm: 'zstd', level: 19 });
249
+ ```
250
+
221
251
  ### `db.config(options)`
222
252
 
223
253
  Sets global database settings.
@@ -226,7 +256,65 @@ Sets global database settings.
226
256
  - `blockCacheSize: number` The amount of memory in bytes to use to cache uncompressed blocks.
227
257
  Defaults to 32MB. Set to `0` (zero) disables block cache for future opened databases. Existing
228
258
  block cache for any opened databases is resized immediately. Negative values throw an error.
259
+ - `commitThreads: number` The maximum number of dedicated threads each database opened after the
260
+ call uses to run async `transaction.commit()` calls. Defaults to `min(4, cores)`; accepts
261
+ integers from `1` through `64`. A thread starts only when a commit finds every started thread
262
+ busy, so a database that is never committed to concurrently owns one. Async commits never use
263
+ the libuv threadpool, so slow commits cannot delay `fs`, `dns`, `crypto` or async `get()` calls.
264
+ With more than one thread, RocksDB validates and applies concurrent commits on several cores
265
+ and can combine them into one write: with four worker threads committing 64-key transactions to
266
+ one database, this committed about twice as many transactions per second as one thread.
267
+ Concurrent commits finish, and their promises resolve, in any order, as concurrent
268
+ `commitSync()` calls always have, and their transaction-log entries are appended in the order
269
+ the commits reach the log; a log reader never sees an entry before its transaction's data is
270
+ committed. Log position is not the order in which writes to the same key were applied: a
271
+ commit that conflicts and is retried keeps its original log position but lands after commits
272
+ logged behind it. Its transaction timestamp does not advance across a retry either, so
273
+ replaying entries for one key in timestamp order does not recover true apply order — a
274
+ consumer that needs one must have the producer embed its own last-writer-wins marker.
275
+ Concurrent commit threads make this more frequent; a single commit thread and `commitSync()`
276
+ callers could already produce it. With `1`, a database's async commits run one at a time in
277
+ the order `commit()` was called. Databases already open keep their limit.
229
278
  - `compactOnClose: boolean` When `true`, compacts the database on close. Defaults to `false`.
279
+ - `occValidation: 'parallel' | 'serial'` How writable optimistic databases opened after the call
280
+ validate commits for conflicts. Defaults to `'parallel'` (RocksDB's default). Databases already
281
+ open keep their policy. Pessimistic, read-only and secondary opens are unaffected.
282
+ - `'parallel'` locks the commit's keys in a per-database pool of `occLockBuckets` mutexes,
283
+ checks for conflicts, and holds the locks through the write. Commits on different threads
284
+ that touch different buckets can then be batched into one RocksDB write. Non-transactional
285
+ writes (`db.putSync()` and friends) take no bucket, so one that lands between a commit's
286
+ conflict check and its write is overwritten by that commit without an `ERR_BUSY`.
287
+ - `'serial'` checks for conflicts inside RocksDB's write group instead, and allocates no bucket
288
+ pool, which saves its memory. Because the check and the write happen in one step, a
289
+ non-transactional write cannot land between them and be silently overwritten, and a column
290
+ family cannot be dropped between them. The cost: RocksDB never batches a serial-validated
291
+ commit with another writer, including the database's other commit threads, so it gives back
292
+ what `commitThreads` gains. With four worker threads
293
+ committing 64-key transactions to one database it committed about 4,500 transactions per
294
+ second against about 9,900 for `'parallel'`, the same as a single commit thread; it came out
295
+ ahead (about 7%) only at 1,000 keys per transaction with the default bucket count, where
296
+ raising `occLockBuckets` gains more. Concurrent `commitSync()` calls lose throughput the same
297
+ way. It suits a database committed to one transaction at a time (`commitThreads: 1`).
298
+ - `occLockBuckets: number` The number of commit lock buckets each writable optimistic database
299
+ allocates when it is opened with `occValidation: 'parallel'`. Defaults to `65536` (2^16), not
300
+ RocksDB's 2^20. Accepts integers from `16` through `16777216` (2^24). Applies to databases
301
+ opened after the call; databases already open keep their count. Memory is the count times the
302
+ platform mutex size (40 bytes on Linux x64, 64 bytes on macOS), so the default is about 2.5 MiB
303
+ per database on Linux, against about 40 MiB at RocksDB's default. Multiple keys can share a
304
+ bucket, so this is **not a transaction write limit**. A small pool costs throughput only when
305
+ commits to the same database overlap (its `commitThreads`, `commitSync()` callers, or libuv
306
+ commits under `ROCKSDB_JS_COMMIT_THREAD=0`) and their keys share a bucket: colliding commits
307
+ run one at a time and cannot share a RocksDB write. Collisions grow with transaction size. With
308
+ four worker threads and the default four commit threads, 65536 buckets matched 2^20 and 2^22 at
309
+ 64 keys per transaction (and 4096 gave about a third fewer commits per second), but at 1,000 keys
310
+ 65536 committed 336 transactions per second against 466 at 2^20 and 685 at 2^22, so databases
311
+ with large transactions should raise the count.
312
+ - `lifecycleWaitSeconds: number` How long a synchronous open, destroy, or shutdown waits for a
313
+ _conflicting_ lifecycle operation already in progress on the same path (e.g. another open or
314
+ close) before throwing a retryable timeout error. It does not bound the separate, intentionally
315
+ unbounded wait that `destroy()`/`shutdown()` make for in-flight backups, checkpoints, or other
316
+ async work still using the database — see [`db.destroy()`](#dbdestroy-void). Defaults to `30`
317
+ seconds and must be a positive integer.
230
318
  - `verificationTableEntries: number` The number of slots in the process-global
231
319
  [Verification Table](#verification-table). Each slot is 8 bytes, so the default of `131072`
232
320
  (128K) slots is 1 MB. Set to `0` to disable the verification table. This must be configured
@@ -254,7 +342,10 @@ Sets global database settings.
254
342
  ```typescript
255
343
  RocksDatabase.config({
256
344
  blockCacheSize: 100 * 1024 * 1024, // 100MB
345
+ commitThreads: 4, // per database; applies to subsequent opens
257
346
  compactOnClose: true,
347
+ occLockBuckets: 1 << 20, // per optimistic database with parallel validation
348
+ occValidation: 'parallel', // or 'serial'; applies to subsequent optimistic opens
258
349
  writeBufferManagerAllowStall: false,
259
350
  writeBufferManagerCostToCache: false,
260
351
  writeBufferManagerSize: 64 * 1024 * 1024, // 64MB
@@ -574,7 +665,16 @@ db.compactSync({ bottommost: true });
574
665
  ### `db.destroy(): void`
575
666
 
576
667
  Completely removes a database based on the `db` instance's path including all data, column families,
577
- and files on disk.
668
+ and files on disk. Destruction owns the physical path for the process: it closes every writable and
669
+ read-only handle for that path, waits for registered backups and checkpoints to stop using the
670
+ native database, and prevents another handle from reopening the path until removal finishes. Those
671
+ waits are synchronous and can outlive `lifecycleWaitSeconds` once destruction has claimed the path,
672
+ because releasing the native database beneath an active copy would be unsafe.
673
+
674
+ A previously opened instance does not need to remain open, which allows an explicit `destroy()`
675
+ retry after failed physical cleanup. A never-opened or read-only instance cannot destroy the
676
+ database. `shutdown()` reports a pending cleanup tombstone but never retries deletion; only an
677
+ explicit `destroy()` can remove the path.
578
678
 
579
679
  ```typescript
580
680
  db.destroy();
@@ -583,8 +683,8 @@ console.log(fs.existsSync(db.path)); // false
583
683
 
584
684
  ### `db.drop(): Promise<void>`
585
685
 
586
- Removes all entries in the database. If the database was opened with a `name`, the database will be
587
- deleted on close.
686
+ Drops the column family the database was opened with (`name`). For the default column family this
687
+ clears all entries instead.
588
688
 
589
689
  ```typescript
590
690
  const db = RocksDatabase.open('path/to/db', { name: 'users' });
@@ -592,9 +692,56 @@ await db.drop();
592
692
  db.close();
593
693
  ```
594
694
 
695
+ #### Dropping column families
696
+
697
+ A drop retires the column family **logically** before it returns: the name is gone from
698
+ `db.columns`, a later `open()` with the same name creates a fresh, empty column family, and any
699
+ transaction that then stages a write to a handle of the dropped family, or commits one it staged
700
+ earlier, is refused whole with `ERR_COLUMN_FAMILY_DROPPED` (`Column family "users" was dropped`).
701
+ That terminal refusal releases the transaction's verification-table intents and bars further
702
+ writes or commit attempts; retained reads continue until the caller aborts the transaction, and
703
+ they still serve that transaction's own staged writes — values no commit will ever produce. A
704
+ caller that catches the refusal instead of letting it propagate must not read a value back through
705
+ the transaction and carry it forward.
706
+ If a staging call fails while the generation is being retired, retirement takes precedence and the
707
+ transaction is refused whole even when the immediate RocksDB failure was a pessimistic lock timeout.
708
+ Handles other threads still hold keep **reading** the dropped data until they close; a
709
+ non-transactional `putSync`/`removeSync` through such a handle is discarded.
710
+
711
+ The **physical** RocksDB drop is deferred behind commits already admitted when the drop lands: a
712
+ commit claims every column family its batch names before it writes its transaction-log batch and
713
+ releases them after RocksDB has applied it, and the physical drop runs from whichever
714
+ side releases last (or, for a commit a mid-flight `close()` tore out of its pipeline, from the next
715
+ drop, open of that name, or close on the database). With no such commit (the common case) `drop()`/`dropSync()` perform the
716
+ physical drop before returning, exactly as before. This is what keeps a drop racing another
717
+ thread's commit from latching RocksDB's fatal `Invalid column family specified in write batch`
718
+ error on the whole database.
719
+
720
+ Consequences to know about:
721
+
722
+ - A commit admitted before the drop completes successfully into the retiring generation, then the
723
+ physical drop removes that generation. Its caller sees a successful commit, but those writes are
724
+ intentionally discarded with the rest of the dropped column family; a same-name reopen creates
725
+ a fresh, empty generation. If the transaction writes to a transaction log, its entries are still
726
+ published; consumers must order the schema drop after those entries.
727
+ - `open()` of a name whose previous generation is still held by an admitted commit waits for the
728
+ full admission-to-reclamation interval (bounded by `ROCKSDB_JS_CF_RECLAIM_WAIT_MS`, default
729
+ `30000`) before creating the fresh column family; if the previous generation's physical drop
730
+ failed, the open retries it once and throws with that error if it fails again.
731
+ - A physical drop that fails (an I/O error writing the MANIFEST) keeps the name retired, is
732
+ retried on the next drop on the database, the next `open()` of that name, or close, and is
733
+ reported through the global `log.warn` event and the `columnFamily.pendingReclaims` stat. When
734
+ the failing drop was the one `drop()` itself ran, the call rejects with that error as well.
735
+
736
+ What is **not** guaranteed: a process that exits while a physical drop is still pending, or
737
+ after one failed, leaves the column family on disk under its name, and the next open of the
738
+ database opens it as a live column family. A backup or checkpoint taken inside that window copies
739
+ it. Callers that need a drop to survive a crash record their own durable tombstone before
740
+ acknowledging it.
741
+
595
742
  ### `db.dropSync(): void`
596
743
 
597
- Synchronous version of `db.drop()`.
744
+ Synchronous version of `db.drop()`, with the same deferral contract.
598
745
 
599
746
  ```typescript
600
747
  const db = RocksDatabase.open('path/to/db');
@@ -620,9 +767,9 @@ Flushes all in-memory data to disk asynchronously.
620
767
  memtable switch can itself prolong an existing L0 stop-trigger condition rather than clear it.
621
768
  That cost is database-wide, covering every column family on the (process-global,
622
769
  `worker_threads`-shared) database handle, not just the caller's — and it relocates the hang
623
- rather than removing it: a stalled write blocks the database's single commit thread, which
624
- dispatches every `Transaction.commit()` in order, so every commit behind it queues up too,
625
- including ones from callers that never touched flush. This is a different knob from
770
+ rather than removing it: a stalled write blocks each of the database's commit threads that
771
+ reaches it, so every async `Transaction.commit()` behind them queues up too, including ones
772
+ from callers that never touched flush. This is a different knob from
626
773
  `writeBufferManagerAllowStall` (see [`new RocksDatabase()`](#new-rocksdatabasepath-options)
627
774
  options), with nearly opposite polarity: that one governs whether the `WriteBufferManager` may
628
775
  stall writers at all, this one governs whether one manual flush is willing to cause a stall
@@ -796,13 +943,47 @@ const range = db.getKeysCount({ start: 'a', end: 'z' }); // exact number of keys
796
943
  ### `db.getMonotonicTimestamp(): number`
797
944
 
798
945
  Returns the current timestamp as a monotonically increasing timestamp in milliseconds represented as
799
- a decimal number. This process-wide clock also supplies each transaction's initial timestamp.
946
+ a decimal number. This process-wide clock supplies each transaction's initial timestamp, which is
947
+ also the key of the transaction-log batch the transaction is written under.
800
948
 
801
949
  ```typescript
802
950
  const ts = db.getMonotonicTimestamp();
803
951
  console.log(ts); // 1764307857213.739
804
952
  ```
805
953
 
954
+ It is a wall-clock (Unix epoch) value made strictly increasing: on a tie or a backward step of the
955
+ host clock it advances by one floating-point ulp per call until the wall clock catches up. That
956
+ keeps transaction timestamps ordered and durable, but it does not measure elapsed time — after a
957
+ backward step, differences between two calls understate real time. Use
958
+ [`steadyClockNow()`](#steadyclocknow-number) for elapsed durations and deadlines.
959
+
960
+ #### Timestamp floor at open
961
+
962
+ The clock is monotonic within a process, not across restarts: a new process reads the wall clock
963
+ again, so a backward step between runs can reissue a timestamp that is already a batch key in this
964
+ node's transaction log. Opening with
965
+ [`timestampFloorLog`](#new-rocksdatabasepath-options) raises the clock
966
+ above every batch key still durable in the named log before the database handle is returned, so no
967
+ transaction can be constructed below it.
968
+
969
+ Name only a log this process **originates**. A log written under a timestamp adopted from another
970
+ node — a replication receiver calling [`txn.setTimestamp()`](#txnsettimestampts-number-void) — is
971
+ keyed by that node's clock; seeding from it would ratchet this process's clock to the fastest of
972
+ those nodes at every restart, and there is no way for the database to tell the two kinds of log
973
+ apart on its own.
974
+
975
+ The named floor is fail closed: an unreadable segment, incomplete scan, malformed framing, or a key
976
+ more than ten years ahead of the wall clock refuses the opted-in open rather than reissuing a durable
977
+ batch key. The floor is process-wide, so it applies to every database open in the process. The scan
978
+ runs while the process serializes database opens and closes, so increasing its budget can delay
979
+ unrelated opens and closes too.
980
+
981
+ Naming an absent log warns so a fresh local log can be configured before its first write. The first
982
+ use may create that fresh log, but if a nonempty directory appears after the warning it is refused:
983
+ those keys were not part of the seed, and a transaction timestamp is already chosen before
984
+ `txn.useLog()` could scan it. Close every handle for the path and reopen with `timestampFloorLog` to
985
+ scan the restored log before using it.
986
+
806
987
  ### `db.getOldestSnapshotTimestamp(): number`
807
988
 
808
989
  Returns a number representing a unix timestamp of the oldest unreleased snapshot.
@@ -928,8 +1109,13 @@ Returns a new `ArrayBuffer` with two additional methods:
928
1109
  - `cancel()` - Removes the callback; future `notify()` calls do nothing
929
1110
 
930
1111
  Note: If a shared buffer already exists for the given `key`, the returned `ArrayBuffer` will
931
- reference this existing shared buffer. Once all `ArrayBuffer` instances have gone out of scope and
932
- garbage collected, the underlying memory and notify callback will be freed.
1112
+ reference this existing shared buffer. The buffer lives as long as its column family is open (a
1113
+ `drop()` discards it with the column family — except the default column family, which `drop()`
1114
+ only clears, so its buffers survive): it is process-wide state, so a thread dropping or garbage
1115
+ collecting its own view (or exiting) never resets it for the others. Because nothing is ever
1116
+ evicted, key the buffer on a small fixed set of names rather than on unbounded data such as
1117
+ record ids. The notify callback is removed when the `ArrayBuffer` it was registered with is
1118
+ garbage collected, or by `cancel()`.
933
1119
 
934
1120
  ```typescript
935
1121
  const buffer = new Uint8Array(db.getUserSharedBuffer('isDone', new ArrayBuffer(1)));
@@ -1585,9 +1771,7 @@ Set the algorithm per column family with the `compression` option when opening a
1585
1771
  const db = RocksDatabase.open('/path/to/db', { compression: 'zstd' });
1586
1772
 
1587
1773
  // Or an object with an explicit level
1588
- const db2 = RocksDatabase.open('/path/to/db2', {
1589
- compression: { algorithm: 'zstd', level: 3 },
1590
- });
1774
+ const db2 = RocksDatabase.open('/path/to/db2', { compression: { algorithm: 'zstd', level: 3 } });
1591
1775
 
1592
1776
  // Disable compression entirely
1593
1777
  const db3 = RocksDatabase.open('/path/to/db3', { compression: 'none' });
@@ -1604,16 +1788,6 @@ was compiled against, so it varies by build. Always check
1604
1788
  [`supportedCompression`](#supportedcompression) at runtime — opening with an unavailable algorithm
1605
1789
  throws. `'none'` is always available.
1606
1790
 
1607
- **Changing the codec of data already written.** Setting `compression` affects files written from
1608
- that point on. Existing SST and blob files keep the codec they were written with until something
1609
- rewrites them. An ordinary `compact()` can re-encode non-bottommost levels, but RocksDB leaves the
1610
- bottommost level alone unless a compaction filter is installed — and that is where most of the data
1611
- sits, so a plain compaction will not rewrite all existing data. Use
1612
- [`compact({ bottommost: true })`](#dbcompactoptions-promisevoid) to force the rewrite, once per
1613
- column family you want migrated. Note also that omitting `compression` on a column family that
1614
- already exists **inherits** its current codec rather than applying the default — the default
1615
- applies only when the family is created.
1616
-
1617
1791
  **Adopting a codec across a whole database.** Compression is chosen per column family, and RocksDB
1618
1792
  opens every family of a database in one call — so by default the families you did not name keep
1619
1793
  their persisted algorithm. If your first open targets some other family (a catalog, say), the rest
@@ -1643,10 +1817,90 @@ changeable, so an explicit change governs files written afterward while existing
1643
1817
  their original compression until rewritten by compaction. It also applies to blob files (large
1644
1818
  values), whose compression otherwise defaults to none.
1645
1819
 
1646
- Because a column family's compression is fixed while it is open, if the same column family is opened
1647
- a second time in the same process (another `RocksDatabase` on the same path/`name`, including from a
1648
- `worker_thread`) with an **explicitly different** algorithm or level, the second open **throws** — a
1649
- plain reopen (no `compression`) instead inherits the live setting.
1820
+ Because an open-time `compression` option applies only when a column family is first opened in a
1821
+ process, if the same column family is opened a second time in the same process (another
1822
+ `RocksDatabase` on the same path/`name`, including from a `worker_thread`) with an **explicitly
1823
+ different** algorithm or level, the second open **throws** — a plain reopen (no `compression`) instead
1824
+ inherits the live setting. This is a structural limit of the open-time API: `rocksdb::DB::Open()` opens
1825
+ every column family in the database transitively in one call, so by the time a caller can name a
1826
+ newly-discovered column family explicitly, RocksDB has already opened it. Use `db.setCompression()`
1827
+ below to change the algorithm of an already-open column family without a reopen.
1828
+
1829
+ ### `db.setCompression(compression: string | { algorithm: string, level?: number }): void`
1830
+
1831
+ Dynamically changes the compression algorithm (and optional level) for an **already-open** column
1832
+ family, live, with no close/reopen and no conflict with other handles that already have this column
1833
+ family open elsewhere in the process. It accepts the same `compression` shape as the open-time option:
1834
+
1835
+ ```typescript
1836
+ const db = RocksDatabase.open('/path/to/db', { compression: 'none' });
1837
+
1838
+ // ... database is upgraded/reconfigured to prefer zstd ...
1839
+ db.setCompression({ algorithm: 'zstd', level: 19 });
1840
+ // new writes are now compressed with zstd/level 19; db is still open and usable.
1841
+ ```
1842
+
1843
+ This is backed by RocksDB's `DB::SetOptions()`, which the RocksDB headers document as dynamically
1844
+ mutable for exactly the fields this binding needs: `ColumnFamilyOptions::compression` and
1845
+ `AdvancedColumnFamilyOptions::blob_compression_type` are both annotated `// Dynamically changeable
1846
+ through SetOptions() API` (`rocksdb/options.h`, `rocksdb/advanced_options.h`). `setCompression` sets
1847
+ both together (mirroring the open-time option) so blob-stored large values don't stay uncompressed
1848
+ after a live algorithm change. It was chosen over a per-CF options map on the root/default open call
1849
+ (the alternative design considered) because that alternative doesn't fit the motivating use case at
1850
+ all: a caller that doesn't yet know a column family's desired codec until _after_ reading its own
1851
+ catalog — itself a column family opened by that very same call — has no way to supply per-CF options
1852
+ up front. `SetOptions()` sidesteps the problem structurally: it operates on a column family that is
1853
+ already open, after the caller has had a chance to learn what codec it wants.
1854
+
1855
+ **Verified semantics** (RocksDB's own `SetOptions()` doc explicitly limits the guarantee to what's
1856
+ listed as mutable — this was checked directly against the linked RocksDB build, not assumed):
1857
+
1858
+ - **Governs new writes only, going forward.** The very next flush (memtable → SST) and any future
1859
+ compaction output use the new algorithm; SST/blob files already on disk are untouched by the call
1860
+ itself and keep their old compression until they are naturally rewritten by a later compaction.
1861
+ This exactly matches the open-time `compression` option's existing "governs subsequently written
1862
+ files" semantics — `setCompression` is the live-mutation path to the same effective behavior,
1863
+ without the reopen restriction above. An ordinary `db.compact()`/`db.compactSync()` can leave
1864
+ already-bottommost files untouched because RocksDB skips that level by default when no compaction
1865
+ filter is configured. Use `db.compact({ bottommost: true })` or
1866
+ `db.compactSync({ bottommost: true })` to force those files to be rewritten under the new codec.
1867
+ - **The level is a partial update.** The new level is applied via RocksDB's nested-option syntax
1868
+ (`compression_opts={level=N;}`), which — per RocksDB's own documented example for this same API
1869
+ (`db->SetOptions(cfh, {{"block_based_table_factory", "{prepopulate_block_cache=kDisable;}"}})`)
1870
+ — only touches the field(s) named; every other `CompressionOptions` sub-field (window size,
1871
+ strategy, dictionary training, ...) is left at its current live value. Omitting `level` resets it to
1872
+ the algorithm's default (RocksDB's `kDefaultCompressionLevel` sentinel), matching the open-time
1873
+ option's "an explicit algorithm without a level does not inherit a previously-set level" behavior —
1874
+ it is never silently carried over from before the call.
1875
+ - **A persisted no-op request is free.** If the requested algorithm, blob algorithm, and effective
1876
+ level already match the live options, `setCompression` skips the RocksDB call entirely rather than
1877
+ paying for a no-op OPTIONS-file write. If a prior attempt applied the setting in memory but failed to
1878
+ persist it, the same request retries the persistence step instead. This matters because RocksDB's
1879
+ own `SetOptions()` doc calls it "a slow call because a new OPTIONS file is serialized and persisted
1880
+ for each call. Use only infrequently" — a caller that reconciles many column families against a
1881
+ catalog on every boot will find most of them already at the desired codec, and this keeps that sweep
1882
+ cheap.
1883
+ - **A persistence failure can split live and durable state.** RocksDB applies the new options in
1884
+ memory first, then persists an OPTIONS file reflecting them; the returned status covers the persist
1885
+ step alone. On a failure (e.g. the volume is full or read-only), the column family may already be
1886
+ running the new algorithm in memory while the on-disk OPTIONS file still holds the old one —
1887
+ `setCompression` detects this case and says so explicitly in the thrown error's message, since a
1888
+ later cold reopen (which trusts only the OPTIONS file) will silently revert to the old algorithm.
1889
+ - **Rejected on a read-only database.** `setCompression` persists a new OPTIONS file, which a
1890
+ read-only handle (`{ readOnly: true }`) may not do — it throws `ERR_DATABASE_READONLY`, matching
1891
+ every other mutating operation on this binding.
1892
+ - **Read your write.** `db.compression` reflects the change immediately (it reads live via
1893
+ `DB::GetOptions()`), and so does the already-open-column-family conflict check described above: once
1894
+ `setCompression` has changed the live algorithm, a second explicit `RocksDatabase.open()` on that
1895
+ column family is compared against the **new** value, not the one it was originally opened with.
1896
+ - **Overrides another handle's explicit codec without notice.** The open-time conflict check exists
1897
+ so two handles on the same column family can't disagree about compression — but `setCompression`
1898
+ bypasses it by design: it changes the live setting for every handle on that column family, including
1899
+ one that originally opened with an explicit, different algorithm. There is currently no event or
1900
+ other signal to such a handle when this happens.
1901
+ - **Does not change `RocksDatabase.open`'s existing open-time behavior.** `compression` at open time
1902
+ behaves exactly as before for a column family that is not already open in the process; `setCompression`
1903
+ is purely additive for the already-open case.
1650
1904
 
1651
1905
  ### `supportedCompression`
1652
1906
 
@@ -1808,8 +2062,12 @@ intended for read-heavy workloads where records carry a monotonically increasing
1808
2062
  Each record's version is the numeric value stored in the first 8 bytes of its value (interpreted as
1809
2063
  a big-endian float64). The table maps `(database, column family, key)` to a single 8-byte slot that
1810
2064
  holds the last-known version for that key. Because slots are addressed by a hash, distinct keys may
1811
- share a slot; a collision only ever causes a conservative miss (a real read), never a stale value to
1812
- be treated as fresh.
2065
+ share a slot. A slot stores the version combined with a tag derived from its key, so a version that
2066
+ another key cached in a shared slot — even the same version, as keys written in one transaction
2067
+ often carry — answers fresh only if the two keys' version-and-tag combinations coincide; otherwise
2068
+ the collision costs only a conservative miss (a real read). With the default 131,072 slots that
2069
+ coincidence has a probability of 2^-47 per lookup that finds another key's version in its slot, and
2070
+ each doubling of `verificationTableEntries` doubles it.
1813
2071
 
1814
2072
  This first word is the only version the table derives on its own: it is what a read with
1815
2073
  `populateVersion: true` publishes and what a transaction write invalidates against. A producer whose
@@ -1889,13 +2147,18 @@ Transaction log files are read through read-only memory maps. Understanding how
1889
2147
  with system memory helps when interpreting [`log.getStats()`](#loggetstats-transactionlogstats)
1890
2148
  figures and process memory usage:
1891
2149
 
1892
- - **Maps are created lazily and live until the file is closed.** Writing log entries does not map
1893
- anything; a log file is mapped the first time a `log.query()` reads from it. The native layer
1894
- holds each file's map for the life of the file — it is released when the log file is purged or
1895
- the database is closed, not by garbage collection. JS `Buffer` views over the map (including
1896
- `entry.data`) hold an additional reference, so the underlying memory is never unmapped while a
1897
- view is still reachable. `stats.memory.activeMaps` counts the maps currently held by the native
1898
- layer.
2150
+ - **Maps are created lazily; only the current (still-being-written) file's map is held natively.**
2151
+ Writing log entries does not map anything; a log file is mapped the first time a `log.query()`
2152
+ reads from it. The native layer holds the current file's map for as long as it stays current —
2153
+ released when the log file rotates, is purged, or the database is closed. On POSIX, once a file
2154
+ rotates out or is purged, the native layer keeps only a weak reference: the mapping survives
2155
+ solely through JS `Buffer` views over it (including `entry.data`) and is released by garbage
2156
+ collection once none remain, independent of purge or close, and `stats.memory.activeMaps` counts
2157
+ only the current file's strongly-held map — a frozen file's map is weakly held and not counted,
2158
+ even while a JS `Buffer` view keeps it alive. **On Windows this weak-ownership optimization does
2159
+ not apply**: every frozen read re-creates the mapping and re-pins it strongly for the rest of the
2160
+ file's life, so `stats.memory.activeMaps`/`mappedBytes` count every frozen map a process has read,
2161
+ not just the current file's.
1899
2162
  - **Mapped bytes are virtual, not resident.** Creating a map reserves address space only. A page
1900
2163
  consumes physical RAM (RSS) when it is first read (demand paging). Querying a multi-gigabyte log
1901
2164
  can show `memory.mappedBytes` in the gigabytes while actual memory usage barely moves.
@@ -1911,9 +2174,10 @@ figures and process memory usage:
1911
2174
  OS-specific differences:
1912
2175
 
1913
2176
  - **POSIX (Linux and macOS):** The active write file is mapped at the full configured
1914
- `transactionLogMaxSize` (an anonymous reservation with the file's contents overlaid on top), so
1915
- `memory.mappedBytes` over-reports the active file; `memory.overlayBytes` is the file-backed
1916
- portion and is the closer proxy for real consumption.
2177
+ `transactionLogMaxSize` (an anonymous reservation with the file's contents overlaid on top), or
2178
+ at its size when a single transaction pushed it past that target, so `memory.mappedBytes`
2179
+ over-reports the active file; `memory.overlayBytes` is the file-backed portion and is the closer
2180
+ proxy for real consumption.
1917
2181
  - **macOS:** Activity Monitor's "Memory" column reports the physical footprint, which excludes
1918
2182
  clean file-backed pages — mapped log data is essentially invisible there even when resident. Use
1919
2183
  process RSS (e.g. `process.memoryUsage().rss`, `ps`, or `vmmap <pid>`) to observe it.
@@ -2212,6 +2476,51 @@ Options:
2212
2476
  Validating a store that is being actively appended to can spuriously report a torn tail for the
2213
2477
  current log file — the tail of an in-flight append is indistinguishable from a crash artifact.
2214
2478
 
2479
+ ### `steadyClockNow(): number`
2480
+
2481
+ Reads the process-wide steady clock. No database handle is needed.
2482
+
2483
+ ```typescript
2484
+ import { steadyClockNow } from '@harperfast/rocksdb-js';
2485
+
2486
+ const start = steadyClockNow();
2487
+ // ... work, possibly on other worker threads ...
2488
+ const elapsedMs = steadyClockNow() - start;
2489
+ ```
2490
+
2491
+ Contract:
2492
+
2493
+ - **Units / type**: milliseconds with a fractional part, as a `number`. Read from
2494
+ `std::chrono::steady_clock` (`CLOCK_MONOTONIC` on Linux, `CLOCK_MONOTONIC_RAW` on macOS,
2495
+ `QueryPerformanceCounter` on Windows).
2496
+ - **Origin and lifetime**: the origin is unspecified and fixed for the life of the process. Every
2497
+ sample taken in the process is in one domain — the main thread and every `worker_threads` worker,
2498
+ including workers started or restarted at any later time, under Node, Bun and Deno alike. Samples
2499
+ are not meaningful across processes or restarts, and are unrelated to the Unix epoch: never compare
2500
+ them with `Date.now()`, `db.getMonotonicTimestamp()` or transaction timestamps.
2501
+ - **Monotonic, not unique**: a sample taken after another (in real time, on any thread) is `>=` it.
2502
+ Two samples can be equal — in the same clock tick, or when two distinct readings round to the same
2503
+ double — so treat `==` as "not later" and pair the sample with a sequence number if strict ordering
2504
+ is required. On Windows, QPC samples from different threads within ±1 native counter tick also
2505
+ have ambiguous ordering; do not use near-equal clock samples alone to prove causality. No
2506
+ wall-clock ratchet is applied.
2507
+ - **Precision**: the double's spacing grows with distance from the origin: about 2 ns at 100 days,
2508
+ 61 ns at 10 years, 0.49 µs at 100 years. Conversion error is below 0.51 µs per sample through
2509
+ 100 years from the native origin. This is representation precision, not clock accuracy. Rounding is
2510
+ monotone, so distinct readings can only collapse to equality, never invert. The clock's own
2511
+ resolution is platform-defined (the C++ duration period is 1 ns on these platforms; actual clock
2512
+ resolution can be coarser). The native signed 64-bit nanosecond range is about 292 years in either
2513
+ direction from its origin; conversion to `number` does not narrow it.
2514
+ - **Independent of the wall clock**: `settimeofday`/NTP steps in either direction do not move it. NTP
2515
+ frequency slew is platform-defined (`CLOCK_MONOTONIC` is slewed; `CLOCK_MONOTONIC_RAW` and
2516
+ `QueryPerformanceCounter` are not).
2517
+ - **Suspend**: whether time the host spends suspended counts is platform-defined — `CLOCK_MONOTONIC`
2518
+ excludes it on Linux; current macOS `CLOCK_MONOTONIC_RAW` and Windows QPC include it. Do not
2519
+ assume runtime timers (`setTimeout`) use the same suspend policy. Portable suspend-inclusive
2520
+ deadlines are not provided by this API.
2521
+ - **Cost**: one clock read and a double conversion; no lock, no native allocation, no wall-clock read,
2522
+ no database or log I/O, no per-worker calibration. Safe from any thread. The runtime may allocate a boxed JS number.
2523
+
2215
2524
  ### `currentThreadId(): number`
2216
2525
 
2217
2526
  Returns the current thread ID.
@@ -2221,33 +2530,49 @@ import { currentThreadId } from '@harperfast/rocksdb-js';
2221
2530
  console.log(currentThreadId());
2222
2531
  ```
2223
2532
 
2224
- ### `registryStatus(): RegistryStatus`
2533
+ ### `getRegistryStatus(): RegistryStatus`
2225
2534
 
2226
2535
  Returns an array containing that status of all active RocksDB instances.
2227
2536
 
2228
2537
  - `path: string` The database path.
2538
+ - `closeError?: string` The native lifecycle error retaining this registry entry.
2539
+ - `destroyCleanupPending?: boolean` The native database is closed, but physical path cleanup must
2540
+ finish before the next open. Call `destroy()` to retry cleanup.
2229
2541
  - `refCount: number` The number of JavaScript database instances plus the registry's reference.
2230
2542
  - `columnFamiles: object` A map of column family names and their their info.
2231
- - `userSharedBuffers: number` The count of active user shared buffers.
2543
+ - `userSharedBuffers: number` The count of user shared buffer keys created since the column family
2544
+ was opened; entries are never evicted, so this never decreases.
2232
2545
  - `transactions: number` The count of active transactions.
2233
2546
  - `closables: number` The count of active database, transactions, and iterators.
2234
2547
  - `locks: number` The count of active locks.
2235
2548
  - `listenerCallbacks: number` The count of in-flight callbacks.
2236
2549
 
2237
2550
  ```typescript
2238
- import { registryStatus } from '@harperfast/rocksdb-js';
2239
- console.log(registryStatus());
2551
+ import { getRegistryStatus } from '@harperfast/rocksdb-js';
2552
+ console.log(getRegistryStatus());
2240
2553
  ```
2241
2554
 
2242
2555
  ### `shutdown(): void`
2243
2556
 
2244
2557
  The `shutdown()` will flush all in-memory data to disk and wait for any outstanding compactions to
2245
2558
  finish, for all open databases. It is highly recommended to call this in a `process` `exit` event
2246
- listener (on the main thread), to ensure that all data is flushed to disk before the process exits:
2559
+ listener (on the main thread), to ensure that all data is flushed to disk before the process exits.
2560
+ It throws the first close failure after attempting every claimed database; call it again to retry
2561
+ any descriptor whose native teardown did not complete. It reports pending destroy-cleanup
2562
+ tombstones without deleting their paths; retry those with an explicit `destroy()`.
2563
+
2564
+ Wrap the call: an exception thrown from an `exit` listener skips every `exit` listener registered
2565
+ after it, and sets the exit code to 1 unless an `uncaughtException` handler is installed.
2247
2566
 
2248
2567
  ```typescript
2249
2568
  import { shutdown } from '@harperfast/rocksdb-js';
2250
- process.on('exit', shutdown);
2569
+ process.on('exit', () => {
2570
+ try {
2571
+ shutdown();
2572
+ } catch (error) {
2573
+ console.error('rocksdb-js shutdown failed', error);
2574
+ }
2575
+ });
2251
2576
  ```
2252
2577
 
2253
2578
  ### `versions: { 'rocksdb': string; 'rocksdb-js': string }`
@@ -2339,9 +2664,7 @@ live database directory would write backup files on top of RocksDB's own files.
2339
2664
  before anything is written.
2340
2665
 
2341
2666
  ```typescript
2342
- const id = await db.backup('/path/to/backups', {
2343
- metadata: 'nightly-2026-06-04',
2344
- });
2667
+ const id = await db.backup('/path/to/backups', { metadata: 'nightly-2026-06-04' });
2345
2668
  ```
2346
2669
 
2347
2670
  `BackupOptions`:
@@ -2391,9 +2714,7 @@ await db.backup(Writable.toWeb(createWriteStream('/path/to/backup.tar')));
2391
2714
  // Restore: `tar -xf backup.tar -C /restored`, then open '/restored'.
2392
2715
 
2393
2716
  // Or gzip it (`tar -xzf backup.tar.gz` to restore):
2394
- await db.backup(Writable.toWeb(createWriteStream('/path/to/backup.tar.gz')), {
2395
- gzip: true,
2396
- });
2717
+ await db.backup(Writable.toWeb(createWriteStream('/path/to/backup.tar.gz')), { gzip: true });
2397
2718
  ```
2398
2719
 
2399
2720
  `BackupStreamOptions`:
@@ -2671,7 +2992,7 @@ Available commands:
2671
2992
 
2672
2993
  ## Development
2673
2994
 
2674
- This package requires Node.js 18 or higher, pnpm, and a C++ compiler.
2995
+ This package requires Node.js `^22.18.0 || >=24.0.0`, pnpm, and a C++ compiler.
2675
2996
 
2676
2997
  > [!TIP]
2677
2998
  > Enable pnpm log streaming to see full build output: