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().
169 lines
7.9 KiB
C#
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();
|
|
}
|
|
}
|