open-brainy/src/cli/commands/utility.ts
David Snelling 478fa176f2 refactor(8.0): delete DataAPI — superseded by Db persist/restore + import API + stats
The legacy backup/import/export/stats facade (src/api/DataAPI.ts) drifted
from the modern entity shape and every job it did now has a first-class
surface. Delete it and brain.data(), and rewire the CLI:

- data-stats → brain.stats() (full BrainyStats report: per-type breakdowns,
  indexed fields, index health, storage backend, writer lock, version)
- clean → brain.clear()
- export → alias of snapshot; a db.persist() snapshot is the full-fidelity
  export format (open with Brainy.load, load wholesale with brainy restore);
  external data ingestion remains brainy import (UniversalImportAPI)

Rewiring clean onto brain.clear() exposed two real bugs, both fixed:

- clear() left this.graphIndex undefined forever — any graph-touching call
  afterwards (relate, getNeighbors, stats) crashed. clear() now re-resolves
  the graph index exactly as init() does and re-wires the shared UUID↔int
  resolver, and re-resolves the metadata index with the same provider
  fallback as init().
- storage.clear() reset the legacy totals but not the per-type/subtype
  count rollups or id→type caches, so stats() reported phantom counts for
  deleted entities. Both adapters now delegate derived-state reset to
  reloadDerivedState(), the same path restore-from-snapshot uses.

One-shot CLI commands (data-stats, clean, snapshot/export, restore,
history, generation) now close the brain and exit explicitly — global
cache timers otherwise keep the process alive holding the writer lock.

Verified: build clean, 1383/1383 unit tests, 24/24 db-mvcc integration,
plus an end-to-end CLI smoke (add → data-stats → export → clean →
data-stats).
2026-06-11 09:05:12 -07:00

293 lines
No EOL
8.7 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Utility CLI Commands - TypeScript Implementation
*
* Database maintenance, statistics, and benchmarking
*/
import chalk from 'chalk'
import ora from 'ora'
import Table from 'cli-table3'
import { Brainy } from '../../brainy.js'
import { NounType } from '../../types/graphTypes.js'
interface UtilityOptions {
verbose?: boolean
json?: boolean
pretty?: boolean
}
interface StatsOptions extends UtilityOptions {
byService?: boolean
detailed?: boolean
}
interface CleanOptions extends UtilityOptions {
removeOrphans?: boolean
rebuildIndex?: boolean
}
interface BenchmarkOptions extends UtilityOptions {
operations?: string
iterations?: string
}
let brainyInstance: Brainy | null = null
const getBrainy = (): Brainy => {
if (!brainyInstance) {
brainyInstance = new Brainy()
}
return brainyInstance
}
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const formatOutput = (data: any, options: UtilityOptions): void => {
if (options.json) {
console.log(options.pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data))
}
}
export const utilityCommands = {
/**
* Show database statistics
*/
async stats(options: StatsOptions) {
const spinner = ora('Gathering statistics...').start()
try {
const brain = getBrainy()
const nounCount = await brain.getNounCount()
const verbCount = await brain.getVerbCount()
const memUsage = process.memoryUsage()
spinner.succeed('Statistics gathered')
const stats = {
nounCount,
verbCount,
totalItems: nounCount + verbCount
}
if (options.json) {
formatOutput(stats, options)
return
}
console.log(chalk.cyan('\n📊 Database Statistics\n'))
// Core stats table
const coreTable = new Table({
head: [chalk.cyan('Metric'), chalk.cyan('Value')],
style: { head: [], border: [] }
})
coreTable.push(
['Total Items', chalk.green(stats.totalItems)],
['Nouns', chalk.green(stats.nounCount)],
['Verbs (Relationships)', chalk.green(stats.verbCount)]
)
console.log(coreTable.toString())
// Memory usage
console.log(chalk.cyan('\n🧠 Memory Usage\n'))
const memTable = new Table({
head: [chalk.cyan('Type'), chalk.cyan('Size')],
style: { head: [], border: [] }
})
memTable.push(
['Heap Used', formatBytes(memUsage.heapUsed)],
['Heap Total', formatBytes(memUsage.heapTotal)],
['RSS', formatBytes(memUsage.rss)],
['External', formatBytes(memUsage.external)]
)
console.log(memTable.toString())
} catch (error: any) {
spinner.fail('Failed to gather statistics')
console.error(chalk.red(error.message))
process.exit(1)
}
},
/**
* Clean and optimize database
*/
async clean(options: CleanOptions) {
const spinner = ora('Cleaning database...').start()
try {
const brain = getBrainy()
// For now, only support full clear
// removeOrphans and rebuildIndex would require new Brainy APIs
if (options.removeOrphans || options.rebuildIndex) {
spinner.warn('Advanced cleanup options not yet implemented')
console.log(chalk.yellow('\n⚠ Advanced cleanup features coming soon:'))
console.log(chalk.dim(' • --remove-orphans: Remove disconnected items'))
console.log(chalk.dim(' • --rebuild-index: Rebuild vector index'))
console.log(chalk.dim('\nUse "brainy clean" without options to clear the database'))
return
}
// Show warning before clearing
console.log(chalk.yellow('\n⚠ WARNING: This will permanently delete ALL data!'))
// Clear all data (entities, relationships, and every index)
spinner.text = 'Clearing all data...'
await brain.init()
await brain.clear()
spinner.succeed('Database cleared')
if (!options.json) {
console.log(chalk.green('\n✓ Database cleared successfully'))
console.log(chalk.dim(' All nouns, verbs, and metadata have been removed'))
} else {
formatOutput({ cleared: true, success: true }, options)
}
// close() releases the writer lock and indexes, but global timers
// (UnifiedCache bookkeeping, PathResolver stats) keep the event loop
// alive. CLI commands are one-shot — exit explicitly.
await brain.close()
process.exit(0)
} catch (error: any) {
spinner.fail('Cleanup failed')
console.error(chalk.red(error.message))
process.exit(1)
}
},
/**
* Run performance benchmarks
*/
async benchmark(options: BenchmarkOptions) {
const operations = options.operations || 'all'
const iterations = parseInt(options.iterations || '100')
console.log(chalk.cyan(`\n🚀 Running Benchmarks (${iterations} iterations)\n`))
const results: any = {
operations: {},
summary: {}
}
try {
const brain = getBrainy()
// Benchmark different operations
const benchmarks = [
{ name: 'add', enabled: operations === 'all' || operations.includes('add') },
{ name: 'search', enabled: operations === 'all' || operations.includes('search') },
{ name: 'similarity', enabled: operations === 'all' || operations.includes('similarity') },
{ name: 'cluster', enabled: operations === 'all' || operations.includes('cluster') }
]
for (const bench of benchmarks) {
if (!bench.enabled) continue
const spinner = ora(`Benchmarking ${bench.name}...`).start()
const times: number[] = []
for (let i = 0; i < iterations; i++) {
const start = Date.now()
switch (bench.name) {
case 'add':
await brain.add({ data: `Test item ${i}`, type: NounType.Thing, metadata: { benchmark: true } })
break
case 'search':
await brain.find({ query: 'test', limit: 10 })
break
case 'similarity':
const neural = brain.neural()
await neural.similar('test1', 'test2')
break
case 'cluster':
const neuralApi = brain.neural()
await neuralApi.clusters()
break
}
times.push(Date.now() - start)
}
// Calculate statistics
const avg = times.reduce((a, b) => a + b, 0) / times.length
const min = Math.min(...times)
const max = Math.max(...times)
const median = times.sort((a, b) => a - b)[Math.floor(times.length / 2)]
results.operations[bench.name] = {
avg: avg.toFixed(2),
min,
max,
median,
ops: (1000 / avg).toFixed(2)
}
spinner.succeed(`${bench.name}: ${avg.toFixed(2)}ms avg (${(1000 / avg).toFixed(2)} ops/sec)`)
}
// Calculate summary
const totalOps: number = (Object.values(results.operations) as any[]).reduce((sum: number, op: any) =>
sum + parseFloat(op.ops), 0)
results.summary = {
totalOperations: Object.keys(results.operations).length,
averageOpsPerSec: totalOps > 0 ? (totalOps / Object.keys(results.operations).length).toFixed(2) : '0'
}
if (!options.json) {
// Display results table
console.log(chalk.cyan('\n📊 Benchmark Results\n'))
const table = new Table({
head: [
chalk.cyan('Operation'),
chalk.cyan('Avg (ms)'),
chalk.cyan('Min (ms)'),
chalk.cyan('Max (ms)'),
chalk.cyan('Median (ms)'),
chalk.cyan('Ops/sec')
],
style: { head: [], border: [] }
})
Object.entries(results.operations).forEach(([op, stats]: [string, any]) => {
table.push([
op,
stats.avg,
stats.min,
stats.max,
stats.median,
chalk.green(stats.ops)
])
})
console.log(table.toString())
console.log(chalk.cyan('\n📈 Summary'))
console.log(` Operations tested: ${results.summary.totalOperations}`)
console.log(` Average throughput: ${chalk.green(results.summary.averageOpsPerSec)} ops/sec`)
} else {
formatOutput(results, options)
}
} catch (error: any) {
console.error(chalk.red('Benchmark failed:'), error.message)
process.exit(1)
}
}
}