Files
ClaudeDo/src/ClaudeDo.Data/ClaudeDoDbContext.cs
T
Mika Kuns c25732cff8 feat(claude-do): depends_on als Menge statt Einzelfeld
Ein Task kann jetzt auf beliebig viele Vorgaenger warten. Die Einzelspalte
tasks.depends_on_task_id wird durch die Beziehungstabelle task_dependencies
ersetzt (Composite-PK, beide FKs ON DELETE CASCADE -- das reproduziert das
alte ON DELETE SET NULL: geloeschter Vorgaenger = Kante weg = Task wieder
pickbar). BlockedByTaskId bleibt unberuehrt.

- Migration AddTaskDependencies kopiert jede bestehende depends_on_task_id
  verlustfrei in die Tabelle, bevor die Spalte fallengelassen wird
- QueuePicker gatet per doppelt negiertem NOT EXISTS: kein Task mit 0 Kanten
  wird blockiert, alle Kanten muessen done sein, eine Kante auf einen
  verschwundenen Task blockiert weiter
- SetDependsOnAsync ersetzt die ganze Menge; Zykluspruefung ist jetzt eine
  BFS ueber den Graphen, faengt also auch A->B->C->A
- blockedReason nennt alle offenen Vorgaenger (External/TaskBlocking, von
  ExternalMcpService und TaskWaitMcpTools gemeinsam genutzt)
- add_task/update_task nehmen dependsOnTaskIds; die alte Skalarform bleibt
  als Ein-Element-Menge inkl. ""-Clear-Sentinel gueltig
- UI: Fan-in-Zeile bleibt flach und listet alle Vorgaenger im After-Label,
  die lineare Kette rendert unveraendert
2026-09-02 15:08:05 +02:00

170 lines
8.0 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<TaskDependencyEntity> TaskDependencies => Set<TaskDependencyEntity>();
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();
}
}