Skip to main content
Glama
version-history-service.ts7.31 kB
import type { RepositoryFactory, PlanRepository } from '../repositories/interfaces.js'; import type { Entity, VersionHistory, VersionSnapshot, VersionDiff, } from '../entities/types.js'; export type EntityType = 'requirement' | 'solution' | 'decision' | 'phase' | 'artifact'; export interface GetHistoryInput { planId: string; entityId: string; entityType: EntityType; limit?: number; offset?: number; } export interface DiffInput { planId: string; entityId: string; entityType: EntityType; version1: number; version2: number; currentEntityData?: Entity; // Optional: current entity data if version2 is the current version currentVersion?: number; // Optional: current version number } export class VersionHistoryService { private readonly planRepo: PlanRepository; constructor(private readonly repositoryFactory: RepositoryFactory) { this.planRepo = repositoryFactory.createPlanRepository(); } /** * Check if version history is enabled for the plan */ public async isHistoryEnabled(planId: string): Promise<boolean> { const manifest = await this.planRepo.loadManifest(planId); return manifest.enableHistory === true; } /** * Get the maximum history depth for the plan */ public async getMaxHistoryDepth(planId: string): Promise<number> { const manifest = await this.planRepo.loadManifest(planId); return manifest.maxHistoryDepth ?? 0; // 0 means unlimited } /** * Save a version snapshot */ public async saveVersion( planId: string, entityId: string, entityType: EntityType, data: Entity, version: number, author?: string, changeNote?: string ): Promise<void> { const enabled = await this.isHistoryEnabled(planId); if (!enabled) { return; // History disabled, skip } const maxDepth = await this.getMaxHistoryDepth(planId); const history = await this.loadHistory(planId, entityId, entityType); const snapshot: VersionSnapshot = { version, data, timestamp: new Date().toISOString(), author, changeNote, }; // Add new snapshot history.versions.push(snapshot); history.currentVersion = version; // Sprint 7 fix: Update currentVersion to latest // Apply rotation if maxDepth is set and exceeded if (maxDepth > 0 && history.versions.length > maxDepth) { const excess = history.versions.length - maxDepth; history.versions.splice(0, excess); // Remove oldest versions } // Update total AFTER rotation to reflect actual count history.total = history.versions.length; await this.saveHistory(planId, entityId, entityType, history); } /** * Get version history for an entity * Note: Can retrieve existing history even if history is currently disabled */ public async getHistory(input: GetHistoryInput): Promise<VersionHistory> { // Validate offset const offset = input.offset ?? 0; if (offset < 0) { throw new Error('offset must be non-negative'); } const history = await this.loadHistory( input.planId, input.entityId, input.entityType ); // Reverse to show newest first (reverse chronological order) const reversedVersions = [...history.versions].reverse(); // Apply pagination const limit = input.limit ?? 100; const paginatedVersions = reversedVersions.slice(offset, offset + limit); const hasMore = offset + paginatedVersions.length < history.total; return { ...history, versions: paginatedVersions, total: history.total, hasMore, }; } /** * Compare two versions and generate diff * Note: Can compare versions even if history is currently disabled */ public async diff(input: DiffInput): Promise<VersionDiff> { const history = await this.loadHistory( input.planId, input.entityId, input.entityType ); let v1 = history.versions.find((v) => v.version === input.version1); let v2 = history.versions.find((v) => v.version === input.version2); // If v1 not found in history and current entity data is provided if (v1 === undefined && input.currentEntityData !== undefined && input.currentVersion === input.version1) { v1 = { version: input.version1, data: input.currentEntityData, timestamp: new Date().toISOString(), }; } // If v2 not found in history and current entity data is provided if (v2 === undefined && input.currentEntityData !== undefined && input.currentVersion === input.version2) { v2 = { version: input.version2, data: input.currentEntityData, timestamp: new Date().toISOString(), }; } if (!v1) { throw new Error(`Version ${String(input.version1)} not found`); } if (!v2) { throw new Error(`Version ${String(input.version2)} not found`); } const changes: Record<string, { from: unknown; to: unknown; changed: boolean }> = {}; // Metadata fields to exclude from diff (these always change on updates) // Note: lockVersion removed as it only exists on PlanManifest, not on entities const excludeFields = new Set(['updatedAt', 'version', 'createdAt']); // Get all unique keys from both versions const allKeys = new Set([ ...Object.keys(v1.data), ...Object.keys(v2.data), ]); for (const key of allKeys) { // Skip metadata fields if (excludeFields.has(key)) { continue; } const fromValue = (v1.data as unknown as Record<string, unknown>)[key]; const toValue = (v2.data as unknown as Record<string, unknown>)[key]; // Deep comparison for objects and arrays const changed = JSON.stringify(fromValue) !== JSON.stringify(toValue); // Only include changed fields in the result if (changed) { changes[key] = { from: fromValue, to: toValue, changed: true, }; } } return { entityId: input.entityId, entityType: input.entityType, version1: { version: input.version1, timestamp: v1.timestamp, }, version2: { version: input.version2, timestamp: v2.timestamp, }, changes, }; } /** * Load version history from storage */ private async loadHistory( planId: string, entityId: string, entityType: EntityType ): Promise<VersionHistory> { const history = await this.planRepo.loadVersionHistory(planId, entityType, entityId); if (history !== null) { return history; } // History file doesn't exist, return empty return { entityId, entityType, currentVersion: 1, versions: [], total: 0, }; } /** * Save version history to storage */ private async saveHistory( planId: string, entityId: string, entityType: EntityType, history: VersionHistory ): Promise<void> { await this.planRepo.saveVersionHistory(planId, entityType, entityId, history); } /** * Delete version history for an entity */ public async deleteHistory( planId: string, entityId: string, entityType: EntityType ): Promise<void> { await this.planRepo.deleteVersionHistory(planId, entityType, entityId); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/cppmyjob/cpp-mcp-planner'

If you have feedback or need assistance with the MCP directory API, please join our Discord server