@harperfast/rocksdb-js 1.2.0 → 1.4.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
@@ -106,11 +106,26 @@ Sets global database settings.
106
106
  Defaults to 32MB. Set to `0` (zero) disables block cache for future opened databases. Existing
107
107
  block cache for any opened databases is resized immediately. Negative values throw an error.
108
108
  - `compactOnClose: boolean` When `true`, compacts the database on close. Defaults to `false`.
109
+ - `writeBufferManagerAllowStall: boolean` When `true`, writes are stalled once the manager's
110
+ `buffer_size` is exceeded, providing a hard cap on memtable memory. When `false`, memtables are
111
+ allowed to grow past the limit and flushes are simply scheduled more aggressively. Off by
112
+ default to favor write throughput over hard memory bounding. Defaults to `false`.
113
+ - `writeBufferManagerCostToCache: boolean` When `true`, memtable memory is "charged" against the
114
+ shared block cache so the block cache and write buffers draw from a single pool. During write
115
+ bursts the cache shrinks to make room for memtables; once memtables flush, the cache can grow
116
+ back into the reclaimed space. Defaults to `false`.
117
+ - `writeBufferManagerSize: number` Total memtable memory limit (bytes) shared across every
118
+ database opened in this process. When set, RocksDB uses a single `WriteBufferManager` so write
119
+ buffers are bounded process-wide rather than per database. A value of `0` disables the manager.
120
+ Defaults to `0`.
109
121
 
110
122
  ```typescript
111
123
  RocksDatabase.config({
112
124
  blockCacheSize: 100 * 1024 * 1024, // 100MB
113
125
  compactOnClose: true,
126
+ writeBufferManagerAllowStall: false,
127
+ writeBufferManagerCostToCache: false,
128
+ writeBufferManagerSize: 64 * 1024 * 1024, // 64MB
114
129
  });
115
130
  ```
116
131
 
@@ -1421,6 +1436,24 @@ To run the tests without code coverage, run:
1421
1436
  pnpm test
1422
1437
  ```
1423
1438
 
1439
+ #### Native C++ unit tests (GoogleTest)
1440
+
1441
+ Build and run standalone native tests (RocksDB + binding `core/` helpers, no Node in the test
1442
+ binary):
1443
+
1444
+ ```bash
1445
+ pnpm test:native
1446
+ ```
1447
+
1448
+ Coverage report for native tests (macOS/Linux; requires `lcov`):
1449
+
1450
+ ```bash
1451
+ pnpm coverage:native
1452
+ # open coverage/native/html/index.html
1453
+ ```
1454
+
1455
+ Note: Code coverage is not supported on Windows. Tests are run without coverage.
1456
+
1424
1457
  To run a specific test suite, for example `"ranges"`, run:
1425
1458
 
1426
1459
  ```bash
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { RocksDatabase, parseTransactionLog, versions } from '../dist/index.mjs';
4
4
  import { existsSync, readdirSync } from 'node:fs';
5
- import { mkdir, readdir } from 'node:fs/promises';
5
+ import { mkdir, readdir, stat } from 'node:fs/promises';
6
6
  import { join, resolve } from 'node:path';
7
7
  import { stdin, stdout } from 'node:process';
8
8
  import { createInterface } from 'node:readline/promises';
@@ -19,6 +19,7 @@ const COMMANDS = {
19
19
  get: getCommand,
20
20
  help: helpCommand,
21
21
  log: logCommand,
22
+ logs: logCommand, // alias for log
22
23
  prop: propCommand,
23
24
  'purge-logs': purgeLogsCommand,
24
25
  put: putCommand,
@@ -66,12 +67,12 @@ function completer(line) {
66
67
  return [hits.length ? hits : columns, arg];
67
68
  }
68
69
 
69
- if (command === 'log' && currentDB) {
70
+ if ((command === 'log' || command === 'logs') && currentDB) {
70
71
  if (parts.length === 2) {
71
72
  const arg = parts[1];
72
73
  const logs = currentDB.listLogs();
73
74
  const hits = logs.filter((l) => l.startsWith(arg));
74
- return [hits.length ? hits : logs, arg];
75
+ return [(hits.length ? hits : logs).map((s) => `${s} `), arg];
75
76
  } else if (parts.length === 3) {
76
77
  const [name, store] = parts.slice(1);
77
78
  const logs = currentDB.listLogs();
@@ -81,7 +82,7 @@ function completer(line) {
81
82
  const logFiles = readdirSync(logPath).filter(
82
83
  (f) => (!store || f.startsWith(store)) && f.endsWith('.txnlog')
83
84
  );
84
- return [logFiles.length ? logFiles : [], store, name];
85
+ return [logFiles.length ? logFiles.map((f) => `${f} `) : [], store, name];
85
86
  }
86
87
  return [[], store, name];
87
88
  }
@@ -149,6 +150,34 @@ function wrapColumns(columns) {
149
150
  return prefix + lines.join('\n' + indent);
150
151
  }
151
152
 
153
+ function formatHexDump(data) {
154
+ const bytesPerRow = 16;
155
+ const lines = [];
156
+ for (let offset = 0; offset < data.length; offset += bytesPerRow) {
157
+ const chunk = data.subarray(offset, offset + bytesPerRow);
158
+ const hexParts = [];
159
+ for (let i = 0; i < bytesPerRow; i++) {
160
+ if (i === 8) hexParts.push('');
161
+ hexParts.push(i < chunk.length ? chunk[i].toString(16).padStart(2, '0') : ' ');
162
+ }
163
+ let ascii = '';
164
+ for (const byte of chunk) {
165
+ ascii += byte >= 0x20 && byte <= 0x7e ? String.fromCharCode(byte) : '.';
166
+ }
167
+ lines.push(
168
+ `${note(offset.toString(16).padStart(8, '0'))} ${hl(hexParts.join(' '))} ${note('|')}${hl(ascii)}${note('|')}`
169
+ );
170
+ }
171
+ return lines.join('\n');
172
+ }
173
+
174
+ function formatBytes(bytes) {
175
+ if (bytes < 1024) return `${bytes} B`;
176
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
177
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
178
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
179
+ }
180
+
152
181
  async function run(fn) {
153
182
  const start = Date.now();
154
183
  const result = await fn();
@@ -320,40 +349,72 @@ async function logCommand(args) {
320
349
  }
321
350
  const { data } = log.entries[entry - 1];
322
351
  console.log(`Entry ${entry} (${data.length.toLocaleString()} bytes)\n`);
323
- console.log(
324
- hl(
325
- data
326
- .toString('hex')
327
- .match(/.{1,2}/g)
328
- .join(' ')
329
- )
330
- );
352
+ console.log(formatHexDump(data));
331
353
  console.log();
332
354
  return;
333
355
  }
334
356
 
335
- console.log(`Size = ${hl(`${log.size.toLocaleString()} bytes`)}`);
336
- console.log(`Version = ${hl(log.version)}`);
337
- console.log(`Timestamp = ${hl(new Date(log.timestamp).toISOString())}`);
338
- console.log(`Entries = ${hl(log.entries.length)}`);
357
+ for (const anomaly of log.anomalies) {
358
+ console.log(bad(`! ${anomaly}`));
359
+ }
339
360
  let i = 1;
340
361
  for (const entry of log.entries) {
362
+ const ts =
363
+ Number.isFinite(entry.timestamp) && entry.timestamp > 0
364
+ ? new Date(entry.timestamp).toISOString()
365
+ : String(entry.timestamp);
341
366
  console.log(
342
- `${String(i++).padStart(3)}) ${hl(new Date(entry.timestamp).toISOString())} ${entry.length.toLocaleString()} bytes ${entry.flags & 0x01 ? '' : note('(continued in next file)')}`
367
+ `${String(i++).padStart(3)}) ${hl(ts)} ${entry.length.toLocaleString()} bytes ${entry.flags & 0x01 ? note('(end of transaction)') : ''}`
343
368
  );
369
+ if (entry.anomalies) {
370
+ for (const anomaly of entry.anomalies) {
371
+ console.log(` ${bad(`! ${anomaly}`)}`);
372
+ }
373
+ }
344
374
  }
345
375
  console.log();
346
376
  return;
347
377
  }
378
+
348
379
  const logPath = join(currentDB.path, 'transaction_logs', name);
349
380
  const logFiles = await readdir(logPath);
350
381
  for (const logFile of logFiles) {
351
382
  if (!logFile.endsWith('.txnlog')) continue;
352
383
  console.log(hl(logFile));
384
+
385
+ const log = parseTransactionLog(join(logPath, logFile), { skipData: true });
386
+ const ts =
387
+ Number.isFinite(log.timestamp) && log.timestamp > 0
388
+ ? new Date(log.timestamp).toISOString()
389
+ : String(log.timestamp);
390
+ console.log(` Size = ${hl(`${log.size.toLocaleString()} bytes`)}`);
391
+ console.log(` Version = ${hl(log.version)}`);
392
+ console.log(` Timestamp = ${hl(ts)}`);
393
+ console.log(` Entries = ${hl(log.entries.length)}`);
394
+ const totalAnomalies = log.anomalies.length + log.entryAnomalyCount;
395
+ if (totalAnomalies > 0) {
396
+ console.log(` Anomalies = ${bad(totalAnomalies)}`);
397
+ for (const anomaly of log.anomalies) {
398
+ console.log(` ${bad(`! ${anomaly}`)}`);
399
+ }
400
+ if (log.entryAnomalyCount > 0) {
401
+ console.log(
402
+ ` ${bad(`! ${log.entryAnomalyCount} entr${log.entryAnomalyCount === 1 ? 'y has' : 'ies have'} anomalies (run "log ${name} ${logFile}" for details)`)}`
403
+ );
404
+ }
405
+ }
353
406
  }
354
407
  } else if (logs.length > 0) {
355
- for (const log of logs) {
356
- console.log(hl(log));
408
+ for (const name of logs) {
409
+ const files = (await readdir(join(currentDB.path, 'transaction_logs', name))).filter((f) =>
410
+ f.endsWith('.txnlog')
411
+ );
412
+ const maxlen = files.reduce((max, file) => Math.max(max, file.length), 0);
413
+ console.log(hl(name));
414
+ for (const file of files) {
415
+ const { size } = await stat(join(currentDB.path, 'transaction_logs', name, file));
416
+ console.log(` ${file.padEnd(maxlen)} ${note(`(${formatBytes(size)})`)}`);
417
+ }
357
418
  }
358
419
  } else {
359
420
  console.log('No log stores found');
@@ -533,6 +594,7 @@ async function main() {
533
594
  const command = line[0];
534
595
  if (!command) continue;
535
596
  const commandFn = COMMANDS[command];
597
+ console.log();
536
598
  if (commandFn) {
537
599
  try {
538
600
  await commandFn(line.slice(1));
package/dist/index.cjs CHANGED
@@ -658,6 +658,24 @@ var Store = class {
658
658
  */
659
659
  maxKeySize;
660
660
  /**
661
+ * The maximum number of memtables that can be queued per column family
662
+ * before writes stall. Higher values absorb write bursts while flushes catch
663
+ * up, at the cost of memory.
664
+ */
665
+ maxWriteBufferNumber;
666
+ /**
667
+ * The bytes of recent memtable history to retain in memory for transaction
668
+ * conflict checking. `-1` derives the value from
669
+ * `maxWriteBufferNumber * writeBufferSize`.
670
+ */
671
+ maxWriteBufferSizeToMaintain;
672
+ /**
673
+ * The total memtable budget in bytes across all column families. When the
674
+ * sum of memtables reaches this size, RocksDB flushes the largest one. `0`
675
+ * disables the global trigger so per-CF `writeBufferSize` drives flushing.
676
+ */
677
+ dbWriteBufferSize;
678
+ /**
661
679
  * The name of the store (e.g. the column family). Defaults to `'default'`.
662
680
  */
663
681
  name;
@@ -667,7 +685,8 @@ var Store = class {
667
685
  noBlockCache;
668
686
  /**
669
687
  * The number of threads to use for parallel operations. This is a RocksDB
670
- * option.
688
+ * option. When undefined, the native layer picks
689
+ * `max(1, hardware_concurrency() / 2)`.
671
690
  */
672
691
  parallelismThreads;
673
692
  /**
@@ -725,6 +744,11 @@ var Store = class {
725
744
  */
726
745
  transactionLogsPath;
727
746
  /**
747
+ * The per-column-family memtable size in bytes at which the memtable is
748
+ * sealed and flushed.
749
+ */
750
+ writeBufferSize;
751
+ /**
728
752
  * The function used to encode keys using the shared `keyBuffer`.
729
753
  */
730
754
  writeKey;
@@ -739,6 +763,7 @@ var Store = class {
739
763
  if (options !== void 0 && options !== null && typeof options !== "object") throw new TypeError("Database options must be an object");
740
764
  const { keyEncoding, readKey, writeKey } = initKeyEncoder(options?.keyEncoding, options?.keyEncoder);
741
765
  this.db = new NativeDatabase();
766
+ this.dbWriteBufferSize = options?.dbWriteBufferSize;
742
767
  this.decoder = options?.decoder ?? null;
743
768
  this.disableWAL = options?.disableWAL ?? false;
744
769
  this.enableStats = options?.enableStats ?? false;
@@ -749,9 +774,11 @@ var Store = class {
749
774
  this.keyBuffer = KEY_BUFFER;
750
775
  this.keyEncoding = keyEncoding;
751
776
  this.maxKeySize = options?.maxKeySize ?? MAX_KEY_SIZE;
777
+ this.maxWriteBufferNumber = options?.maxWriteBufferNumber;
778
+ this.maxWriteBufferSizeToMaintain = options?.maxWriteBufferSizeToMaintain;
752
779
  this.name = options?.name ?? "default";
753
780
  this.noBlockCache = options?.noBlockCache;
754
- this.parallelismThreads = options?.parallelismThreads ?? 1;
781
+ this.parallelismThreads = options?.parallelismThreads;
755
782
  this.path = path;
756
783
  this.pessimistic = options?.pessimistic ?? false;
757
784
  this.readOnly = options?.readOnly ?? false;
@@ -763,6 +790,7 @@ var Store = class {
763
790
  this.transactionLogMaxSize = options?.transactionLogMaxSize;
764
791
  this.transactionLogRetention = options?.transactionLogRetention;
765
792
  this.transactionLogsPath = options?.transactionLogsPath;
793
+ this.writeBufferSize = options?.writeBufferSize;
766
794
  this.writeKey = writeKey;
767
795
  }
768
796
  /**
@@ -1024,8 +1052,11 @@ var Store = class {
1024
1052
  open() {
1025
1053
  if (this.db.opened) return true;
1026
1054
  this.db.open(this.path, {
1055
+ dbWriteBufferSize: this.dbWriteBufferSize,
1027
1056
  disableWAL: this.disableWAL,
1028
1057
  enableStats: this.enableStats,
1058
+ maxWriteBufferNumber: this.maxWriteBufferNumber,
1059
+ maxWriteBufferSizeToMaintain: this.maxWriteBufferSizeToMaintain,
1029
1060
  mode: this.pessimistic ? "pessimistic" : "optimistic",
1030
1061
  name: this.name,
1031
1062
  noBlockCache: this.noBlockCache,
@@ -1035,7 +1066,8 @@ var Store = class {
1035
1066
  transactionLogMaxAgeThreshold: this.transactionLogMaxAgeThreshold,
1036
1067
  transactionLogMaxSize: this.transactionLogMaxSize,
1037
1068
  transactionLogRetentionMs: this.transactionLogRetention ? parseDuration(this.transactionLogRetention) : void 0,
1038
- transactionLogsPath: this.transactionLogsPath
1069
+ transactionLogsPath: this.transactionLogsPath,
1070
+ writeBufferSize: this.writeBufferSize
1039
1071
  });
1040
1072
  return false;
1041
1073
  }
@@ -1828,12 +1860,14 @@ var RocksDatabase = class RocksDatabase extends DBI {
1828
1860
  //#endregion
1829
1861
  //#region src/parse-transaction-log.ts
1830
1862
  const { TRANSACTION_LOG_TOKEN } = constants;
1863
+ const MIN_VALID_TIMESTAMP = Date.UTC(2026, 0, 27);
1864
+ const VALID_FLAGS_MASK = 1;
1831
1865
  /**
1832
1866
  * Loads an entire transaction log file into memory.
1833
1867
  * @param path - The path to the transaction log file.
1834
1868
  * @returns The transaction log.
1835
1869
  */
1836
- function parseTransactionLog(path) {
1870
+ function parseTransactionLog(path, options = {}) {
1837
1871
  let stats;
1838
1872
  try {
1839
1873
  stats = (0, node_fs.statSync)(path);
@@ -1857,7 +1891,10 @@ function parseTransactionLog(path) {
1857
1891
  const version = read(1).readUInt8(0);
1858
1892
  if (version !== 1) throw new Error(`Unsupported transaction log file version: ${version}`);
1859
1893
  const timestamp = read(8).readDoubleBE(0);
1894
+ const anomalies = [];
1895
+ if (!Number.isFinite(timestamp) || timestamp < MIN_VALID_TIMESTAMP) anomalies.push(`Header timestamp ${timestamp} predates 2026-01-27 (possible corruption)`);
1860
1896
  const entries = [];
1897
+ let entryAnomalyCount = 0;
1861
1898
  while (fileOffset < size) {
1862
1899
  const timestamp = read(8).readDoubleBE(0);
1863
1900
  if (timestamp === 0) {
@@ -1867,15 +1904,25 @@ function parseTransactionLog(path) {
1867
1904
  const length = read(4).readUInt32BE(0);
1868
1905
  const flags = read(1).readUInt8(0);
1869
1906
  const data = read(length);
1870
- entries.push({
1907
+ const entryAnomalies = [];
1908
+ if (!Number.isFinite(timestamp) || timestamp < MIN_VALID_TIMESTAMP) entryAnomalies.push(`timestamp ${timestamp} predates 2026-01-27 (possible corruption)`);
1909
+ if ((flags & ~VALID_FLAGS_MASK) !== 0) entryAnomalies.push(`flags 0x${flags.toString(16).padStart(2, "0")} contains undefined bits (expected 0x00 or 0x01)`);
1910
+ const entry = {
1871
1911
  timestamp,
1872
1912
  length,
1873
1913
  flags,
1874
- data
1875
- });
1914
+ data: options.skipData ? void 0 : Buffer.from(data)
1915
+ };
1916
+ if (entryAnomalies.length > 0) {
1917
+ entry.anomalies = entryAnomalies;
1918
+ entryAnomalyCount += entryAnomalies.length;
1919
+ }
1920
+ entries.push(entry);
1876
1921
  }
1877
1922
  return {
1923
+ anomalies,
1878
1924
  entries,
1925
+ entryAnomalyCount,
1879
1926
  timestamp,
1880
1927
  size,
1881
1928
  version
@@ -2068,7 +2115,7 @@ function loadLastPosition(transactionLog, readUncommitted) {
2068
2115
  //#region src/index.ts
2069
2116
  const versions = {
2070
2117
  rocksdb: version,
2071
- "rocksdb-js": "1.2.0"
2118
+ "rocksdb-js": "1.4.0"
2072
2119
  };
2073
2120
 
2074
2121
  //#endregion