using System.Data.Common; using ClaudeDo.Data.Models; using ClaudeDo.Data.Seeding; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace ClaudeDo.Data; public class ClaudeDoDbContext : DbContext { // Runs PRAGMA foreign_keys=ON on every EF-managed connection open so FK // enforcement is active for all IDbContextFactory-created contexts, not // just the single context used in MigrateAndConfigure. private sealed class SqliteForeignKeyInterceptor : DbConnectionInterceptor { internal static readonly SqliteForeignKeyInterceptor Instance = new(); public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData) => Apply(connection); public override Task ConnectionOpenedAsync(DbConnection connection, ConnectionEndEventData eventData, CancellationToken cancellationToken = default) { Apply(connection); return Task.CompletedTask; } private static void Apply(DbConnection connection) { using var cmd = connection.CreateCommand(); cmd.CommandText = "PRAGMA foreign_keys=ON;"; cmd.ExecuteNonQuery(); } } public ClaudeDoDbContext(DbContextOptions options) : base(options) { } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.AddInterceptors(SqliteForeignKeyInterceptor.Instance); public DbSet Tasks => Set(); public DbSet Lists => Set(); public DbSet ListConfigs => Set(); public DbSet Worktrees => Set(); public DbSet TaskRuns => Set(); public DbSet Subtasks => Set(); public DbSet TaskAttachments => Set(); public DbSet AppSettings => Set(); public DbSet PrimeSchedules => Set(); public DbSet DailyNotes => Set(); public DbSet WeekReports => Set(); public DbSet SessionSkills => Set(); private static readonly ValueConverter UtcConverter = new(v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc)); private static readonly ValueConverter UtcNullableConverter = new(v => v, v => v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : null); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfigurationsFromAssembly(typeof(ClaudeDoDbContext).Assembly); foreach (var entityType in modelBuilder.Model.GetEntityTypes()) foreach (var property in entityType.GetProperties()) { if (property.ClrType == typeof(DateTime) && property.GetValueConverter() == null) property.SetValueConverter(UtcConverter); else if (property.ClrType == typeof(DateTime?) && property.GetValueConverter() == null) property.SetValueConverter(UtcNullableConverter); } } // The 40 incremental migrations were squashed into a single InitialCreate on 2026-08-26. // Any database that already carries the full pre-squash schema has to be re-stamped to the // squashed id, or Migrate() would run CREATE TABLE over live tables. private const string SquashedMigrationId = "20260826094154_InitialCreate"; private const string LastPreSquashMigrationId = "20260825063230_AddPrimeActionKind"; /// /// Applies EF Core migrations and sets WAL mode. Safe for both fresh and existing databases. /// Existing databases are baselined onto instead of being /// re-created: ones with tables but no __EFMigrationsHistory (created by the old schema.sql), /// and ones whose history still lists the pre-squash chain. A database that stopped *mid*-chain /// can no longer be upgraded — the incremental migrations are gone — and throws instead of /// being silently stamped onto a schema it doesn't have. /// public static void MigrateAndConfigure(ClaudeDoDbContext db) { var conn = db.Database.GetDbConnection(); try { conn.Open(); // Set WAL FIRST, before migrations — prevents write-lock contention // when UI and Worker start simultaneously. using (var walCmd = conn.CreateCommand()) { walCmd.CommandText = "PRAGMA journal_mode=wal;"; walCmd.ExecuteNonQuery(); } // Enable FK enforcement — SQLite defaults to OFF per connection. using (var fkCmd = conn.CreateCommand()) { fkCmd.CommandText = "PRAGMA foreign_keys=ON;"; fkCmd.ExecuteNonQuery(); } using (var cmd = conn.CreateCommand()) { cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='lists'"; var hasLists = Convert.ToInt64(cmd.ExecuteScalar()) > 0; cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='__EFMigrationsHistory'"; var hasHistory = Convert.ToInt64(cmd.ExecuteScalar()) > 0; if (hasLists && !hasHistory) { // Pre-EF database: tables, no history at all. cmd.CommandText = $""" CREATE TABLE "__EFMigrationsHistory" ( "MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY, "ProductVersion" TEXT NOT NULL ); INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") VALUES ('{SquashedMigrationId}', '8.0.11'); """; cmd.ExecuteNonQuery(); } else if (hasLists && hasHistory) { cmd.CommandText = $"""SELECT COUNT(*) FROM "__EFMigrationsHistory" WHERE "MigrationId" = '{SquashedMigrationId}'"""; var alreadySquashed = Convert.ToInt64(cmd.ExecuteScalar()) > 0; if (!alreadySquashed) { cmd.CommandText = """SELECT MAX("MigrationId") FROM "__EFMigrationsHistory" """; var newest = cmd.ExecuteScalar() as string ?? ""; if (newest != LastPreSquashMigrationId) throw new InvalidOperationException( $"This database was last migrated by '{newest}', but the migration history was " + $"squashed into '{SquashedMigrationId}' and the incremental migrations no longer " + "exist. Reinstall ClaudeDo against a fresh database (or restore a backup taken " + $"after '{LastPreSquashMigrationId}')."); cmd.CommandText = $""" DELETE FROM "__EFMigrationsHistory"; INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") VALUES ('{SquashedMigrationId}', '8.0.11'); """; cmd.ExecuteNonQuery(); } } } } finally { conn.Close(); } db.Database.Migrate(); DefaultListsSeeder.SeedAsync(db).GetAwaiter().GetResult(); } }