@harperfast/rocksdb-js 2.10.0 → 2.12.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
@@ -218,12 +233,21 @@ when a non-default compression level is set. The database must be open. See
218
233
  [Compression](#compression).
219
234
 
220
235
  ```typescript
221
- const db = RocksDatabase.open('path/to/db', {
222
- compression: { algorithm: 'zstd', level: 3 },
223
- });
236
+ const db = RocksDatabase.open('path/to/db', { compression: { algorithm: 'zstd', level: 3 } });
224
237
  console.log(db.compression); // { algorithm: 'zstd', level: 3 }
225
238
  ```
226
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
+
227
251
  ### `db.config(options)`
228
252
 
229
253
  Sets global database settings.
@@ -232,7 +256,59 @@ Sets global database settings.
232
256
  - `blockCacheSize: number` The amount of memory in bytes to use to cache uncompressed blocks.
233
257
  Defaults to 32MB. Set to `0` (zero) disables block cache for future opened databases. Existing
234
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.
235
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.
236
312
  - `lifecycleWaitSeconds: number` How long a synchronous open, destroy, or shutdown waits for a
237
313
  _conflicting_ lifecycle operation already in progress on the same path (e.g. another open or
238
314
  close) before throwing a retryable timeout error. It does not bound the separate, intentionally
@@ -266,7 +342,10 @@ Sets global database settings.
266
342
  ```typescript
267
343
  RocksDatabase.config({
268
344
  blockCacheSize: 100 * 1024 * 1024, // 100MB
345
+ commitThreads: 4, // per database; applies to subsequent opens
269
346
  compactOnClose: true,
347
+ occLockBuckets: 1 << 20, // per optimistic database with parallel validation
348
+ occValidation: 'parallel', // or 'serial'; applies to subsequent optimistic opens
270
349
  writeBufferManagerAllowStall: false,
271
350
  writeBufferManagerCostToCache: false,
272
351
  writeBufferManagerSize: 64 * 1024 * 1024, // 64MB
@@ -688,9 +767,9 @@ Flushes all in-memory data to disk asynchronously.
688
767
  memtable switch can itself prolong an existing L0 stop-trigger condition rather than clear it.
689
768
  That cost is database-wide, covering every column family on the (process-global,
690
769
  `worker_threads`-shared) database handle, not just the caller's — and it relocates the hang
691
- rather than removing it: a stalled write blocks the database's single commit thread, which
692
- dispatches every `Transaction.commit()` in order, so every commit behind it queues up too,
693
- 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
694
773
  `writeBufferManagerAllowStall` (see [`new RocksDatabase()`](#new-rocksdatabasepath-options)
695
774
  options), with nearly opposite polarity: that one governs whether the `WriteBufferManager` may
696
775
  stall writers at all, this one governs whether one manual flush is willing to cause a stall
@@ -864,7 +943,8 @@ const range = db.getKeysCount({ start: 'a', end: 'z' }); // exact number of keys
864
943
  ### `db.getMonotonicTimestamp(): number`
865
944
 
866
945
  Returns the current timestamp as a monotonically increasing timestamp in milliseconds represented as
867
- 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.
868
948
 
869
949
  ```typescript
870
950
  const ts = db.getMonotonicTimestamp();
@@ -877,6 +957,33 @@ keeps transaction timestamps ordered and durable, but it does not measure elapse
877
957
  backward step, differences between two calls understate real time. Use
878
958
  [`steadyClockNow()`](#steadyclocknow-number) for elapsed durations and deadlines.
879
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
+
880
987
  ### `db.getOldestSnapshotTimestamp(): number`
881
988
 
882
989
  Returns a number representing a unix timestamp of the oldest unreleased snapshot.
@@ -1664,9 +1771,7 @@ Set the algorithm per column family with the `compression` option when opening a
1664
1771
  const db = RocksDatabase.open('/path/to/db', { compression: 'zstd' });
1665
1772
 
1666
1773
  // Or an object with an explicit level
1667
- const db2 = RocksDatabase.open('/path/to/db2', {
1668
- compression: { algorithm: 'zstd', level: 3 },
1669
- });
1774
+ const db2 = RocksDatabase.open('/path/to/db2', { compression: { algorithm: 'zstd', level: 3 } });
1670
1775
 
1671
1776
  // Disable compression entirely
1672
1777
  const db3 = RocksDatabase.open('/path/to/db3', { compression: 'none' });
@@ -1683,16 +1788,6 @@ was compiled against, so it varies by build. Always check
1683
1788
  [`supportedCompression`](#supportedcompression) at runtime — opening with an unavailable algorithm
1684
1789
  throws. `'none'` is always available.
1685
1790
 
1686
- **Changing the codec of data already written.** Setting `compression` affects files written from
1687
- that point on. Existing SST and blob files keep the codec they were written with until something
1688
- rewrites them. An ordinary `compact()` can re-encode non-bottommost levels, but RocksDB leaves the
1689
- bottommost level alone unless a compaction filter is installed — and that is where most of the data
1690
- sits, so a plain compaction will not rewrite all existing data. Use
1691
- [`compact({ bottommost: true })`](#dbcompactoptions-promisevoid) to force the rewrite, once per
1692
- column family you want migrated. Note also that omitting `compression` on a column family that
1693
- already exists **inherits** its current codec rather than applying the default — the default
1694
- applies only when the family is created.
1695
-
1696
1791
  **Adopting a codec across a whole database.** Compression is chosen per column family, and RocksDB
1697
1792
  opens every family of a database in one call — so by default the families you did not name keep
1698
1793
  their persisted algorithm. If your first open targets some other family (a catalog, say), the rest
@@ -1722,10 +1817,90 @@ changeable, so an explicit change governs files written afterward while existing
1722
1817
  their original compression until rewritten by compaction. It also applies to blob files (large
1723
1818
  values), whose compression otherwise defaults to none.
1724
1819
 
1725
- Because a column family's compression is fixed while it is open, if the same column family is opened
1726
- a second time in the same process (another `RocksDatabase` on the same path/`name`, including from a
1727
- `worker_thread`) with an **explicitly different** algorithm or level, the second open **throws** — a
1728
- 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.
1729
1904
 
1730
1905
  ### `supportedCompression`
1731
1906
 
@@ -1887,8 +2062,12 @@ intended for read-heavy workloads where records carry a monotonically increasing
1887
2062
  Each record's version is the numeric value stored in the first 8 bytes of its value (interpreted as
1888
2063
  a big-endian float64). The table maps `(database, column family, key)` to a single 8-byte slot that
1889
2064
  holds the last-known version for that key. Because slots are addressed by a hash, distinct keys may
1890
- share a slot; a collision only ever causes a conservative miss (a real read), never a stale value to
1891
- 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.
1892
2071
 
1893
2072
  This first word is the only version the table derives on its own: it is what a read with
1894
2073
  `populateVersion: true` publishes and what a transaction write invalidates against. A producer whose
@@ -1968,13 +2147,18 @@ Transaction log files are read through read-only memory maps. Understanding how
1968
2147
  with system memory helps when interpreting [`log.getStats()`](#loggetstats-transactionlogstats)
1969
2148
  figures and process memory usage:
1970
2149
 
1971
- - **Maps are created lazily and live until the file is closed.** Writing log entries does not map
1972
- anything; a log file is mapped the first time a `log.query()` reads from it. The native layer
1973
- holds each file's map for the life of the file — it is released when the log file is purged or
1974
- the database is closed, not by garbage collection. JS `Buffer` views over the map (including
1975
- `entry.data`) hold an additional reference, so the underlying memory is never unmapped while a
1976
- view is still reachable. `stats.memory.activeMaps` counts the maps currently held by the native
1977
- 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.
1978
2162
  - **Mapped bytes are virtual, not resident.** Creating a map reserves address space only. A page
1979
2163
  consumes physical RAM (RSS) when it is first read (demand paging). Querying a multi-gigabyte log
1980
2164
  can show `memory.mappedBytes` in the gigabytes while actual memory usage barely moves.
@@ -1990,9 +2174,10 @@ figures and process memory usage:
1990
2174
  OS-specific differences:
1991
2175
 
1992
2176
  - **POSIX (Linux and macOS):** The active write file is mapped at the full configured
1993
- `transactionLogMaxSize` (an anonymous reservation with the file's contents overlaid on top), so
1994
- `memory.mappedBytes` over-reports the active file; `memory.overlayBytes` is the file-backed
1995
- 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.
1996
2181
  - **macOS:** Activity Monitor's "Memory" column reports the physical footprint, which excludes
1997
2182
  clean file-backed pages — mapped log data is essentially invisible there even when resident. Use
1998
2183
  process RSS (e.g. `process.memoryUsage().rss`, `ps`, or `vmmap <pid>`) to observe it.
@@ -2014,20 +2199,21 @@ const names = db.listLogs();
2014
2199
 
2015
2200
  ### `db.purgeLogs({ includeEntryCounts: true, ...options }): { path: string; entries: number }[]`
2016
2201
 
2017
- Deletes transaction log files older than the `transactionLogRetention` (defaults to 3 days).
2018
- Ordinary retention keeps the sequence file named by `txn.state` and every newer file as the live
2019
- store's retention floor, or the highest sequence file when there is no persisted flush position. It
2020
- removes only an eligible contiguous prefix below that floor. An idle store can therefore retain one
2021
- file past the cutoff until a later write rotates and flushes it; that extra file is bounded by the
2022
- store's `transactionLogMaxSize` (except when a single transaction exceeds the target). That bound
2023
- covers only the floor file: when the flush position lags — `txn.state` stuck at an old sequence
2024
- because RocksDB flushing is behind — every file above the floor is retained too, which
2025
- `transactionLogMaxSize` does not bound. `purge.retainedUnflushedFiles` reports that case. Use
2026
- `destroy: true` only to remove the store itself.
2202
+ Deletes transaction log files older than the `transactionLogRetention` (defaults to 3 days). A file
2203
+ is deleted only when it lies entirely at or before the flushed position recorded in `txn.state`, so
2204
+ every entry in it is already in RocksDB; that includes the file `txn.state` names and the file
2205
+ currently being written, so an idle, fully flushed store is left with no log files. Retention removes
2206
+ a contiguous prefix: the first file that is too new or holds an unflushed entry stops the run. When
2207
+ the flush position lags — `txn.state` stuck at an old sequence because RocksDB flushing is behind —
2208
+ every file after it is retained regardless of age; `purge.retainedUnflushedFiles` reports that case.
2209
+ While a backup with `transactionLogs` is copying a store's files, purges of that store are skipped
2210
+ (`purge.retentionPins` reports it). On a filesystem that rejects a directory fsync, the store's last
2211
+ file is kept, since `txn.state` could not be made durable before deleting it.
2212
+ Use `destroy: true` only to remove the store itself.
2027
2213
 
2028
2214
  - `options: object`
2029
2215
  - `before?: number` Remove transaction log files older than the specified timestamp, subject to
2030
- the retention floor and contiguous-prefix rules above.
2216
+ the flushed-position and contiguous-prefix rules above.
2031
2217
  - `destroy?: boolean` When `true`, deletes transaction log stores including all log sequence files
2032
2218
  on disk.
2033
2219
  - `includeEntryCounts?: boolean` When `true`, counts the entries in each deleted log file and
@@ -2201,9 +2387,8 @@ stats.totals.transactionsWritten; // lifetime count of transactions written
2201
2387
 
2202
2388
  The `purge.retainedUnflushedFiles` gauge is useful for diagnosing why logs are not being cleaned
2203
2389
  up: a file can be older than the retention period but still retained because its transactions have
2204
- not yet been flushed to RocksDB (purging it would be unsafe for crash recovery). The retention
2205
- floor — the sequence named by `txn.state`, or the highest sequence when there is no persisted flush
2206
- position — is not counted as purgeable even when it is old and fully flushed.
2390
+ not yet been flushed to RocksDB (purging it would be unsafe for crash recovery). An old, fully
2391
+ flushed current file counts as purgeable.
2207
2392
 
2208
2393
  ### Transaction Log Initialization
2209
2394
 
@@ -2345,7 +2530,7 @@ import { currentThreadId } from '@harperfast/rocksdb-js';
2345
2530
  console.log(currentThreadId());
2346
2531
  ```
2347
2532
 
2348
- ### `registryStatus(): RegistryStatus`
2533
+ ### `getRegistryStatus(): RegistryStatus`
2349
2534
 
2350
2535
  Returns an array containing that status of all active RocksDB instances.
2351
2536
 
@@ -2363,8 +2548,8 @@ Returns an array containing that status of all active RocksDB instances.
2363
2548
  - `listenerCallbacks: number` The count of in-flight callbacks.
2364
2549
 
2365
2550
  ```typescript
2366
- import { registryStatus } from '@harperfast/rocksdb-js';
2367
- console.log(registryStatus());
2551
+ import { getRegistryStatus } from '@harperfast/rocksdb-js';
2552
+ console.log(getRegistryStatus());
2368
2553
  ```
2369
2554
 
2370
2555
  ### `shutdown(): void`
@@ -2479,9 +2664,7 @@ live database directory would write backup files on top of RocksDB's own files.
2479
2664
  before anything is written.
2480
2665
 
2481
2666
  ```typescript
2482
- const id = await db.backup('/path/to/backups', {
2483
- metadata: 'nightly-2026-06-04',
2484
- });
2667
+ const id = await db.backup('/path/to/backups', { metadata: 'nightly-2026-06-04' });
2485
2668
  ```
2486
2669
 
2487
2670
  `BackupOptions`:
@@ -2531,9 +2714,7 @@ await db.backup(Writable.toWeb(createWriteStream('/path/to/backup.tar')));
2531
2714
  // Restore: `tar -xf backup.tar -C /restored`, then open '/restored'.
2532
2715
 
2533
2716
  // Or gzip it (`tar -xzf backup.tar.gz` to restore):
2534
- await db.backup(Writable.toWeb(createWriteStream('/path/to/backup.tar.gz')), {
2535
- gzip: true,
2536
- });
2717
+ await db.backup(Writable.toWeb(createWriteStream('/path/to/backup.tar.gz')), { gzip: true });
2537
2718
  ```
2538
2719
 
2539
2720
  `BackupStreamOptions`:
@@ -2608,7 +2789,7 @@ backup references them, so this is not equivalent to deleting files manually.
2608
2789
 
2609
2790
  Deletes all but the newest `keepCount` backups.
2610
2791
 
2611
- ### `backups.verify(backupDir: string, backupId: number, options?: { verifyWithChecksum?: boolean; verifyTransactionLogs?: boolean }): Promise<void>`
2792
+ ### `backups.verify(backupDir: string, backupId: number, options?: { verifyWithChecksum?: boolean; verifyTables?: boolean; verifyTransactionLogs?: boolean }): Promise<void>`
2612
2793
 
2613
2794
  Verifies a backup's file sizes, and optionally their checksums (which requires reading all
2614
2795
  backed-up data). Resolves if the backup is intact and rejects otherwise.
@@ -2619,8 +2800,17 @@ When the backup was created with `transactionLogs: true`, the backup's transacti
2619
2800
  log file's header and entry framing must be intact (snapshots are copied on committed entry
2620
2801
  boundaries, so even a torn tail fails verification). Set `verifyTransactionLogs: false` to skip.
2621
2802
 
2803
+ Set `verifyTables: true` (default `false`) to also prove this build can read the backup's table
2804
+ files before you restore it. Each SST is opened in turn, one at a time, so file-descriptor use does
2805
+ not grow with the number of tables. Verification rejects, naming the file, when this build cannot
2806
+ decode a table's footer (for example a `format_version` written by a newer RocksDB) or its
2807
+ properties, or when the table records a compression algorithm this build does not support (see
2808
+ [`supportedCompression`](#supportedcompression)). Checksums cannot catch either case: the file is
2809
+ intact, just unreadable here, and RocksDB would otherwise fail on the first read after a restore.
2810
+ Blob files are not checked.
2811
+
2622
2812
  ```typescript
2623
- await backups.verify('/path/to/backups', 1, { verifyWithChecksum: true });
2813
+ await backups.verify('/path/to/backups', 1, { verifyWithChecksum: true, verifyTables: true });
2624
2814
  ```
2625
2815
 
2626
2816
  ## Custom Store
@@ -2811,7 +3001,7 @@ Available commands:
2811
3001
 
2812
3002
  ## Development
2813
3003
 
2814
- This package requires Node.js 18 or higher, pnpm, and a C++ compiler.
3004
+ This package requires Node.js `^22.18.0 || >=24.0.0`, pnpm, and a C++ compiler.
2815
3005
 
2816
3006
  > [!TIP]
2817
3007
  > Enable pnpm log streaming to see full build output:
@@ -160,13 +160,32 @@ function createRL() {
160
160
  const iface = createInterface({ input: stdin, output: stdout, completer });
161
161
  iface.on('SIGINT', onSIGINT);
162
162
  iface.on('SIGBREAK', () => process.exit(0));
163
+ iface.on('close', () => {
164
+ rlClosed = true;
165
+ });
166
+ iface.on('error', (err) => {
167
+ console.error(bad(`stdin error: ${err.message ?? err}`));
168
+ process.exit(1);
169
+ });
163
170
  return iface;
164
171
  }
165
172
 
166
173
  let rl = createRL();
174
+ let lineIterator = rl[Symbol.asyncIterator]();
175
+ let rlClosed = false;
167
176
  let ctrlC = false;
168
177
  let currentAbortController = null;
169
178
 
179
+ function whenAborted(signal) {
180
+ return new Promise((_resolve, reject) => {
181
+ signal.addEventListener(
182
+ 'abort',
183
+ () => reject(Object.assign(new Error('The operation was aborted'), { code: 'ABORT_ERR' })),
184
+ { once: true }
185
+ );
186
+ });
187
+ }
188
+
170
189
  function str(text) {
171
190
  try {
172
191
  if (typeof text === 'symbol') {
@@ -240,17 +259,32 @@ async function run(fn) {
240
259
  return { time: end - start, result };
241
260
  }
242
261
 
262
+ // Returns the next line, or `null` once the iterator itself reports `done`. `close` can
263
+ // fire before lineIterator's already-buffered lines are drained, so `rlClosed` never
264
+ // gates reading — only resume()/setPrompt()/prompt()/pause(), which throw
265
+ // ERR_USE_AFTER_CLOSE once closed.
243
266
  async function ask(prompt) {
244
- rl.resume();
267
+ if (!rlClosed) {
268
+ rl.resume();
269
+ rl.setPrompt(prompt);
270
+ rl.prompt();
271
+ }
272
+ // A second next() would queue behind this one and steal the next real line out from
273
+ // under a Ctrl-C retry below, so retries re-await this same promise instead.
274
+ const pending = lineIterator.next();
245
275
  while (true) {
246
276
  try {
247
277
  currentAbortController = new AbortController();
248
- const answer = await rl.question(prompt, { signal: currentAbortController.signal });
249
- rl.pause();
278
+ const result = await Promise.race([pending, whenAborted(currentAbortController.signal)]);
250
279
  ctrlC = false;
251
- return answer;
280
+ if (result.done) return null;
281
+ if (!rlClosed) rl.pause();
282
+ return result.value;
252
283
  } catch (err) {
253
284
  if (err.code !== 'ABORT_ERR') throw err;
285
+ if (rlClosed) continue;
286
+ rl.clearLine();
287
+ rl.prompt();
254
288
  }
255
289
  }
256
290
  }
@@ -842,6 +876,12 @@ async function removeCommand(args) {
842
876
  }
843
877
 
844
878
  async function replCommand() {
879
+ // A non-TTY stdin can't feed the sub-REPL interactively, and input already read out
880
+ // of it has no defined recipient once this CLI's interface closes.
881
+ if (!stdin.isTTY) {
882
+ console.log(bad('The JS sub-REPL needs an interactive terminal; stdin is not one here.\n'));
883
+ process.exit(1);
884
+ }
845
885
  const history = [...rl.history];
846
886
  rl.close();
847
887
  r = repl.start({ prompt: styleText('magenta', 'js> '), useColors: true, useGlobal: false });
@@ -852,6 +892,8 @@ async function replCommand() {
852
892
  console.log();
853
893
  rl = createRL();
854
894
  rl.history = history;
895
+ lineIterator = rl[Symbol.asyncIterator]();
896
+ rlClosed = false;
855
897
  }
856
898
 
857
899
  function statsCommand() {
@@ -942,7 +984,9 @@ async function main() {
942
984
  console.log(`Type "help" for more information.`);
943
985
 
944
986
  while (true) {
945
- const line = (await ask('> ')).trim().split(/[ \t]+/);
987
+ const raw = await ask('> ');
988
+ if (raw === null) break;
989
+ const line = raw.trim().split(/[ \t]+/);
946
990
  const command = line[0];
947
991
  if (!command) continue;
948
992
  const commandFn = COMMANDS[command];