Files
ClaudeDo/src/ClaudeDo.Data/ClaudeDoDbContext.cs
T
mika kuns 39d8241360 refactor(data): squash the 40 migrations into one InitialCreate
The incremental chain was ~31k lines of generated Designer snapshots for a
schema no shipped database steps through anymore. MigrateAndConfigure now
baselines an existing DB onto the squashed id (pre-EF, or history ending at
AddPrimeActionKind) and throws with a reinstall message for a mid-chain DB.
MigrationBaselineTests covers all three paths with a real Migrate().
2026-08-26 13:55:30 +02:00

169 lines
7.9 KiB
C#

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<ClaudeDoDbContext> options) : base(options) { }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.AddInterceptors(SqliteForeignKeyInterceptor.Instance);
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
public DbSet<ListEntity> Lists => Set<ListEntity>();
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();
public DbSet<TaskAttachmentEntity> TaskAttachments => Set<TaskAttachmentEntity>();
public DbSet<AppSettingsEntity> AppSettings => Set<AppSettingsEntity>();
public DbSet<PrimeScheduleEntity> PrimeSchedules => Set<PrimeScheduleEntity>();
public DbSet<DailyNoteEntity> DailyNotes => Set<DailyNoteEntity>();
public DbSet<WeekReportEntity> WeekReports => Set<WeekReportEntity>();
public DbSet<SessionSkillEntity> SessionSkills => Set<SessionSkillEntity>();
private static readonly ValueConverter<DateTime, DateTime> UtcConverter =
new(v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
private static readonly ValueConverter<DateTime?, DateTime?> 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";
/// <summary>
/// Applies EF Core migrations and sets WAL mode. Safe for both fresh and existing databases.
/// Existing databases are baselined onto <see cref="SquashedMigrationId"/> 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.
/// </summary>
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();
}
}