Skip to main content
Glama

COA Goldfish MCP

by anortham
JsonToSqliteMigrator.csโ€ข28.8 kB
using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using COA.Goldfish.McpServer.Services.Storage; using COA.Goldfish.McpServer.Models; using Microsoft.EntityFrameworkCore; namespace COA.Goldfish.Migration; /// <summary> /// Migrates Goldfish data from TypeScript JSON files to .NET SQLite database /// </summary> public class JsonToSqliteMigrator { private readonly ILogger<JsonToSqliteMigrator> _logger; private readonly string _jsonDataPath; private readonly string _sqliteConnectionString; public JsonToSqliteMigrator(ILogger<JsonToSqliteMigrator> logger, string jsonDataPath, string sqliteConnectionString) { _logger = logger; _jsonDataPath = jsonDataPath; _sqliteConnectionString = sqliteConnectionString; } /// <summary> /// Migrate all data from JSON to SQLite with full preservation /// </summary> public async Task<MigrationResult> MigrateAllAsync() { var result = new MigrationResult { StartTime = DateTime.UtcNow }; try { _logger.LogInformation("Starting migration from {JsonPath} to SQLite", _jsonDataPath); // Validate source data exists if (!Directory.Exists(_jsonDataPath)) { throw new DirectoryNotFoundException($"JSON data directory not found: {_jsonDataPath}"); } // Create backup of existing SQLite database if it exists await CreateBackupAsync(); // Initialize SQLite database var options = new DbContextOptionsBuilder<GoldfishDbContext>() .UseSqlite(_sqliteConnectionString) .Options; using var dbContext = new GoldfishDbContext(options); await dbContext.Database.EnsureCreatedAsync(); // Migrate each data type in dependency order, saving after each to avoid conflicts result.CheckpointsMigrated = await MigrateCheckpointsAsync(dbContext); await dbContext.SaveChangesAsync(); result.TodoListsMigrated = await MigrateTodoListsAsync(dbContext); await dbContext.SaveChangesAsync(); result.PlansMigrated = await MigratePlansAsync(dbContext); await dbContext.SaveChangesAsync(); result.MemoriesMigrated = await MigrateMemoriesToChronicleAsync(dbContext); await dbContext.SaveChangesAsync(); result.WorkspacesMigrated = await MigrateWorkspaceStatesAsync(dbContext); await dbContext.SaveChangesAsync(); // Validate migration integrity var validationResult = await ValidateMigrationIntegrityAsync(dbContext); result.ValidationErrors = validationResult.Errors; result.Success = validationResult.Success; result.EndTime = DateTime.UtcNow; result.Duration = result.EndTime.Value - result.StartTime; _logger.LogInformation("Migration completed successfully in {Duration}. " + "Migrated: {Checkpoints} checkpoints, {TodoLists} todo lists, {Plans} plans, {Memories} memories, {Workspaces} workspaces", result.Duration, result.CheckpointsMigrated, result.TodoListsMigrated, result.PlansMigrated, result.MemoriesMigrated, result.WorkspacesMigrated); return result; } catch (Exception ex) { _logger.LogError(ex, "Migration failed"); result.Success = false; result.ErrorMessage = ex.Message; result.EndTime = DateTime.UtcNow; result.Duration = result.EndTime.Value - result.StartTime; return result; } } /// <summary> /// Rollback migration by restoring from backup /// </summary> public Task<bool> RollbackAsync() { try { var backupPath = GetBackupPath(); if (!File.Exists(backupPath)) { _logger.LogWarning("No backup found to rollback from"); return Task.FromResult(false); } var dbPath = GetSqliteDatabasePath(); // Replace current database with backup File.Copy(backupPath, dbPath, overwrite: true); _logger.LogInformation("Successfully rolled back migration"); return Task.FromResult(true); } catch (Exception ex) { _logger.LogError(ex, "Rollback failed"); return Task.FromResult(false); } } #region Private Migration Methods private async Task<int> MigrateCheckpointsAsync(GoldfishDbContext dbContext) { // Search for checkpoints directories across all workspace subdirectories var checkpointFiles = new List<string>(); if (Directory.Exists(_jsonDataPath)) { foreach (var workspaceDir in Directory.GetDirectories(_jsonDataPath)) { var checkpointsPath = Path.Combine(workspaceDir, "checkpoints"); if (Directory.Exists(checkpointsPath)) { var files = Directory.GetFiles(checkpointsPath, "*.json", SearchOption.AllDirectories); checkpointFiles.AddRange(files); _logger.LogInformation("Found {Count} checkpoint files in {Path}", files.Length, checkpointsPath); } } } if (checkpointFiles.Count == 0) { _logger.LogInformation("No checkpoint files found in any workspace directories"); return 0; } var count = 0; foreach (var file in checkpointFiles) { try { var json = await File.ReadAllTextAsync(file); var typescriptCheckpoint = JsonSerializer.Deserialize<TypeScriptCheckpoint>(json); if (typescriptCheckpoint != null) { var checkpoint = ConvertCheckpoint(typescriptCheckpoint); // Check if this ID already exists to avoid duplicate key errors var existingCheckpoint = await dbContext.Checkpoints.FirstOrDefaultAsync(c => c.Id == checkpoint.Id); if (existingCheckpoint == null) { dbContext.Checkpoints.Add(checkpoint); count++; } else { _logger.LogWarning("Skipping duplicate checkpoint ID: {Id} from {File}", checkpoint.Id, file); } } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to migrate checkpoint file: {File}", file); } } _logger.LogInformation("Migrated {Count} checkpoints", count); return count; } private async Task<int> MigrateTodoListsAsync(GoldfishDbContext dbContext) { // Search for todos directories across all workspace subdirectories var todoFiles = new List<string>(); if (Directory.Exists(_jsonDataPath)) { foreach (var workspaceDir in Directory.GetDirectories(_jsonDataPath)) { var todosPath = Path.Combine(workspaceDir, "todos"); if (Directory.Exists(todosPath)) { var files = Directory.GetFiles(todosPath, "*.json", SearchOption.AllDirectories); todoFiles.AddRange(files); _logger.LogInformation("Found {Count} todo files in {Path}", files.Length, todosPath); } } } if (todoFiles.Count == 0) { _logger.LogInformation("No todo files found in any workspace directories"); return 0; } var count = 0; foreach (var file in todoFiles) { try { var json = await File.ReadAllTextAsync(file); var typescriptTodos = JsonSerializer.Deserialize<TypeScriptTodoList>(json); if (typescriptTodos != null) { var todoList = ConvertTodoList(typescriptTodos); // Check if this ID already exists to avoid duplicate key errors var existingTodoList = await dbContext.TodoLists.FirstOrDefaultAsync(t => t.Id == todoList.Id); if (existingTodoList == null) { dbContext.TodoLists.Add(todoList); count++; } else { _logger.LogWarning("Skipping duplicate TODO list ID: {Id} from {File}", todoList.Id, file); } } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to migrate todo file: {File}", file); } } _logger.LogInformation("Migrated {Count} todo lists", count); return count; } private async Task<int> MigratePlansAsync(GoldfishDbContext dbContext) { // Search for plans directories across all workspace subdirectories var planFiles = new List<string>(); if (Directory.Exists(_jsonDataPath)) { foreach (var workspaceDir in Directory.GetDirectories(_jsonDataPath)) { var plansPath = Path.Combine(workspaceDir, "plans"); if (Directory.Exists(plansPath)) { var files = Directory.GetFiles(plansPath, "*.json", SearchOption.AllDirectories); planFiles.AddRange(files); _logger.LogInformation("Found {Count} plan files in {Path}", files.Length, plansPath); } } } if (planFiles.Count == 0) { _logger.LogInformation("No plan files found in any workspace directories"); return 0; } var count = 0; foreach (var file in planFiles) { try { var json = await File.ReadAllTextAsync(file); var typescriptPlan = JsonSerializer.Deserialize<TypeScriptPlan>(json); if (typescriptPlan != null) { var plan = ConvertPlan(typescriptPlan); // Check if this ID already exists to avoid duplicate key errors var existingPlan = await dbContext.Plans.FirstOrDefaultAsync(p => p.Id == plan.Id); if (existingPlan == null) { dbContext.Plans.Add(plan); count++; } else { _logger.LogWarning("Skipping duplicate plan ID: {Id} from {File}", plan.Id, file); } } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to migrate plan file: {File}", file); } } _logger.LogInformation("Migrated {Count} plans", count); return count; } private async Task<int> MigrateMemoriesToChronicleAsync(GoldfishDbContext dbContext) { // Search for memories directories across all workspace subdirectories var memoryFiles = new List<string>(); if (Directory.Exists(_jsonDataPath)) { foreach (var workspaceDir in Directory.GetDirectories(_jsonDataPath)) { var memoriesPath = Path.Combine(workspaceDir, "memories"); if (Directory.Exists(memoriesPath)) { var files = Directory.GetFiles(memoriesPath, "*.json", SearchOption.AllDirectories); memoryFiles.AddRange(files); _logger.LogInformation("Found {Count} memory files in {Path}", files.Length, memoriesPath); } } } if (memoryFiles.Count == 0) { _logger.LogInformation("No memory files found in any workspace directories"); return 0; } var count = 0; foreach (var file in memoryFiles) { try { var json = await File.ReadAllTextAsync(file); var typescriptMemory = JsonSerializer.Deserialize<TypeScriptMemory>(json); if (typescriptMemory != null) { var chronicleEntry = ConvertMemoryToChronicle(typescriptMemory); dbContext.ChronicleEntries.Add(chronicleEntry); count++; } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to migrate memory file: {File}", file); } } _logger.LogInformation("Migrated {Count} memories to chronicle entries", count); return count; } private Task<int> MigrateWorkspaceStatesAsync(GoldfishDbContext dbContext) { // Workspace states might be implicit in TypeScript version // Create default workspace states for discovered workspaces var workspaces = new HashSet<string>(); // Find all workspace IDs from existing data foreach (var checkpoint in dbContext.Checkpoints) { workspaces.Add(checkpoint.WorkspaceId); } foreach (var todoList in dbContext.TodoLists) { workspaces.Add(todoList.WorkspaceId); } foreach (var plan in dbContext.Plans) { workspaces.Add(plan.WorkspaceId); } var count = 0; foreach (var workspaceId in workspaces) { if (!dbContext.WorkspaceStates.Any(ws => ws.WorkspaceId == workspaceId)) { var workspaceState = new WorkspaceState { WorkspaceId = workspaceId, LastActivity = DateTime.UtcNow }; // Try to find active plan and todo var activePlan = dbContext.Plans .Where(p => p.WorkspaceId == workspaceId && p.Status == PlanStatus.Active) .FirstOrDefault(); if (activePlan != null) { workspaceState.ActivePlanId = activePlan.Id; } var activeTodo = dbContext.TodoLists .Where(t => t.WorkspaceId == workspaceId && t.IsActive) .FirstOrDefault(); if (activeTodo != null) { workspaceState.ActiveTodoListId = activeTodo.Id; } dbContext.WorkspaceStates.Add(workspaceState); count++; } } _logger.LogInformation("Created {Count} workspace states", count); return Task.FromResult(count); } #endregion #region Conversion Methods private Checkpoint ConvertCheckpoint(TypeScriptCheckpoint ts) { // Handle TypeScript nested content structure var content = ts.Content; return new Checkpoint { Id = ts.Id, WorkspaceId = ts.Workspace, // TypeScript uses "workspace" Description = content?.Description ?? "", WorkContext = content?.WorkContext ?? "", ActiveFiles = content?.ActiveFiles ?? new List<string>(), Highlights = content?.Highlights ?? new List<string>(), GitBranch = content?.GitBranch ?? "", SessionId = ts.SessionId ?? content?.SessionId ?? "default-session", // Handle null SessionId CreatedAt = ParseDateTime(ts.Timestamp) ?? DateTime.UtcNow, // UpdatedAt is automatically managed by EF Core }; } private TodoList ConvertTodoList(TypeScriptTodoList ts) { var todoList = new TodoList { Id = ts.Id, WorkspaceId = ts.Workspace, // TypeScript uses "workspace" Title = ts.Title, Description = "", // TypeScript doesn't have description field IsActive = ts.Status != "archived", // TypeScript uses different status values CreatedAt = ParseDateTime(ts.CreatedAt) ?? DateTime.UtcNow, // UpdatedAt is automatically managed by EF Core, Items = new List<TodoItem>() }; if (ts.Items != null) { foreach (var tsItem in ts.Items) { var item = new TodoItem { Id = tsItem.Id, TodoListId = todoList.Id, Content = tsItem.Task, // TypeScript uses "task" not "content" Status = ConvertTodoStatus(tsItem.Status), Priority = ConvertTodoPriority(tsItem.Priority ?? "normal"), CreatedAt = ParseDateTime(tsItem.CreatedAt) ?? DateTime.UtcNow, // UpdatedAt is automatically managed by EF Core }; todoList.Items.Add(item); } } return todoList; } private Plan ConvertPlan(TypeScriptPlan ts) { return new Plan { Id = ts.Id, WorkspaceId = ts.Workspace, // TypeScript uses "workspace" Title = ts.Title, Description = ts.Description, Category = ConvertPlanCategory(ts.Category), Priority = ConvertPlanPriority(ts.Priority), Status = ConvertPlanStatus(ts.Status), Items = ts.Items, EstimatedEffort = ts.EstimatedEffort, CreatedAt = ParseDateTime(ts.CreatedAt) ?? DateTime.UtcNow, // UpdatedAt is automatically managed by EF Core }; } private ChronicleEntry ConvertMemoryToChronicle(TypeScriptMemory ts) { return new ChronicleEntry { Id = ts.Id, WorkspaceId = ts.Workspace, // TypeScript uses "workspace" Type = ConvertMemoryType(ts.Type), Description = ts.Content, Timestamp = ParseDateTime(ts.CreatedAt) ?? DateTime.UtcNow, // RelatedPlanId and RelatedTodoId not in TypeScript model }; } private TodoItemStatus ConvertTodoStatus(string? status) { return status?.ToLowerInvariant() switch { "done" or "completed" => TodoItemStatus.Done, "in_progress" or "active" => TodoItemStatus.Active, _ => TodoItemStatus.Pending }; } private TodoItemPriority ConvertTodoPriority(string? priority) { return priority?.ToLowerInvariant() switch { "high" or "urgent" => TodoItemPriority.High, "low" => TodoItemPriority.Low, _ => TodoItemPriority.Normal }; } private PlanStatus ConvertPlanStatus(string? status) { return status?.ToLowerInvariant() switch { "complete" or "completed" => PlanStatus.Complete, "abandoned" => PlanStatus.Abandoned, "active" => PlanStatus.Active, _ => PlanStatus.Draft }; } private ChronicleEntryType ConvertMemoryType(string? type) { return type?.ToLowerInvariant() switch { "decision" => ChronicleEntryType.Decision, "milestone" => ChronicleEntryType.Milestone, "issue" => ChronicleEntryType.Issue, "resolution" => ChronicleEntryType.Resolution, "discovery" => ChronicleEntryType.Discovery, _ => ChronicleEntryType.Note }; } private string ConvertPlanCategory(string? category) { return category?.ToLowerInvariant() switch { "feature" => "feature", "refactor" => "refactor", "research" => "research", "architecture" => "architecture", "bugfix" => "bugfix", "testing" => "maintenance", _ => "feature" }; } private string ConvertPlanPriority(string? priority) { return priority?.ToLowerInvariant() switch { "high" or "urgent" => "high", "critical" => "critical", "low" => "low", _ => "normal" }; } private DateTime? ParseDateTime(string? dateString) { if (string.IsNullOrEmpty(dateString)) return null; if (DateTime.TryParse(dateString, out var dateTime)) return dateTime; // Handle TypeScript ISO 8601 format specifically if (DateTimeOffset.TryParse(dateString, out var dateTimeOffset)) return dateTimeOffset.DateTime; return null; } #endregion #region Validation and Backup Methods private Task CreateBackupAsync() { var dbPath = GetSqliteDatabasePath(); if (File.Exists(dbPath)) { var backupPath = GetBackupPath(); File.Copy(dbPath, backupPath, overwrite: true); _logger.LogInformation("Created database backup at: {BackupPath}", backupPath); } return Task.CompletedTask; } private Task<ValidationResult> ValidateMigrationIntegrityAsync(GoldfishDbContext dbContext) { var result = new ValidationResult(); // Validate referential integrity var orphanedTodoItems = dbContext.TodoItems .Where(ti => !dbContext.TodoLists.Any(tl => tl.Id == ti.TodoListId)) .Count(); if (orphanedTodoItems > 0) { result.Errors.Add($"Found {orphanedTodoItems} orphaned TODO items"); } // Validate required fields var checkpointsWithoutWorkspace = dbContext.Checkpoints .Where(c => string.IsNullOrEmpty(c.WorkspaceId)) .Count(); if (checkpointsWithoutWorkspace > 0) { result.Errors.Add($"Found {checkpointsWithoutWorkspace} checkpoints without workspace ID"); } result.Success = result.Errors.Count == 0; return Task.FromResult(result); } private string GetSqliteDatabasePath() { // Extract database path from connection string if (_sqliteConnectionString.Contains("Data Source=")) { return _sqliteConnectionString.Split("Data Source=")[1].Split(';')[0]; } return "goldfish.db"; } private string GetBackupPath() { var dbPath = GetSqliteDatabasePath(); var directory = Path.GetDirectoryName(dbPath) ?? ""; var filename = Path.GetFileNameWithoutExtension(dbPath); var extension = Path.GetExtension(dbPath); return Path.Combine(directory, $"{filename}_backup_{DateTime.UtcNow:yyyyMMdd_HHmmss}{extension}"); } #endregion } #region TypeScript Data Models (for deserialization) public class TypeScriptCheckpoint { [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; [JsonPropertyName("workspace")] public string Workspace { get; set; } = string.Empty; // TypeScript uses "workspace" [JsonPropertyName("timestamp")] public string Timestamp { get; set; } = string.Empty; [JsonPropertyName("sessionId")] public string? SessionId { get; set; } [JsonPropertyName("type")] public string Type { get; set; } = string.Empty; [JsonPropertyName("content")] // Nested object in TypeScript public TypeScriptCheckpointContent? Content { get; set; } [JsonPropertyName("ttlHours")] public int? TtlHours { get; set; } [JsonPropertyName("tags")] public List<string> Tags { get; set; } = new(); [JsonPropertyName("metadata")] public Dictionary<string, object>? Metadata { get; set; } } public class TypeScriptCheckpointContent { [JsonPropertyName("description")] public string? Description { get; set; } [JsonPropertyName("highlights")] public List<string> Highlights { get; set; } = new(); [JsonPropertyName("gitBranch")] public string? GitBranch { get; set; } [JsonPropertyName("sessionId")] public string? SessionId { get; set; } [JsonPropertyName("activeFiles")] public List<string> ActiveFiles { get; set; } = new(); [JsonPropertyName("workContext")] public string? WorkContext { get; set; } } public class TypeScriptTodoList { [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; [JsonPropertyName("title")] public string Title { get; set; } = string.Empty; [JsonPropertyName("workspace")] public string Workspace { get; set; } = string.Empty; // TypeScript uses "workspace" [JsonPropertyName("items")] public List<TypeScriptTodoItem> Items { get; set; } = new(); [JsonPropertyName("createdAt")] public string? CreatedAt { get; set; } [JsonPropertyName("updatedAt")] public string? UpdatedAt { get; set; } [JsonPropertyName("status")] public string? Status { get; set; } // "active", "archived", etc. [JsonPropertyName("archivedAt")] public string? ArchivedAt { get; set; } } public class TypeScriptTodoItem { [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; [JsonPropertyName("task")] // TypeScript uses "task" not "content" public string Task { get; set; } = string.Empty; [JsonPropertyName("status")] public string Status { get; set; } = "pending"; [JsonPropertyName("createdAt")] public string? CreatedAt { get; set; } [JsonPropertyName("priority")] public string? Priority { get; set; } } public class TypeScriptPlan { [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; [JsonPropertyName("workspace")] public string Workspace { get; set; } = string.Empty; // TypeScript uses "workspace" [JsonPropertyName("title")] public string Title { get; set; } = string.Empty; [JsonPropertyName("description")] public string Description { get; set; } = string.Empty; [JsonPropertyName("category")] public string Category { get; set; } = string.Empty; [JsonPropertyName("status")] public string Status { get; set; } = string.Empty; [JsonPropertyName("priority")] public string Priority { get; set; } = string.Empty; [JsonPropertyName("items")] public List<string> Items { get; set; } = new(); [JsonPropertyName("estimatedEffort")] public string? EstimatedEffort { get; set; } [JsonPropertyName("createdAt")] public string? CreatedAt { get; set; } [JsonPropertyName("updatedAt")] public string? UpdatedAt { get; set; } } public class TypeScriptMemory { [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; [JsonPropertyName("workspace")] public string Workspace { get; set; } = string.Empty; // TypeScript uses "workspace" [JsonPropertyName("content")] public string Content { get; set; } = string.Empty; [JsonPropertyName("type")] public string Type { get; set; } = string.Empty; [JsonPropertyName("context")] public string? Context { get; set; } [JsonPropertyName("tags")] public List<string> Tags { get; set; } = new(); [JsonPropertyName("createdAt")] public string? CreatedAt { get; set; } [JsonPropertyName("updatedAt")] public string? UpdatedAt { get; set; } } #endregion #region Result Models public class MigrationResult { public bool Success { get; set; } = true; public string? ErrorMessage { get; set; } public DateTime StartTime { get; set; } public DateTime? EndTime { get; set; } public TimeSpan? Duration { get; set; } public int CheckpointsMigrated { get; set; } public int TodoListsMigrated { get; set; } public int PlansMigrated { get; set; } public int MemoriesMigrated { get; set; } public int WorkspacesMigrated { get; set; } public List<string> ValidationErrors { get; set; } = new(); } public class ValidationResult { public bool Success { get; set; } = true; public List<string> Errors { get; set; } = new(); } #endregion

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/anortham/coa-goldfish-mcp'

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