Add temporal versioning for node history tracking (Milestone 1)
Enable time-travel queries and history viewing by creating immutable version records on every node update. Includes database schema changes, store functions, MCP tools, and CLI commands for viewing history, comparing versions, and restoring to previous states.
This commit is contained in:
90
src/cli/commands/diff.ts
Normal file
90
src/cli/commands/diff.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { findNodeByPrefix, diffVersions, getNodeAtTime, getNodeHistory } from '../../core/store';
|
||||
|
||||
export const diffCommand = new Command('diff')
|
||||
.argument('<id>', 'Node ID (or prefix)')
|
||||
.option('--v1 <n>', 'First version number')
|
||||
.option('--v2 <n>', 'Second version number')
|
||||
.option('--from <date>', 'Start date (ISO format or timestamp)')
|
||||
.option('--to <date>', 'End date (ISO format or timestamp)')
|
||||
.option('--format <fmt>', 'Output format: text or json', 'text')
|
||||
.description('Compare two versions of a node')
|
||||
.action(async (idRaw: string, opts) => {
|
||||
const node = findNodeByPrefix(idRaw);
|
||||
if (!node) {
|
||||
console.error(chalk.red(`Node not found: ${idRaw}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let v1: number;
|
||||
let v2: number;
|
||||
|
||||
if (opts.v1 && opts.v2) {
|
||||
v1 = parseInt(opts.v1);
|
||||
v2 = parseInt(opts.v2);
|
||||
} else if (opts.from && opts.to) {
|
||||
// Parse dates
|
||||
const fromTs = Date.parse(opts.from);
|
||||
const toTs = Date.parse(opts.to);
|
||||
|
||||
if (isNaN(fromTs) || isNaN(toTs)) {
|
||||
console.error(chalk.red('Invalid date format. Use ISO format (e.g., 2024-01-01) or timestamp.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fromNode = getNodeAtTime(node.id, fromTs);
|
||||
const toNode = getNodeAtTime(node.id, toTs);
|
||||
|
||||
if (!fromNode || !toNode) {
|
||||
console.error(chalk.red('Could not find versions for the specified dates.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
v1 = fromNode.version;
|
||||
v2 = toNode.version;
|
||||
} else {
|
||||
// Default: compare latest two versions
|
||||
const history = getNodeHistory(node.id);
|
||||
if (history.length < 2) {
|
||||
console.log(chalk.yellow('Not enough versions to compare.'));
|
||||
return;
|
||||
}
|
||||
v1 = history[1].version; // Second latest
|
||||
v2 = history[0].version; // Latest
|
||||
}
|
||||
|
||||
const diff = diffVersions(node.id, v1, v2);
|
||||
if (!diff) {
|
||||
console.error(chalk.red('One or both versions not found.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (opts.format === 'json') {
|
||||
console.log(JSON.stringify(diff, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold.cyan(`[${node.kind}] ${node.title}`));
|
||||
console.log(`ID: ${node.id}`);
|
||||
console.log(`Comparing: ${chalk.cyan(`v${v1}`)} -> ${chalk.cyan(`v${v2}`)}`);
|
||||
console.log('');
|
||||
|
||||
if (diff.changes.length === 0) {
|
||||
console.log(chalk.green('No changes between versions.'));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const change of diff.changes) {
|
||||
console.log(chalk.bold(`${change.field}:`));
|
||||
|
||||
const oldStr = typeof change.old === 'string' ? change.old : JSON.stringify(change.old);
|
||||
const newStr = typeof change.new === 'string' ? change.new : JSON.stringify(change.new);
|
||||
|
||||
console.log(chalk.red(` - ${oldStr}`));
|
||||
console.log(chalk.green(` + ${newStr}`));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log(chalk.dim(`${diff.changes.length} field(s) changed`));
|
||||
});
|
||||
48
src/cli/commands/history.ts
Normal file
48
src/cli/commands/history.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { findNodeByPrefix, getNodeHistory } from '../../core/store';
|
||||
|
||||
export const historyCommand = new Command('history')
|
||||
.argument('<id>', 'Node ID (or prefix)')
|
||||
.option('--format <fmt>', 'Output format: text or json', 'text')
|
||||
.description('Show version history for a node')
|
||||
.action(async (idRaw: string, opts) => {
|
||||
const node = findNodeByPrefix(idRaw);
|
||||
if (!node) {
|
||||
console.error(chalk.red(`Node not found: ${idRaw}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const history = getNodeHistory(node.id);
|
||||
|
||||
if (history.length === 0) {
|
||||
console.log(chalk.yellow('No version history found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.format === 'json') {
|
||||
console.log(JSON.stringify({ nodeId: node.id, title: node.title, versions: history }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold.cyan(`[${node.kind}] ${node.title}`));
|
||||
console.log(`ID: ${node.id}`);
|
||||
console.log(chalk.bold('\nVersion History:'));
|
||||
console.log('');
|
||||
|
||||
for (const v of history) {
|
||||
const validFrom = new Date(v.validFrom).toLocaleString();
|
||||
const validUntil = v.validUntil ? new Date(v.validUntil).toLocaleString() : chalk.green('current');
|
||||
const createdBy = chalk.dim(`(${v.createdBy})`);
|
||||
|
||||
console.log(` ${chalk.cyan(`v${v.version}`)} ${createdBy}`);
|
||||
console.log(` ${chalk.dim('From:')} ${validFrom}`);
|
||||
console.log(` ${chalk.dim('Until:')} ${validUntil}`);
|
||||
console.log(` ${chalk.dim('Title:')} ${v.title}`);
|
||||
if (v.status) console.log(` ${chalk.dim('Status:')} ${v.status}`);
|
||||
if (v.tags.length) console.log(` ${chalk.dim('Tags:')} ${v.tags.join(', ')}`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log(chalk.dim(`${history.length} version(s)`));
|
||||
});
|
||||
65
src/cli/commands/restore.ts
Normal file
65
src/cli/commands/restore.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { findNodeByPrefix, restoreVersion, getNodeHistory, getCurrentVersion } from '../../core/store';
|
||||
|
||||
export const restoreCommand = new Command('restore')
|
||||
.argument('<id>', 'Node ID (or prefix)')
|
||||
.option('-v, --to-version <n>', 'Version number to restore')
|
||||
.option('--format <fmt>', 'Output format: text or json', 'text')
|
||||
.description('Restore a node to a previous version (creates new version)')
|
||||
.action(async (idRaw: string, opts) => {
|
||||
const node = findNodeByPrefix(idRaw);
|
||||
if (!node) {
|
||||
console.error(chalk.red(`Node not found: ${idRaw}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!opts.toVersion) {
|
||||
// Show available versions and ask user to specify
|
||||
const history = getNodeHistory(node.id);
|
||||
console.log(chalk.bold.cyan(`[${node.kind}] ${node.title}`));
|
||||
console.log(`ID: ${node.id}`);
|
||||
console.log(`Current version: ${chalk.cyan(`v${getCurrentVersion(node.id)}`)}`);
|
||||
console.log('');
|
||||
console.log(chalk.bold('Available versions:'));
|
||||
for (const v of history) {
|
||||
const validFrom = new Date(v.validFrom).toLocaleString();
|
||||
const current = v.validUntil === null ? chalk.green(' (current)') : '';
|
||||
console.log(` ${chalk.cyan(`v${v.version}`)} - ${validFrom} - ${v.title}${current}`);
|
||||
}
|
||||
console.log('');
|
||||
console.log(chalk.yellow('Use --to-version <n> or -v <n> to restore to a specific version.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const targetVersion = parseInt(opts.toVersion);
|
||||
const currentVersion = getCurrentVersion(node.id);
|
||||
|
||||
if (targetVersion === currentVersion) {
|
||||
console.log(chalk.yellow('Cannot restore to the current version.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const restored = await restoreVersion(node.id, targetVersion, 'restore');
|
||||
if (!restored) {
|
||||
console.error(chalk.red(`Version ${targetVersion} not found.`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (opts.format === 'json') {
|
||||
console.log(JSON.stringify({ message: `Restored to version ${targetVersion}`, node: { ...restored, embedding: undefined } }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.green(`Restored node to version ${targetVersion}`));
|
||||
console.log(`New version: ${chalk.cyan(`v${getCurrentVersion(node.id)}`)}`);
|
||||
console.log('');
|
||||
console.log(chalk.bold.cyan(`[${restored.kind}] ${restored.title}`));
|
||||
console.log(`ID: ${restored.id}`);
|
||||
if (restored.status) console.log(`Status: ${restored.status}`);
|
||||
if (restored.tags.length) console.log(`Tags: ${restored.tags.join(', ')}`);
|
||||
if (restored.content) {
|
||||
console.log('');
|
||||
console.log(restored.content);
|
||||
}
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { findNodeByPrefix } from '../../core/store';
|
||||
import { findNodeByPrefix, getNodeAtTime, getCurrentVersion } from '../../core/store';
|
||||
import { getConnections } from '../../core/graph';
|
||||
|
||||
export const showCommand = new Command('show')
|
||||
.argument('<id>', 'Node ID (or prefix)')
|
||||
.option('--format <fmt>', 'Output format: text or json', 'text')
|
||||
.option('--at <timestamp>', 'Show node at a specific point in time (ISO date or timestamp)')
|
||||
.description('Show a node and its connections')
|
||||
.action(async (idRaw: string, opts) => {
|
||||
const node = findNodeByPrefix(idRaw);
|
||||
@@ -14,15 +15,56 @@ export const showCommand = new Command('show')
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// If --at is specified, show historical state
|
||||
if (opts.at) {
|
||||
const ts = isNaN(Number(opts.at)) ? Date.parse(opts.at) : Number(opts.at);
|
||||
if (isNaN(ts)) {
|
||||
console.error(chalk.red('Invalid timestamp format. Use ISO date (e.g., 2024-01-01) or Unix timestamp.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const historical = getNodeAtTime(node.id, ts);
|
||||
if (!historical) {
|
||||
console.error(chalk.red('No version found for the specified time.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (opts.format === 'json') {
|
||||
console.log(JSON.stringify(historical, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.dim(`Viewing historical state at: ${new Date(ts).toLocaleString()}`));
|
||||
console.log('');
|
||||
console.log(chalk.bold.cyan(`[${historical.kind}] ${historical.title}`));
|
||||
console.log(`ID: ${historical.id}`);
|
||||
console.log(`Version: v${historical.version}`);
|
||||
if (historical.status) console.log(`Status: ${historical.status}`);
|
||||
if (historical.tags.length) console.log(`Tags: ${historical.tags.join(', ')}`);
|
||||
console.log(`Valid: ${new Date(historical.validFrom).toLocaleString()} - ${historical.validUntil ? new Date(historical.validUntil).toLocaleString() : 'current'}`);
|
||||
if (historical.content) console.log(`\n${historical.content}`);
|
||||
|
||||
// Render structured sections
|
||||
if (historical.metadata?.sections && Array.isArray(historical.metadata.sections)) {
|
||||
for (const sec of historical.metadata.sections) {
|
||||
console.log(`\n${chalk.bold(`-- ${sec.label} --`)}`);
|
||||
if (sec.body) console.log(sec.body);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const conns = getConnections(node.id);
|
||||
const version = getCurrentVersion(node.id);
|
||||
|
||||
if (opts.format === 'json') {
|
||||
console.log(JSON.stringify({ ...node, embedding: undefined, connections: conns }, null, 2));
|
||||
console.log(JSON.stringify({ ...node, embedding: undefined, version, connections: conns }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold.cyan(`[${node.kind}] ${node.title}`));
|
||||
console.log(`ID: ${node.id}`);
|
||||
console.log(`Version: v${version}`);
|
||||
if (node.status) console.log(`Status: ${node.status}`);
|
||||
if (node.tags.length) console.log(`Tags: ${node.tags.join(', ')}`);
|
||||
console.log(`Created: ${new Date(node.createdAt).toLocaleString()}`);
|
||||
|
||||
@@ -11,6 +11,9 @@ import { graphCommand } from './commands/graph';
|
||||
import { serveCommand } from './commands/serve';
|
||||
import { decayCommand } from './commands/decay';
|
||||
import { childrenCommand } from './commands/children';
|
||||
import { historyCommand } from './commands/history';
|
||||
import { diffCommand } from './commands/diff';
|
||||
import { restoreCommand } from './commands/restore';
|
||||
import { closeDb } from '../core/db';
|
||||
|
||||
const program = new Command();
|
||||
@@ -31,6 +34,9 @@ program.addCommand(graphCommand);
|
||||
program.addCommand(serveCommand);
|
||||
program.addCommand(decayCommand);
|
||||
program.addCommand(childrenCommand);
|
||||
program.addCommand(historyCommand);
|
||||
program.addCommand(diffCommand);
|
||||
program.addCommand(restoreCommand);
|
||||
|
||||
program.hook('postAction', () => {
|
||||
closeDb();
|
||||
|
||||
Reference in New Issue
Block a user