feat(8.0)!: delete fork/branch/commit/history/versions — superseded by the Db API
The COW version-control surface (fork, branches, checkout, commit,
getHistory/streamHistory, asOfCommit, brain.versions) is gone, along with
its subsystems: src/versioning/, the COW object store (CommitLog,
CommitObject, RefManager, TreeObject), HistoricalStorageAdapter, and the
TypeAwareHNSWIndex path it kept alive. The Db API (now/transact/asOf/
with/persist/restore) is the one versioning model in 8.0.
Survivors and replacements:
- BlobStorage survives (the VFS stores file content through it), relocated
to src/storage/blobStorage.ts with binaryDataCodec.ts; its adapter
interface is now BlobStoreAdapter, slimmed to the consumed surface
(write/read/has/delete/getMetadata + MIME-aware compression policy).
- brain.migrate() backup branches are replaced by persist-before-migrate:
MigrateOptions.backupTo persists a hard-link snapshot of the current
generation before any transform runs; MigrationResult.backupPath reports
it, and brain.restore(path) brings it back wholesale.
- CLI: cow.ts (fork/branch/checkout/history/migrate) is replaced by
snapshot.ts — snapshot <path>, restore <path>, history (tx-log),
generation.
- New public read API: brain.transactionLog({limit}) exposes the reified
tx-log (generation/timestamp/meta, newest first) that backs the CLI
history command; TxLogEntry is exported.
Tests: superseded suites deleted; fork/commit blocks excised from shared
suites; BlobStorage tests relocated + reworked against the slimmed store;
migration tests now prove the backupTo snapshot/restore round trip; new
transactionLog coverage in db-mvcc.
This commit is contained in:
parent
431cd64406
commit
8f93add705
64 changed files with 1444 additions and 16313 deletions
|
|
@ -1,465 +0,0 @@
|
|||
/**
|
||||
* COW CLI Commands - Copy-on-Write Operations
|
||||
*
|
||||
* Fork, branch, merge, and migration operations for instant cloning
|
||||
*/
|
||||
|
||||
import chalk from 'chalk'
|
||||
import ora from 'ora'
|
||||
import inquirer from 'inquirer'
|
||||
import { Brainy } from '../../brainy.js'
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
interface CoreOptions {
|
||||
verbose?: boolean
|
||||
json?: boolean
|
||||
pretty?: boolean
|
||||
}
|
||||
|
||||
interface ForkOptions extends CoreOptions {
|
||||
name?: string
|
||||
message?: string
|
||||
author?: string
|
||||
}
|
||||
|
||||
interface MergeOptions extends CoreOptions {
|
||||
force?: boolean
|
||||
strategy?: 'last-write-wins' | 'custom'
|
||||
}
|
||||
|
||||
interface MigrateOptions extends CoreOptions {
|
||||
from?: string
|
||||
to?: string
|
||||
backup?: boolean
|
||||
dryRun?: boolean
|
||||
}
|
||||
|
||||
let brainyInstance: Brainy | null = null
|
||||
|
||||
const getBrainy = (): Brainy => {
|
||||
if (!brainyInstance) {
|
||||
brainyInstance = new Brainy()
|
||||
}
|
||||
return brainyInstance
|
||||
}
|
||||
|
||||
const formatOutput = (data: any, options: CoreOptions): void => {
|
||||
if (options.json) {
|
||||
console.log(options.pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data))
|
||||
}
|
||||
}
|
||||
|
||||
export const cowCommands = {
|
||||
/**
|
||||
* Fork the current brain (instant clone)
|
||||
*/
|
||||
async fork(name: string | undefined, options: ForkOptions) {
|
||||
let spinner: any = null
|
||||
try {
|
||||
const brain = getBrainy()
|
||||
await brain.init()
|
||||
|
||||
// Interactive mode if no name provided
|
||||
if (!name) {
|
||||
const answers = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'branchName',
|
||||
message: 'Enter fork/branch name:',
|
||||
default: `fork-${Date.now()}`,
|
||||
validate: (input: string) =>
|
||||
input.trim().length > 0 || 'Branch name cannot be empty'
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'message',
|
||||
message: 'Commit message (optional):',
|
||||
default: 'Fork from main'
|
||||
}
|
||||
])
|
||||
name = answers.branchName
|
||||
options.message = answers.message
|
||||
}
|
||||
|
||||
spinner = ora(`Forking brain to ${chalk.cyan(name)}...`).start()
|
||||
|
||||
const startTime = Date.now()
|
||||
const fork = await brain.fork(name)
|
||||
const elapsed = Date.now() - startTime
|
||||
|
||||
spinner.succeed(
|
||||
`Fork created: ${chalk.green(name)} ${chalk.dim(`(${elapsed}ms)`)}`
|
||||
)
|
||||
|
||||
// Show stats
|
||||
const stats = await fork.getStats()
|
||||
console.log(`
|
||||
${chalk.cyan('Fork Statistics:')}
|
||||
${chalk.dim('Entities:')} ${stats.entities.total || 0}
|
||||
${chalk.dim('Relationships:')} ${stats.relationships.totalRelationships || 0}
|
||||
${chalk.dim('Time:')} ${elapsed}ms
|
||||
${chalk.dim('Storage overhead:')} ~10-20%
|
||||
`.trim())
|
||||
|
||||
if (options.json) {
|
||||
formatOutput({
|
||||
branch: name,
|
||||
time: elapsed,
|
||||
stats
|
||||
}, options)
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
if (spinner) spinner.fail('Fork failed')
|
||||
console.error(chalk.red('Error:'), error.message)
|
||||
if (options.verbose) console.error(error)
|
||||
process.exit(1)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* List all branches/forks
|
||||
*/
|
||||
async branchList(options: CoreOptions) {
|
||||
try {
|
||||
const brain = getBrainy()
|
||||
await brain.init()
|
||||
|
||||
const branches = await brain.listBranches()
|
||||
const currentBranch = await brain.getCurrentBranch()
|
||||
|
||||
console.log(chalk.cyan('\nBranches:'))
|
||||
|
||||
for (const branch of branches) {
|
||||
const isCurrent = branch === currentBranch
|
||||
const marker = isCurrent ? chalk.green('*') : ' '
|
||||
const name = isCurrent ? chalk.green(branch) : branch
|
||||
|
||||
// Branch-info enrichment (last-commit timestamp, etc.) requires a
|
||||
// refManager accessor that brainy hasn't surfaced on the public
|
||||
// storage adapter yet. CLI shows the bare branch name for now.
|
||||
console.log(` ${marker} ${name}`)
|
||||
}
|
||||
|
||||
console.log()
|
||||
|
||||
if (options.json) {
|
||||
formatOutput({
|
||||
branches,
|
||||
currentBranch
|
||||
}, options)
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(chalk.red('Error:'), error.message)
|
||||
if (options.verbose) console.error(error)
|
||||
process.exit(1)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Switch to a different branch
|
||||
*/
|
||||
async checkout(branch: string | undefined, options: CoreOptions) {
|
||||
let spinner: any = null
|
||||
try {
|
||||
const brain = getBrainy()
|
||||
await brain.init()
|
||||
|
||||
// Interactive mode if no branch provided
|
||||
if (!branch) {
|
||||
const branches = await brain.listBranches()
|
||||
const currentBranch = await brain.getCurrentBranch()
|
||||
|
||||
const { selected } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'selected',
|
||||
message: 'Select branch:',
|
||||
choices: branches.map(b => ({
|
||||
name: b === currentBranch ? `${b} (current)` : b,
|
||||
value: b
|
||||
}))
|
||||
}
|
||||
])
|
||||
branch = selected
|
||||
}
|
||||
|
||||
const currentBranch = await brain.getCurrentBranch()
|
||||
|
||||
if (branch === currentBranch) {
|
||||
console.log(chalk.yellow(`Already on branch '${branch}'`))
|
||||
return
|
||||
}
|
||||
|
||||
spinner = ora(`Switching to ${chalk.cyan(branch)}...`).start()
|
||||
|
||||
await brain.checkout(branch!)
|
||||
|
||||
spinner.succeed(`Switched to branch ${chalk.green(branch)}`)
|
||||
|
||||
if (options.json) {
|
||||
formatOutput({ branch }, options)
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
if (spinner) spinner.fail('Checkout failed')
|
||||
console.error(chalk.red('Error:'), error.message)
|
||||
if (options.verbose) console.error(error)
|
||||
process.exit(1)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a branch/fork
|
||||
*/
|
||||
async branchDelete(branch: string | undefined, options: CoreOptions & { force?: boolean }) {
|
||||
try {
|
||||
const brain = getBrainy()
|
||||
await brain.init()
|
||||
|
||||
// Interactive mode if no branch provided
|
||||
if (!branch) {
|
||||
const branches = await brain.listBranches()
|
||||
const currentBranch = await brain.getCurrentBranch()
|
||||
|
||||
const { selected } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'selected',
|
||||
message: 'Select branch to delete:',
|
||||
choices: branches
|
||||
.filter(b => b !== currentBranch) // Can't delete current
|
||||
.map(b => ({ name: b, value: b }))
|
||||
}
|
||||
])
|
||||
branch = selected
|
||||
}
|
||||
|
||||
// Confirm deletion
|
||||
if (!options.force) {
|
||||
const { confirm } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirm',
|
||||
message: `Delete branch '${branch}'? This cannot be undone.`,
|
||||
default: false
|
||||
}
|
||||
])
|
||||
|
||||
if (!confirm) {
|
||||
console.log(chalk.yellow('Deletion cancelled'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const spinner = ora(`Deleting branch ${chalk.red(branch)}...`).start()
|
||||
|
||||
await brain.deleteBranch(branch!)
|
||||
|
||||
spinner.succeed(`Deleted branch ${chalk.red(branch)}`)
|
||||
|
||||
if (options.json) {
|
||||
formatOutput({ deleted: branch }, options)
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(chalk.red('Error:'), error.message)
|
||||
if (options.verbose) console.error(error)
|
||||
process.exit(1)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* View commit history
|
||||
*/
|
||||
async history(options: CoreOptions & { limit?: string }) {
|
||||
try {
|
||||
const brain = getBrainy()
|
||||
await brain.init()
|
||||
|
||||
const limit = options.limit ? parseInt(options.limit) : 10
|
||||
|
||||
const history = await brain.getHistory({ limit })
|
||||
|
||||
console.log(chalk.cyan(`\nCommit History (last ${limit}):\n`))
|
||||
|
||||
for (const commit of history) {
|
||||
const date = new Date(commit.timestamp)
|
||||
const age = formatAge(Date.now() - commit.timestamp)
|
||||
|
||||
console.log(
|
||||
`${chalk.yellow(commit.hash.substring(0, 8))} ` +
|
||||
`${chalk.dim(commit.message)} ` +
|
||||
`${chalk.dim(`by ${commit.author} (${age} ago)`)}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log()
|
||||
|
||||
if (options.json) {
|
||||
formatOutput(history, options)
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(chalk.red('Error:'), error.message)
|
||||
if (options.verbose) console.error(error)
|
||||
process.exit(1)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Migrate storage format (one-time)
|
||||
*/
|
||||
async migrate(options: MigrateOptions) {
|
||||
let spinner: any = null
|
||||
try {
|
||||
// Interactive mode if paths not provided
|
||||
let fromPath = options.from
|
||||
let toPath = options.to
|
||||
|
||||
if (!fromPath || !toPath) {
|
||||
const answers = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'from',
|
||||
message: 'Old Brainy data path (v4.x):',
|
||||
default: './brainy-data',
|
||||
when: !fromPath
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'to',
|
||||
message: 'New Brainy data path (v5.0.0):',
|
||||
default: './brainy-data-v5',
|
||||
when: !toPath
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'backup',
|
||||
message: 'Create backup before migration?',
|
||||
default: true,
|
||||
when: options.backup === undefined
|
||||
}
|
||||
])
|
||||
|
||||
fromPath = fromPath || answers.from
|
||||
toPath = toPath || answers.to
|
||||
options.backup = options.backup ?? answers.backup
|
||||
}
|
||||
|
||||
// Verify old data exists
|
||||
if (!existsSync(resolve(fromPath!))) {
|
||||
throw new Error(`Old data path not found: ${fromPath}`)
|
||||
}
|
||||
|
||||
// Create backup if requested
|
||||
if (options.backup) {
|
||||
const backupPath = `${fromPath}.backup-${Date.now()}`
|
||||
spinner = ora(`Creating backup: ${backupPath}...`).start()
|
||||
const fs = await import('node:fs/promises')
|
||||
await fs.cp(fromPath, backupPath, { recursive: true, force: false })
|
||||
spinner.succeed(`Backup created: ${chalk.green(backupPath)}`)
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log(chalk.yellow('\n[DRY RUN] Migration plan:'))
|
||||
console.log(` From: ${fromPath}`)
|
||||
console.log(` To: ${toPath}`)
|
||||
console.log(` Backup: ${options.backup ? 'Yes' : 'No'}`)
|
||||
console.log()
|
||||
return
|
||||
}
|
||||
|
||||
spinner = ora('Migrating to v5.0.0 COW format...').start()
|
||||
|
||||
// Load old brain (v4.x)
|
||||
const oldBrain = new Brainy({
|
||||
storage: {
|
||||
type: 'filesystem',
|
||||
options: { path: fromPath }
|
||||
}
|
||||
})
|
||||
|
||||
await oldBrain.init()
|
||||
|
||||
// Create new brain
|
||||
const newBrain = new Brainy({
|
||||
storage: {
|
||||
type: 'filesystem',
|
||||
options: { path: toPath }
|
||||
}
|
||||
})
|
||||
|
||||
await newBrain.init()
|
||||
|
||||
// Migrate all entities
|
||||
const entities = await oldBrain.find({})
|
||||
let migrated = 0
|
||||
|
||||
spinner.text = `Migrating entities (0/${entities.length})...`
|
||||
|
||||
for (const result of entities) {
|
||||
// Add entity with proper params
|
||||
await newBrain.add({
|
||||
type: result.entity.type as any,
|
||||
data: result.entity.data
|
||||
})
|
||||
migrated++
|
||||
|
||||
if (migrated % 100 === 0) {
|
||||
spinner.text = `Migrating entities (${migrated}/${entities.length})...`
|
||||
}
|
||||
}
|
||||
|
||||
// Create initial commit (will be available after COW integration)
|
||||
// await newBrain.commit({
|
||||
// message: `Migrated from v4.x (${entities.length} entities)`,
|
||||
// author: 'migration-tool'
|
||||
// })
|
||||
|
||||
spinner.succeed(`Migration complete: ${chalk.green(migrated)} entities`)
|
||||
|
||||
console.log(`
|
||||
${chalk.cyan('Migration Summary:')}
|
||||
${chalk.dim('Old path:')} ${fromPath}
|
||||
${chalk.dim('New path:')} ${toPath}
|
||||
${chalk.dim('Entities:')} ${migrated}
|
||||
${chalk.dim('Format:')} v5.0.0 COW
|
||||
`.trim())
|
||||
|
||||
if (options.json) {
|
||||
formatOutput({
|
||||
from: fromPath,
|
||||
to: toPath,
|
||||
migrated
|
||||
}, options)
|
||||
}
|
||||
|
||||
await oldBrain.close()
|
||||
await newBrain.close()
|
||||
|
||||
} catch (error: any) {
|
||||
if (spinner) spinner.fail('Migration failed')
|
||||
console.error(chalk.red('Error:'), error.message)
|
||||
if (options.verbose) console.error(error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp age
|
||||
*/
|
||||
function formatAge(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) return `${days}d`
|
||||
if (hours > 0) return `${hours}h`
|
||||
if (minutes > 0) return `${minutes}m`
|
||||
return `${seconds}s`
|
||||
}
|
||||
227
src/cli/commands/snapshot.ts
Normal file
227
src/cli/commands/snapshot.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
/**
|
||||
* @module cli/commands/snapshot
|
||||
* @description Snapshot & time-travel CLI commands over the 8.0 Db API.
|
||||
*
|
||||
* - `snapshot <path>` — persist the current generation as a self-contained,
|
||||
* hard-link snapshot directory (`brain.now().persist(path)`).
|
||||
* - `restore <path>` — replace the store's ENTIRE state from a snapshot
|
||||
* (`brain.restore(path, { confirm: true })`, after an interactive confirm).
|
||||
* - `history` — the reified transaction log: one line per committed
|
||||
* `transact()` batch (generation, commit time, metadata).
|
||||
* - `generation` — the store's current generation watermark.
|
||||
*/
|
||||
|
||||
import chalk from 'chalk'
|
||||
import ora from 'ora'
|
||||
import inquirer from 'inquirer'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { Brainy } from '../../brainy.js'
|
||||
|
||||
/**
|
||||
* @description Shared CLI flags every snapshot command accepts (`--verbose`,
|
||||
* `--json`, `--pretty`), mirroring the other command modules.
|
||||
*/
|
||||
interface SnapshotCliOptions {
|
||||
verbose?: boolean
|
||||
json?: boolean
|
||||
pretty?: boolean
|
||||
}
|
||||
|
||||
let brainyInstance: Brainy | null = null
|
||||
|
||||
const getBrainy = (): Brainy => {
|
||||
if (!brainyInstance) {
|
||||
brainyInstance = new Brainy()
|
||||
}
|
||||
return brainyInstance
|
||||
}
|
||||
|
||||
const formatOutput = (data: unknown, options: SnapshotCliOptions): void => {
|
||||
if (options.json) {
|
||||
console.log(options.pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description The `snapshot`/`restore`/`history`/`generation` command
|
||||
* handlers registered in `src/cli/index.ts`.
|
||||
*/
|
||||
export const snapshotCommands = {
|
||||
/**
|
||||
* @description Persist the current generation as a self-contained snapshot
|
||||
* directory. Instant on same-device targets (hard links); the result opens
|
||||
* with `Brainy.load(path)` or restores wholesale via `brainy restore`.
|
||||
* @param path - Target directory (created; must be empty or absent).
|
||||
* @param options - Shared CLI flags.
|
||||
*/
|
||||
async snapshot(path: string, options: SnapshotCliOptions) {
|
||||
let spinner: ReturnType<typeof ora> | null = null
|
||||
try {
|
||||
const brain = getBrainy()
|
||||
await brain.init()
|
||||
|
||||
const target = resolve(path)
|
||||
spinner = ora(`Persisting snapshot to ${chalk.cyan(target)}...`).start()
|
||||
|
||||
const startTime = Date.now()
|
||||
const db = brain.now()
|
||||
try {
|
||||
await db.persist(target)
|
||||
} finally {
|
||||
await db.release()
|
||||
}
|
||||
const elapsed = Date.now() - startTime
|
||||
|
||||
spinner.succeed(
|
||||
`Snapshot persisted: ${chalk.green(target)} ${chalk.dim(`(generation ${db.generation}, ${elapsed}ms)`)}`
|
||||
)
|
||||
console.log(`
|
||||
${chalk.cyan('Snapshot:')}
|
||||
${chalk.dim('Path:')} ${target}
|
||||
${chalk.dim('Generation:')} ${db.generation}
|
||||
${chalk.dim('Open with:')} Brainy.load('${target}')
|
||||
${chalk.dim('Restore with:')} brainy restore ${target}
|
||||
`.trim())
|
||||
|
||||
formatOutput({ path: target, generation: db.generation, time: elapsed }, options)
|
||||
} catch (error: any) {
|
||||
if (spinner) spinner.fail('Snapshot failed')
|
||||
console.error(chalk.red('Error:'), error.message)
|
||||
if (options.verbose) console.error(error)
|
||||
process.exit(1)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @description Replace the store's ENTIRE current state from a snapshot
|
||||
* directory. Destructive — asks for interactive confirmation first
|
||||
* (skipped with `--force`).
|
||||
* @param path - Snapshot directory produced by `brainy snapshot <path>`.
|
||||
* @param options - Shared CLI flags plus `--force`.
|
||||
*/
|
||||
async restore(path: string, options: SnapshotCliOptions & { force?: boolean }) {
|
||||
let spinner: ReturnType<typeof ora> | null = null
|
||||
try {
|
||||
const target = resolve(path)
|
||||
if (!existsSync(target)) {
|
||||
throw new Error(`Snapshot directory not found: ${target}`)
|
||||
}
|
||||
|
||||
if (!options.force) {
|
||||
const { confirm } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirm',
|
||||
message: `Replace the store's ENTIRE current state with the snapshot at '${target}'? This cannot be undone.`,
|
||||
default: false
|
||||
}
|
||||
])
|
||||
if (!confirm) {
|
||||
console.log(chalk.yellow('Restore cancelled'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const brain = getBrainy()
|
||||
await brain.init()
|
||||
|
||||
spinner = ora(`Restoring from ${chalk.cyan(target)}...`).start()
|
||||
const startTime = Date.now()
|
||||
await brain.restore(target, { confirm: true })
|
||||
const elapsed = Date.now() - startTime
|
||||
|
||||
spinner.succeed(
|
||||
`Restored from ${chalk.green(target)} ${chalk.dim(`(now at generation ${brain.generation()}, ${elapsed}ms)`)}`
|
||||
)
|
||||
|
||||
formatOutput({ path: target, generation: brain.generation(), time: elapsed }, options)
|
||||
} catch (error: any) {
|
||||
if (spinner) spinner.fail('Restore failed')
|
||||
console.error(chalk.red('Error:'), error.message)
|
||||
if (options.verbose) console.error(error)
|
||||
process.exit(1)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @description Show the reified transaction log, newest first: one entry
|
||||
* per committed `transact()` batch with its generation, commit time, and
|
||||
* the `meta` the transaction was submitted with.
|
||||
* @param options - Shared CLI flags plus `--limit`.
|
||||
*/
|
||||
async history(options: SnapshotCliOptions & { limit?: string }) {
|
||||
try {
|
||||
const brain = getBrainy()
|
||||
await brain.init()
|
||||
|
||||
const limit = options.limit ? parseInt(options.limit, 10) : 10
|
||||
const entries = await brain.transactionLog({ limit })
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log(chalk.yellow('\nNo committed transactions yet (the log records transact() batches).\n'))
|
||||
formatOutput([], options)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(chalk.cyan(`\nTransaction Log (last ${entries.length}):\n`))
|
||||
for (const entry of entries) {
|
||||
const age = formatAge(Date.now() - entry.timestamp)
|
||||
const meta = entry.meta ? ` ${chalk.dim(JSON.stringify(entry.meta))}` : ''
|
||||
console.log(
|
||||
`${chalk.yellow(`g${entry.generation}`)} ` +
|
||||
`${chalk.dim(new Date(entry.timestamp).toISOString())} ` +
|
||||
`${chalk.dim(`(${age} ago)`)}` +
|
||||
meta
|
||||
)
|
||||
}
|
||||
console.log()
|
||||
|
||||
formatOutput(entries, options)
|
||||
} catch (error: any) {
|
||||
console.error(chalk.red('Error:'), error.message)
|
||||
if (options.verbose) console.error(error)
|
||||
process.exit(1)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @description Print the store's current generation watermark (bumped once
|
||||
* per committed `transact()` batch and once per single-operation write
|
||||
* batch).
|
||||
* @param options - Shared CLI flags.
|
||||
*/
|
||||
async generation(options: SnapshotCliOptions) {
|
||||
try {
|
||||
const brain = getBrainy()
|
||||
await brain.init()
|
||||
|
||||
const generation = brain.generation()
|
||||
console.log(`${chalk.cyan('Generation:')} ${chalk.green(String(generation))}`)
|
||||
|
||||
formatOutput({ generation }, options)
|
||||
} catch (error: any) {
|
||||
console.error(chalk.red('Error:'), error.message)
|
||||
if (options.verbose) console.error(error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Format a millisecond duration as a compact age string
|
||||
* (`3d` / `5h` / `12m` / `42s`).
|
||||
* @param ms - Elapsed milliseconds.
|
||||
* @returns The compact age string.
|
||||
*/
|
||||
function formatAge(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) return `${days}d`
|
||||
if (hours > 0) return `${hours}h`
|
||||
if (minutes > 0) return `${minutes}m`
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ import { storageCommands } from './commands/storage.js'
|
|||
import { nlpCommands } from './commands/nlp.js'
|
||||
import { insightsCommands } from './commands/insights.js'
|
||||
import { importCommands } from './commands/import.js'
|
||||
import { cowCommands } from './commands/cow.js'
|
||||
import { snapshotCommands } from './commands/snapshot.js'
|
||||
import { inspectCommands } from './commands/inspect.js'
|
||||
import { readFileSync } from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
|
@ -760,58 +760,30 @@ program
|
|||
.option('--iterations <n>', 'Number of iterations', '100')
|
||||
.action(utilityCommands.benchmark)
|
||||
|
||||
// ===== COW Commands - Instant Fork & Branching =====
|
||||
// ===== Snapshot & Time-Travel Commands - Db API =====
|
||||
|
||||
program
|
||||
.command('fork [name]')
|
||||
.description('🚀 Fork the brain (instant clone in 1-2 seconds)')
|
||||
.option('--message <msg>', 'Commit message')
|
||||
.option('--author <name>', 'Author name')
|
||||
.action(cowCommands.fork)
|
||||
.command('snapshot <path>')
|
||||
.description('📸 Persist the current generation as a self-contained snapshot (instant hard links)')
|
||||
.action(snapshotCommands.snapshot)
|
||||
|
||||
program
|
||||
.command('branch')
|
||||
.description('🌿 Branch management')
|
||||
.addCommand(
|
||||
new Command('list')
|
||||
.alias('ls')
|
||||
.description('List all branches/forks')
|
||||
.action((options) => {
|
||||
cowCommands.branchList(options)
|
||||
})
|
||||
)
|
||||
.addCommand(
|
||||
new Command('delete')
|
||||
.alias('rm')
|
||||
.argument('[name]', 'Branch name to delete')
|
||||
.description('Delete a branch/fork')
|
||||
.option('-f, --force', 'Skip confirmation')
|
||||
.action((name, options) => {
|
||||
cowCommands.branchDelete(name, options)
|
||||
})
|
||||
)
|
||||
|
||||
program
|
||||
.command('checkout [branch]')
|
||||
.alias('co')
|
||||
.description('Switch to a different branch')
|
||||
.action(cowCommands.checkout)
|
||||
.command('restore <path>')
|
||||
.description('Replace the ENTIRE store state from a snapshot (asks for confirmation)')
|
||||
.option('-f, --force', 'Skip confirmation')
|
||||
.action(snapshotCommands.restore)
|
||||
|
||||
program
|
||||
.command('history')
|
||||
.alias('log')
|
||||
.description('Show commit history')
|
||||
.option('-l, --limit <number>', 'Number of commits to show', '10')
|
||||
.action(cowCommands.history)
|
||||
.description('Show the transaction log (one entry per committed transact() batch)')
|
||||
.option('-l, --limit <number>', 'Number of entries to show', '10')
|
||||
.action(snapshotCommands.history)
|
||||
|
||||
program
|
||||
.command('migrate')
|
||||
.description('🔄 Migrate from v4.x to v5.0.0 (one-time)')
|
||||
.option('--from <path>', 'Old Brainy data path (v4.x)')
|
||||
.option('--to <path>', 'New Brainy data path (v5.0.0)')
|
||||
.option('--backup', 'Create backup before migration')
|
||||
.option('--dry-run', 'Show migration plan without executing')
|
||||
.action(cowCommands.migrate)
|
||||
.command('generation')
|
||||
.description('Show the store\'s current generation watermark')
|
||||
.action(snapshotCommands.generation)
|
||||
|
||||
// ===== Interactive Mode =====
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue