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();
|
||||
|
||||
@@ -32,6 +32,21 @@ CREATE TABLE IF NOT EXISTS node_tags (
|
||||
PRIMARY KEY (node_id, tag)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS node_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
status TEXT,
|
||||
tags TEXT DEFAULT '[]',
|
||||
metadata TEXT DEFAULT '{}',
|
||||
valid_from INTEGER NOT NULL,
|
||||
valid_until INTEGER,
|
||||
created_by TEXT DEFAULT 'user',
|
||||
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_status ON nodes(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_created ON nodes(created_at DESC);
|
||||
@@ -40,6 +55,9 @@ CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_tags_tag ON node_tags(tag);
|
||||
CREATE INDEX IF NOT EXISTS idx_versions_node ON node_versions(node_id, version);
|
||||
CREATE INDEX IF NOT EXISTS idx_versions_time ON node_versions(valid_from, valid_until);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_versions_node_version ON node_versions(node_id, version);
|
||||
`;
|
||||
|
||||
let _db: Database.Database | null = null;
|
||||
@@ -68,6 +86,41 @@ export function getDb(): Database.Database {
|
||||
_db.exec('UPDATE nodes SET last_accessed_at = updated_at WHERE last_accessed_at IS NULL');
|
||||
}
|
||||
|
||||
// Migration: add version column to nodes table
|
||||
if (!cols.some((c: any) => c.name === 'version')) {
|
||||
_db.exec('ALTER TABLE nodes ADD COLUMN version INTEGER DEFAULT 1');
|
||||
_db.exec('UPDATE nodes SET version = 1 WHERE version IS NULL');
|
||||
}
|
||||
|
||||
// Migration: backfill node_versions for existing nodes without versions
|
||||
const existingWithoutVersion = _db.prepare(`
|
||||
SELECT * FROM nodes WHERE id NOT IN (SELECT DISTINCT node_id FROM node_versions)
|
||||
`).all() as any[];
|
||||
|
||||
if (existingWithoutVersion.length > 0) {
|
||||
const insertVersion = _db.prepare(`
|
||||
INSERT INTO node_versions (id, node_id, version, title, content, status, tags, metadata, valid_from, valid_until, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const node of existingWithoutVersion) {
|
||||
const versionId = require('crypto').randomUUID();
|
||||
insertVersion.run(
|
||||
versionId,
|
||||
node.id,
|
||||
1,
|
||||
node.title,
|
||||
node.content,
|
||||
node.status,
|
||||
node.tags,
|
||||
node.metadata,
|
||||
node.created_at,
|
||||
null,
|
||||
'migration'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return _db;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID as uuid } from 'crypto';
|
||||
import { getDb } from './db';
|
||||
import { Node, Edge, AddNodeInput, UpdateNodeInput, ListOptions, QueryOptions, SearchResult, EdgeType } from '../types';
|
||||
import { Node, Edge, AddNodeInput, UpdateNodeInput, ListOptions, QueryOptions, SearchResult, EdgeType, NodeVersion, HistoricalNode, NodeDiff } from '../types';
|
||||
import { hybridSearch, deserializeEmbedding } from './search/index';
|
||||
import { getEmbedding } from './search/ollama';
|
||||
|
||||
@@ -43,21 +43,44 @@ export async function addNode(input: AddNodeInput): Promise<Node> {
|
||||
// Try to get embedding
|
||||
const embedding = await getEmbedding(`${input.title} ${content}`);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO nodes (id, kind, title, content, status, tags, metadata, embedding, created_at, updated_at, last_accessed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id, input.kind, input.title, content, input.status ?? null,
|
||||
JSON.stringify(tags), JSON.stringify(metadata),
|
||||
embedding ? serializeEmbedding(embedding) : null,
|
||||
now, now, now
|
||||
);
|
||||
const transaction = db.transaction(() => {
|
||||
db.prepare(`
|
||||
INSERT INTO nodes (id, kind, title, content, status, tags, metadata, embedding, created_at, updated_at, last_accessed_at, version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id, input.kind, input.title, content, input.status ?? null,
|
||||
JSON.stringify(tags), JSON.stringify(metadata),
|
||||
embedding ? serializeEmbedding(embedding) : null,
|
||||
now, now, now, 1
|
||||
);
|
||||
|
||||
// Insert tags
|
||||
const insertTag = db.prepare('INSERT OR IGNORE INTO node_tags (node_id, tag) VALUES (?, ?)');
|
||||
for (const tag of tags) {
|
||||
insertTag.run(id, tag);
|
||||
}
|
||||
// Insert tags
|
||||
const insertTag = db.prepare('INSERT OR IGNORE INTO node_tags (node_id, tag) VALUES (?, ?)');
|
||||
for (const tag of tags) {
|
||||
insertTag.run(id, tag);
|
||||
}
|
||||
|
||||
// Create initial version record
|
||||
const versionId = uuid();
|
||||
db.prepare(`
|
||||
INSERT INTO node_versions (id, node_id, version, title, content, status, tags, metadata, valid_from, valid_until, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
versionId,
|
||||
id,
|
||||
1,
|
||||
input.title,
|
||||
content,
|
||||
input.status ?? null,
|
||||
JSON.stringify(tags),
|
||||
JSON.stringify(metadata),
|
||||
now,
|
||||
null,
|
||||
'user'
|
||||
);
|
||||
});
|
||||
|
||||
transaction();
|
||||
|
||||
notifyDirty();
|
||||
return {
|
||||
@@ -114,46 +137,88 @@ export function listNodes(options: ListOptions = {}): Node[] {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export async function updateNode(id: string, input: UpdateNodeInput): Promise<Node | null> {
|
||||
export async function updateNode(id: string, input: UpdateNodeInput, createdBy: string = 'user'): Promise<Node | null> {
|
||||
const db = getDb();
|
||||
const existing = getNode(id);
|
||||
if (!existing) return null;
|
||||
// Get existing node without updating last_accessed_at
|
||||
const existingRow = db.prepare('SELECT * FROM nodes WHERE id = ?').get(id) as any;
|
||||
if (!existingRow) return null;
|
||||
const existing = rowToNode(existingRow);
|
||||
|
||||
const now = Date.now();
|
||||
const sets: string[] = ['updated_at = ?'];
|
||||
const params: any[] = [now];
|
||||
|
||||
if (input.title !== undefined) { sets.push('title = ?'); params.push(input.title); }
|
||||
if (input.content !== undefined) { sets.push('content = ?'); params.push(input.content); }
|
||||
if (input.status !== undefined) { sets.push('status = ?'); params.push(input.status); }
|
||||
if (input.isStale !== undefined) { sets.push('is_stale = ?'); params.push(input.isStale ? 1 : 0); }
|
||||
if (input.tags !== undefined) { sets.push('tags = ?'); params.push(JSON.stringify(input.tags)); }
|
||||
if (input.metadata !== undefined) {
|
||||
const merged = { ...existing.metadata, ...input.metadata };
|
||||
sets.push('metadata = ?');
|
||||
params.push(JSON.stringify(merged));
|
||||
}
|
||||
// Get current version number
|
||||
const currentVersion = existingRow.version ?? 1;
|
||||
const newVersion = currentVersion + 1;
|
||||
|
||||
// Re-embed if title or content changed
|
||||
// Create version snapshot in a transaction
|
||||
const transaction = db.transaction(() => {
|
||||
// Close out the current version by setting valid_until
|
||||
db.prepare(`
|
||||
UPDATE node_versions SET valid_until = ? WHERE node_id = ? AND valid_until IS NULL
|
||||
`).run(now, id);
|
||||
|
||||
// Insert new version record with the NEW state (after update)
|
||||
const versionId = uuid();
|
||||
const newTitle = input.title ?? existing.title;
|
||||
const newContent = input.content ?? existing.content;
|
||||
const newStatus = input.status !== undefined ? input.status : existing.status;
|
||||
const newTags = input.tags ?? existing.tags;
|
||||
const newMetadata = input.metadata !== undefined ? { ...existing.metadata, ...input.metadata } : existing.metadata;
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO node_versions (id, node_id, version, title, content, status, tags, metadata, valid_from, valid_until, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
versionId,
|
||||
id,
|
||||
newVersion,
|
||||
newTitle,
|
||||
newContent,
|
||||
newStatus ?? null,
|
||||
JSON.stringify(newTags),
|
||||
JSON.stringify(newMetadata),
|
||||
now,
|
||||
null,
|
||||
createdBy
|
||||
);
|
||||
|
||||
// Build the update query
|
||||
const sets: string[] = ['updated_at = ?', 'version = ?'];
|
||||
const params: any[] = [now, newVersion];
|
||||
|
||||
if (input.title !== undefined) { sets.push('title = ?'); params.push(input.title); }
|
||||
if (input.content !== undefined) { sets.push('content = ?'); params.push(input.content); }
|
||||
if (input.status !== undefined) { sets.push('status = ?'); params.push(input.status); }
|
||||
if (input.isStale !== undefined) { sets.push('is_stale = ?'); params.push(input.isStale ? 1 : 0); }
|
||||
if (input.tags !== undefined) { sets.push('tags = ?'); params.push(JSON.stringify(input.tags)); }
|
||||
if (input.metadata !== undefined) {
|
||||
const merged = { ...existing.metadata, ...input.metadata };
|
||||
sets.push('metadata = ?');
|
||||
params.push(JSON.stringify(merged));
|
||||
}
|
||||
|
||||
params.push(id);
|
||||
db.prepare(`UPDATE nodes SET ${sets.join(', ')} WHERE id = ?`).run(...params);
|
||||
|
||||
// Update tags if changed
|
||||
if (input.tags !== undefined) {
|
||||
db.prepare('DELETE FROM node_tags WHERE node_id = ?').run(id);
|
||||
const insertTag = db.prepare('INSERT OR IGNORE INTO node_tags (node_id, tag) VALUES (?, ?)');
|
||||
for (const tag of input.tags) {
|
||||
insertTag.run(id, tag);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
transaction();
|
||||
|
||||
// Re-embed if title or content changed (outside transaction since it's async)
|
||||
if (input.title !== undefined || input.content !== undefined) {
|
||||
const newTitle = input.title ?? existing.title;
|
||||
const newContent = input.content ?? existing.content;
|
||||
const embedding = await getEmbedding(`${newTitle} ${newContent}`);
|
||||
if (embedding) {
|
||||
sets.push('embedding = ?');
|
||||
params.push(serializeEmbedding(embedding));
|
||||
}
|
||||
}
|
||||
|
||||
params.push(id);
|
||||
db.prepare(`UPDATE nodes SET ${sets.join(', ')} WHERE id = ?`).run(...params);
|
||||
|
||||
// Update tags if changed
|
||||
if (input.tags !== undefined) {
|
||||
db.prepare('DELETE FROM node_tags WHERE node_id = ?').run(id);
|
||||
const insertTag = db.prepare('INSERT OR IGNORE INTO node_tags (node_id, tag) VALUES (?, ?)');
|
||||
for (const tag of input.tags) {
|
||||
insertTag.run(id, tag);
|
||||
db.prepare('UPDATE nodes SET embedding = ? WHERE id = ?').run(serializeEmbedding(embedding), id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,3 +260,114 @@ export async function query(text: string, options: QueryOptions = {}): Promise<S
|
||||
const nodes = listNodes({ kind: options.kind, tags: options.tags, includeStale: options.includeStale });
|
||||
return hybridSearch(nodes, text, options);
|
||||
}
|
||||
|
||||
// Version tracking functions
|
||||
|
||||
function rowToNodeVersion(row: any): NodeVersion {
|
||||
return {
|
||||
id: row.id,
|
||||
nodeId: row.node_id,
|
||||
version: row.version,
|
||||
title: row.title,
|
||||
content: row.content,
|
||||
status: row.status ?? undefined,
|
||||
tags: JSON.parse(row.tags || '[]'),
|
||||
metadata: JSON.parse(row.metadata || '{}'),
|
||||
validFrom: row.valid_from,
|
||||
validUntil: row.valid_until ?? null,
|
||||
createdBy: row.created_by,
|
||||
};
|
||||
}
|
||||
|
||||
function rowToHistoricalNode(row: any, nodeRow: any): HistoricalNode {
|
||||
return {
|
||||
id: nodeRow.id,
|
||||
kind: nodeRow.kind,
|
||||
title: row.title,
|
||||
content: row.content,
|
||||
status: row.status ?? undefined,
|
||||
tags: JSON.parse(row.tags || '[]'),
|
||||
metadata: JSON.parse(row.metadata || '{}'),
|
||||
version: row.version,
|
||||
validFrom: row.valid_from,
|
||||
validUntil: row.valid_until ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getNodeHistory(id: string): NodeVersion[] {
|
||||
const db = getDb();
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM node_versions WHERE node_id = ? ORDER BY version DESC
|
||||
`).all(id) as any[];
|
||||
return rows.map(rowToNodeVersion);
|
||||
}
|
||||
|
||||
export function getNodeAtTime(id: string, timestamp: number): HistoricalNode | null {
|
||||
const db = getDb();
|
||||
const nodeRow = db.prepare('SELECT * FROM nodes WHERE id = ?').get(id) as any;
|
||||
if (!nodeRow) return null;
|
||||
|
||||
const versionRow = db.prepare(`
|
||||
SELECT * FROM node_versions
|
||||
WHERE node_id = ? AND valid_from <= ? AND (valid_until IS NULL OR valid_until > ?)
|
||||
ORDER BY version DESC LIMIT 1
|
||||
`).get(id, timestamp, timestamp) as any;
|
||||
|
||||
if (!versionRow) return null;
|
||||
return rowToHistoricalNode(versionRow, nodeRow);
|
||||
}
|
||||
|
||||
export function getNodeVersion(id: string, version: number): HistoricalNode | null {
|
||||
const db = getDb();
|
||||
const nodeRow = db.prepare('SELECT * FROM nodes WHERE id = ?').get(id) as any;
|
||||
if (!nodeRow) return null;
|
||||
|
||||
const versionRow = db.prepare(`
|
||||
SELECT * FROM node_versions WHERE node_id = ? AND version = ?
|
||||
`).get(id, version) as any;
|
||||
|
||||
if (!versionRow) return null;
|
||||
return rowToHistoricalNode(versionRow, nodeRow);
|
||||
}
|
||||
|
||||
export function diffVersions(id: string, v1: number, v2: number): NodeDiff | null {
|
||||
const version1 = getNodeVersion(id, v1);
|
||||
const version2 = getNodeVersion(id, v2);
|
||||
|
||||
if (!version1 || !version2) return null;
|
||||
|
||||
const changes: NodeDiff['changes'] = [];
|
||||
const fieldsToCompare: (keyof HistoricalNode)[] = ['title', 'content', 'status', 'tags', 'metadata'];
|
||||
|
||||
for (const field of fieldsToCompare) {
|
||||
const oldVal = version1[field];
|
||||
const newVal = version2[field];
|
||||
const oldStr = JSON.stringify(oldVal);
|
||||
const newStr = JSON.stringify(newVal);
|
||||
if (oldStr !== newStr) {
|
||||
changes.push({ field, old: oldVal, new: newVal });
|
||||
}
|
||||
}
|
||||
|
||||
return { nodeId: id, v1, v2, changes };
|
||||
}
|
||||
|
||||
export async function restoreVersion(id: string, version: number, createdBy: string = 'restore'): Promise<Node | null> {
|
||||
const historical = getNodeVersion(id, version);
|
||||
if (!historical) return null;
|
||||
|
||||
// updateNode will handle creating a new version
|
||||
return updateNode(id, {
|
||||
title: historical.title,
|
||||
content: historical.content,
|
||||
status: historical.status,
|
||||
tags: historical.tags,
|
||||
metadata: historical.metadata,
|
||||
}, createdBy);
|
||||
}
|
||||
|
||||
export function getCurrentVersion(id: string): number {
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT version FROM nodes WHERE id = ?').get(id) as any;
|
||||
return row?.version ?? 1;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod/v3';
|
||||
import { query, listNodes, getNode, findNodeByPrefix, addNode, addEdge, removeNode, removeEdge, updateNode } from '../core/store';
|
||||
import { query, listNodes, getNode, findNodeByPrefix, addNode, addEdge, removeNode, removeEdge, updateNode, getNodeHistory, getNodeAtTime, getNodeVersion, diffVersions, restoreVersion } from '../core/store';
|
||||
import { getConnections, getEdgesByNode } from '../core/graph';
|
||||
import { cosineSimilarity } from '../core/search/vector';
|
||||
import { getDb } from '../core/db';
|
||||
@@ -400,6 +400,102 @@ server.tool(
|
||||
}
|
||||
);
|
||||
|
||||
// --- memory_history ---
|
||||
server.tool(
|
||||
'memory_history',
|
||||
'Get version history for a node',
|
||||
{
|
||||
id: z.string().describe('Node ID or prefix'),
|
||||
},
|
||||
async ({ id }) => {
|
||||
const node = getNode(id) ?? findNodeByPrefix(id);
|
||||
if (!node) {
|
||||
return { content: [{ type: 'text' as const, text: serialize({ error: 'Node not found' }) }], isError: true };
|
||||
}
|
||||
const history = getNodeHistory(node.id);
|
||||
return { content: [{ type: 'text' as const, text: serialize({ nodeId: node.id, title: node.title, versions: history }) }] };
|
||||
}
|
||||
);
|
||||
|
||||
// --- memory_show_at ---
|
||||
server.tool(
|
||||
'memory_show_at',
|
||||
'Show node at a specific point in time',
|
||||
{
|
||||
id: z.string().describe('Node ID or prefix'),
|
||||
timestamp: z.union([z.number(), z.string()]).describe('Unix ms or ISO date string'),
|
||||
},
|
||||
async ({ id, timestamp }) => {
|
||||
const node = getNode(id) ?? findNodeByPrefix(id);
|
||||
if (!node) {
|
||||
return { content: [{ type: 'text' as const, text: serialize({ error: 'Node not found' }) }], isError: true };
|
||||
}
|
||||
|
||||
// Parse timestamp
|
||||
let ts: number;
|
||||
if (typeof timestamp === 'number') {
|
||||
ts = timestamp;
|
||||
} else {
|
||||
const parsed = Date.parse(timestamp);
|
||||
if (isNaN(parsed)) {
|
||||
return { content: [{ type: 'text' as const, text: serialize({ error: 'Invalid timestamp format' }) }], isError: true };
|
||||
}
|
||||
ts = parsed;
|
||||
}
|
||||
|
||||
const historical = getNodeAtTime(node.id, ts);
|
||||
if (!historical) {
|
||||
return { content: [{ type: 'text' as const, text: serialize({ error: 'No version found for the specified time' }) }], isError: true };
|
||||
}
|
||||
return { content: [{ type: 'text' as const, text: serialize(historical) }] };
|
||||
}
|
||||
);
|
||||
|
||||
// --- memory_diff ---
|
||||
server.tool(
|
||||
'memory_diff',
|
||||
'Compare two versions of a node',
|
||||
{
|
||||
id: z.string().describe('Node ID or prefix'),
|
||||
v1: z.number().describe('First version number'),
|
||||
v2: z.number().describe('Second version number'),
|
||||
},
|
||||
async ({ id, v1, v2 }) => {
|
||||
const node = getNode(id) ?? findNodeByPrefix(id);
|
||||
if (!node) {
|
||||
return { content: [{ type: 'text' as const, text: serialize({ error: 'Node not found' }) }], isError: true };
|
||||
}
|
||||
|
||||
const diff = diffVersions(node.id, v1, v2);
|
||||
if (!diff) {
|
||||
return { content: [{ type: 'text' as const, text: serialize({ error: 'One or both versions not found' }) }], isError: true };
|
||||
}
|
||||
return { content: [{ type: 'text' as const, text: serialize(diff) }] };
|
||||
}
|
||||
);
|
||||
|
||||
// --- memory_restore ---
|
||||
server.tool(
|
||||
'memory_restore',
|
||||
'Restore a node to a previous version (creates new version)',
|
||||
{
|
||||
id: z.string().describe('Node ID or prefix'),
|
||||
version: z.number().describe('Version number to restore'),
|
||||
},
|
||||
async ({ id, version }) => {
|
||||
const node = getNode(id) ?? findNodeByPrefix(id);
|
||||
if (!node) {
|
||||
return { content: [{ type: 'text' as const, text: serialize({ error: 'Node not found' }) }], isError: true };
|
||||
}
|
||||
|
||||
const restored = await restoreVersion(node.id, version);
|
||||
if (!restored) {
|
||||
return { content: [{ type: 'text' as const, text: serialize({ error: 'Version not found' }) }], isError: true };
|
||||
}
|
||||
return { content: [{ type: 'text' as const, text: serialize({ message: `Restored to version ${version}`, node: restored }) }] };
|
||||
}
|
||||
);
|
||||
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
39
src/types.ts
39
src/types.ts
@@ -71,3 +71,42 @@ export interface ListOptions {
|
||||
limit?: number;
|
||||
includeStale?: boolean;
|
||||
}
|
||||
|
||||
// Version tracking types
|
||||
export interface NodeVersion {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
version: number;
|
||||
title: string;
|
||||
content: string;
|
||||
status?: string;
|
||||
tags: string[];
|
||||
metadata: Record<string, any>;
|
||||
validFrom: number;
|
||||
validUntil: number | null;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export interface HistoricalNode {
|
||||
id: string;
|
||||
kind: NodeKind;
|
||||
title: string;
|
||||
content: string;
|
||||
status?: string;
|
||||
tags: string[];
|
||||
metadata: Record<string, any>;
|
||||
version: number;
|
||||
validFrom: number;
|
||||
validUntil: number | null;
|
||||
}
|
||||
|
||||
export interface NodeDiff {
|
||||
nodeId: string;
|
||||
v1: number;
|
||||
v2: number;
|
||||
changes: {
|
||||
field: string;
|
||||
old: any;
|
||||
new: any;
|
||||
}[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user