@harperfast/rocksdb-js 2.0.0 → 2.2.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.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { RocksDatabase, parseTransactionLog, versions } from '../dist/index.mjs';
3
+ import { RocksDatabase, backups, parseTransactionLog, versions } from '../dist/index.mjs';
4
4
  import { existsSync, readdirSync } from 'node:fs';
5
5
  import { mkdir, readdir, stat } from 'node:fs/promises';
6
6
  import { join, resolve } from 'node:path';
@@ -10,6 +10,7 @@ import repl from 'node:repl';
10
10
  import { inspect, parseArgs, styleText } from 'node:util';
11
11
 
12
12
  const COMMANDS = {
13
+ backups: backupsCommand,
13
14
  clear: clearCommand,
14
15
  columns: columnsCommand,
15
16
  compact: compactCommand,
@@ -30,6 +31,11 @@ const COMMANDS = {
30
31
  use: useCommand,
31
32
  };
32
33
 
34
+ const BACKUP_SUBCOMMANDS = ['backup', 'restore', 'verify', 'delete', 'purge'];
35
+
36
+ // Resolved backup dir -> BackupInfo[]; warmed by list operations, used for tab completion.
37
+ let backupListCache = { dir: null, list: null };
38
+
33
39
  const { values: argv, positionals } = parseArgs({
34
40
  allowPositionals: true,
35
41
  arguments: process.argv.slice(2),
@@ -88,9 +94,45 @@ function completer(line) {
88
94
  }
89
95
  }
90
96
 
97
+ if (command === 'backups') {
98
+ if (parts.length === 2) {
99
+ const arg = parts[1];
100
+ return [completePath(arg), arg];
101
+ }
102
+ if (parts.length === 3) {
103
+ const arg = parts[2];
104
+ const hits = BACKUP_SUBCOMMANDS.filter((s) => s.startsWith(arg)).map((s) => `${s} `);
105
+ return [hits.length ? hits : BACKUP_SUBCOMMANDS.map((s) => `${s} `), arg];
106
+ }
107
+ if (parts.length === 4 && backupListCache.dir === resolve(parts[1]) && backupListCache.list) {
108
+ const subcommand = parts[2];
109
+ const arg = parts[3];
110
+ let candidates = [];
111
+ if (['restore', 'verify', 'delete'].includes(subcommand)) {
112
+ candidates = backupListCache.list.map((b) => String(b.backupId));
113
+ } else if (subcommand === 'purge') {
114
+ candidates = backupListCache.list.map((_, i) => String(i + 1));
115
+ }
116
+ return [candidates.filter((c) => c.startsWith(arg)).map((c) => `${c} `), arg];
117
+ }
118
+ }
119
+
91
120
  return [[], line];
92
121
  }
93
122
 
123
+ function completePath(partial) {
124
+ const sep = partial.lastIndexOf('/');
125
+ const dir = sep === -1 ? '.' : partial.slice(0, sep + 1);
126
+ const prefix = sep === -1 ? partial : partial.slice(sep + 1);
127
+ try {
128
+ return readdirSync(dir, { withFileTypes: true })
129
+ .filter((entry) => entry.name.startsWith(prefix))
130
+ .map((entry) => `${sep === -1 ? '' : dir}${entry.name}${entry.isDirectory() ? '/' : ' '}`);
131
+ } catch {
132
+ return [];
133
+ }
134
+ }
135
+
94
136
  function onSIGINT() {
95
137
  if (ctrlC) {
96
138
  console.log();
@@ -200,6 +242,250 @@ async function ask(prompt) {
200
242
  }
201
243
  }
202
244
 
245
+ function invalidateBackupListCache() {
246
+ backupListCache = { dir: null, list: null };
247
+ }
248
+
249
+ async function fetchBackupList(backupDir) {
250
+ const list = await backups.list(backupDir);
251
+ backupListCache = { dir: backupDir, list };
252
+ return list;
253
+ }
254
+
255
+ function printBackupList(list) {
256
+ if (list.length === 0) {
257
+ console.log('No backups found');
258
+ return;
259
+ }
260
+ for (const backup of list) {
261
+ const ts = new Date(backup.timestamp * 1000).toISOString();
262
+ console.log(
263
+ `${String(backup.backupId).padStart(3)}) ${hl(ts)} ${formatBytes(backup.size)} ${backup.numberFiles} file${backup.numberFiles === 1 ? '' : 's'}`
264
+ );
265
+ if (backup.appMetadata) {
266
+ console.log(` ${note(`metadata: ${backup.appMetadata}`)}`);
267
+ }
268
+ }
269
+ }
270
+
271
+ async function listBackups(backupDir) {
272
+ let list;
273
+ try {
274
+ list = await fetchBackupList(backupDir);
275
+ } catch (error) {
276
+ console.log(bad(`Unable to list backups: ${error.message ?? error}`));
277
+ console.log(note(`Check the path or create a first backup with "backups <dir> backup"`));
278
+ return null;
279
+ }
280
+ printBackupList(list);
281
+ return list;
282
+ }
283
+
284
+ async function requireBackupId(backupDir, idStr, list = null) {
285
+ if (!list) {
286
+ list = await fetchBackupList(backupDir);
287
+ }
288
+ const id = Number(idStr);
289
+ if (!Number.isInteger(id)) {
290
+ console.log(`Invalid backup id: ${hl(idStr)}\n`);
291
+ printBackupList(list);
292
+ return null;
293
+ }
294
+ const backup = list.find((b) => b.backupId === id);
295
+ if (!backup) {
296
+ console.log(`Backup ${hl(id)} does not exist\n`);
297
+ printBackupList(list);
298
+ return null;
299
+ }
300
+ return { id, backup };
301
+ }
302
+
303
+ async function dirSize(dir) {
304
+ let entries;
305
+ try {
306
+ entries = await readdir(dir, { withFileTypes: true });
307
+ } catch {
308
+ return 0;
309
+ }
310
+ let total = 0;
311
+ for (const entry of entries) {
312
+ const entryPath = join(dir, entry.name);
313
+ if (entry.isDirectory()) {
314
+ total += await dirSize(entryPath);
315
+ } else if (entry.isFile()) {
316
+ try {
317
+ total += (await stat(entryPath)).size;
318
+ } catch {
319
+ // File removed while walking; skip it.
320
+ }
321
+ }
322
+ }
323
+ return total;
324
+ }
325
+
326
+ function closeAllDatabases() {
327
+ for (const [name, db] of Object.entries(dbs)) {
328
+ db?.close();
329
+ delete dbs[name];
330
+ }
331
+ currentDB = null;
332
+ }
333
+
334
+ function reopenDatabases(preferredColumn) {
335
+ currentDB = RocksDatabase.open(dbPath, { ...defaultOpenOptions });
336
+ dbs[currentDB.name] = currentDB;
337
+ if (
338
+ preferredColumn &&
339
+ preferredColumn !== currentDB.name &&
340
+ currentDB.columns.includes(preferredColumn)
341
+ ) {
342
+ dbs[preferredColumn] = RocksDatabase.open(dbPath, {
343
+ name: preferredColumn,
344
+ ...defaultOpenOptions,
345
+ });
346
+ currentDB = dbs[preferredColumn];
347
+ }
348
+ console.log(wrapColumns(currentDB.columns));
349
+ }
350
+
351
+ async function backupsCommand(args) {
352
+ const [dirArg, subcommand, extra] = args;
353
+ if (!dirArg) {
354
+ console.log(`Usage: ${hl('backups <dir> [subcommand] [arg]')}`);
355
+ console.log(`Subcommands: ${BACKUP_SUBCOMMANDS.map((s) => hl(s)).join(', ')}\n`);
356
+ return;
357
+ }
358
+
359
+ const backupDir = resolve(dirArg);
360
+
361
+ if (subcommand && !BACKUP_SUBCOMMANDS.includes(subcommand)) {
362
+ console.log(`Unknown subcommand: ${hl(subcommand)}`);
363
+ console.log(`Valid subcommands: ${BACKUP_SUBCOMMANDS.map((s) => hl(s)).join(', ')}\n`);
364
+ return;
365
+ }
366
+
367
+ // The backup engine creates the directory when creating a backup; everything
368
+ // else operates on an existing backup directory.
369
+ if (subcommand !== 'backup' && !existsSync(backupDir)) {
370
+ console.log(`Backup directory ${hl(backupDir)} does not exist`);
371
+ console.log(note(`Create a first backup with "backups ${dirArg} backup"\n`));
372
+ return;
373
+ }
374
+
375
+ if (!subcommand || subcommand === 'ls' || subcommand === 'list') {
376
+ await listBackups(backupDir);
377
+ console.log();
378
+ return;
379
+ }
380
+
381
+ if (subcommand === 'backup') {
382
+ const { result: id, time } = await run(() => currentDB.backup(backupDir));
383
+ invalidateBackupListCache();
384
+ console.log(`Created backup ${hl(id)} ${note(`(${time}ms)`)}\n`);
385
+ return;
386
+ }
387
+
388
+ if (extra === undefined) {
389
+ const list = await listBackups(backupDir);
390
+ if (list?.length) {
391
+ const argName = subcommand === 'purge' ? '<keep-count>' : '<backup-id>';
392
+ console.log(`\nUsage: ${hl(`backups ${dirArg} ${subcommand} ${argName}`)}`);
393
+ }
394
+ console.log();
395
+ return;
396
+ }
397
+
398
+ switch (subcommand) {
399
+ case 'restore': {
400
+ if (argv.readonly) {
401
+ console.log(
402
+ 'Restore is not available in read-only mode. Restart without --readonly to restore.\n'
403
+ );
404
+ return;
405
+ }
406
+ if (backupDir === dbPath) {
407
+ console.log(bad('Backup directory and database directory must be different\n'));
408
+ return;
409
+ }
410
+ const found = await requireBackupId(backupDir, extra);
411
+ if (!found) {
412
+ console.log();
413
+ return;
414
+ }
415
+ const answer = await ask(`Are you sure you want to restore backup ${hl(found.id)}? (y/N) `);
416
+ if (answer !== 'y' && answer !== 'Y') {
417
+ console.log();
418
+ return;
419
+ }
420
+ const preferredColumn = currentDB?.name;
421
+ closeAllDatabases();
422
+ let time;
423
+ try {
424
+ ({ time } = await run(() => backups.restore(backupDir, dbPath, { backupId: found.id })));
425
+ } catch (error) {
426
+ // Don't reopen: the destructive restore may have purged the database
427
+ // directory and a read-write open would create a new, empty database.
428
+ console.error(bad(`Restore failed, the database may be incomplete: ${error}`));
429
+ process.exit(1);
430
+ }
431
+ reopenDatabases(preferredColumn);
432
+ console.log(`Restored backup ${hl(found.id)} ${note(`(${time}ms)`)}\n`);
433
+ return;
434
+ }
435
+
436
+ case 'verify': {
437
+ const found = await requireBackupId(backupDir, extra);
438
+ if (!found) {
439
+ console.log();
440
+ return;
441
+ }
442
+ try {
443
+ const { time } = await run(() =>
444
+ backups.verify(backupDir, found.id, { verifyWithChecksum: true })
445
+ );
446
+ console.log(`Backup ${hl(found.id)} verified OK ${note(`(${time}ms)`)}\n`);
447
+ } catch (error) {
448
+ console.error(bad(`Verify failed: ${error.toString()}`));
449
+ }
450
+ return;
451
+ }
452
+
453
+ case 'delete': {
454
+ const found = await requireBackupId(backupDir, extra);
455
+ if (!found) {
456
+ console.log();
457
+ return;
458
+ }
459
+ const sizeBefore = await dirSize(backupDir);
460
+ const { time } = await run(() => backups.delete(backupDir, found.id));
461
+ invalidateBackupListCache();
462
+ const sizeAfter = await dirSize(backupDir);
463
+ console.log(
464
+ `Deleted backup ${hl(found.id)}, recovered ${hl(formatBytes(Math.max(0, sizeBefore - sizeAfter)))} ${note(`(${time}ms)`)}\n`
465
+ );
466
+ return;
467
+ }
468
+
469
+ case 'purge': {
470
+ const keepCount = Number(extra);
471
+ // The native layer reads keepCount as a uint32; values above that wrap.
472
+ if (!Number.isInteger(keepCount) || keepCount < 0 || keepCount > 0xffffffff) {
473
+ console.log(`Invalid keep count: ${hl(extra)}`);
474
+ console.log(`Usage: ${hl(`backups ${dirArg} purge <keep-count>`)}\n`);
475
+ return;
476
+ }
477
+ const sizeBefore = await dirSize(backupDir);
478
+ const { time } = await run(() => backups.purge(backupDir, keepCount));
479
+ invalidateBackupListCache();
480
+ const sizeAfter = await dirSize(backupDir);
481
+ console.log(
482
+ `Purged backups, keeping ${hl(keepCount)}, recovered ${hl(formatBytes(Math.max(0, sizeBefore - sizeAfter)))} ${note(`(${time}ms)`)}\n`
483
+ );
484
+ return;
485
+ }
486
+ }
487
+ }
488
+
203
489
  async function clearCommand() {
204
490
  const answer = await ask(`Are you sure you want to clear all data? (y/N) `);
205
491
  if (answer !== 'y' && answer !== 'Y') {
@@ -300,6 +586,9 @@ async function propCommand(args) {
300
586
  }
301
587
 
302
588
  function helpCommand() {
589
+ console.log(
590
+ `${hl('backups <dir> [subcommand]')} Manage backups (list, backup, restore, verify, delete, purge)`
591
+ );
303
592
  console.log(`${hl('clear')} Clear all data in the current column family`);
304
593
  console.log(`${hl('columns')} List column families`);
305
594
  console.log(`${hl('compact')} Compact the current column family`);
@@ -454,7 +743,6 @@ async function putCommand(args) {
454
743
  value = value.slice(1, -1);
455
744
  }
456
745
  await currentDB.put(key, value);
457
- console.log();
458
746
  }
459
747
 
460
748
  async function queryCommand(args) {