9.7 KiB
EF Core Migration Design
Replace the raw ADO.NET / Microsoft.Data.Sqlite data layer with Entity Framework Core and LINQ queries.
Motivation
- Developer ergonomics: raw SQL is tedious to write and maintain; LINQ enables faster iteration.
- Maintainability: the ad-hoc migration approach (ALTER TABLE with error-code catching) and manual DBNull/enum mapping are a liability as the schema grows. EF Core provides proper migration versioning, value converters, and change tracking.
Decision Summary
| Decision | Choice |
|---|---|
| Approach | Big bang — rewrite all 6 repositories at once |
| Migration strategy | Fresh start — EF Core owns the schema, drop schema.sql |
| DbContext sharing | Single shared ClaudeDoDbContext in ClaudeDo.Data |
| Configuration style | Fluent API only, clean POCO models |
| Atomic queue claim | Kept as FromSqlRaw — not expressible in LINQ |
1. DbContext and Entity Configuration
ClaudeDoDbContext
A single ClaudeDoDbContext in ClaudeDo.Data with DbSets for all entities:
public class ClaudeDoDbContext : DbContext
{
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
public DbSet<ListEntity> Lists => Set<ListEntity>();
public DbSet<TagEntity> Tags => Set<TagEntity>();
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();
}
Entity-to-Table Mapping
| Entity | Table | Key | Notes |
|---|---|---|---|
TaskEntity |
tasks |
Id (TEXT) |
Nav to List, Tags, Worktree, Runs, Subtasks |
ListEntity |
lists |
Id (TEXT) |
Nav to Tasks, Tags, Config |
TagEntity |
tags |
Id (INTEGER auto) |
Nav to Lists, Tasks (both M:N) |
ListConfigEntity |
list_config |
ListId (TEXT) |
1:1 owned by List |
WorktreeEntity |
worktrees |
TaskId (TEXT) |
1:1 owned by Task |
TaskRunEntity |
task_runs |
Id (TEXT) |
FK to Task |
SubtaskEntity |
subtasks |
Id (TEXT) |
FK to Task |
Navigation Properties Added to Models
// TaskEntity gains:
public ListEntity List { get; set; }
public WorktreeEntity? Worktree { get; set; }
public ICollection<TagEntity> Tags { get; set; }
public ICollection<TaskRunEntity> Runs { get; set; }
public ICollection<SubtaskEntity> Subtasks { get; set; }
// ListEntity gains:
public ListConfigEntity? Config { get; set; }
public ICollection<TaskEntity> Tasks { get; set; }
public ICollection<TagEntity> Tags { get; set; }
// TagEntity gains:
public ICollection<ListEntity> Lists { get; set; }
public ICollection<TaskEntity> Tasks { get; set; }
Enum Handling
EF Core ValueConverter<TEnum, string> for TaskStatus and WorktreeState, storing the same lowercase strings ("manual", "active", etc.) for database compatibility. The ToDb/FromDb methods in repositories are removed.
Junction Tables
list_tags and task_tags are configured as implicit join tables via .UsingEntity() in Fluent API — no explicit junction entity classes needed.
Fluent Configuration
Each entity gets its own IEntityTypeConfiguration<T> class in a Configuration/ folder within ClaudeDo.Data.
2. Migration Strategy
Fresh Start
schema.sqlandSchemaInitializerare deleted.- An initial EF Core migration (
InitialCreate) is generated from the DbContext model, producing the full schema (all 8 tables, indexes, foreign keys, check constraints). - EF's
__EFMigrationsHistorytable tracks applied migrations.
Startup
Both App and Worker call context.Database.Migrate() at startup instead of SchemaInitializer.Apply(). This is idempotent.
Existing Database Compatibility
For users who already have a database created by schema.sql, the initial migration must handle the schema already existing. On startup, if the lists table exists but __EFMigrationsHistory does not, insert the initial migration record into __EFMigrationsHistory so EF skips it.
Seed Data
The "agent" and "manual" tags move into OnModelCreating via HasData():
modelBuilder.Entity<TagEntity>().HasData(
new TagEntity { Id = 1, Name = "agent" },
new TagEntity { Id = 2, Name = "manual" });
Ad-hoc Migrations Removed
The 3 manual ALTER TABLE statements (model, system_prompt, agent_path on tasks) become part of the initial migration since they're already in the model. The manual ApplyMigrations() method is deleted.
3. Repository Rewrite
All 6 repositories are rewritten to use ClaudeDoDbContext and LINQ.
Per-Repository Changes
| Repository | After EF Core |
|---|---|
TagRepository |
LINQ queries. GetOrCreateAsync uses FirstOrDefaultAsync + Add + SaveChangesAsync. Static SqliteConnection overload removed. |
SubtaskRepository |
Straightforward LINQ CRUD, .OrderBy(s => s.OrderNum). |
WorktreeRepository |
LINQ CRUD. State update becomes property set + SaveChangesAsync. |
ListRepository |
LINQ CRUD. Tag management via .Tags navigation property. Config upsert via List.Config navigation. |
TaskRunRepository |
LINQ CRUD. Latest = .OrderByDescending(r => r.RunNumber).FirstOrDefaultAsync(). |
TaskRepository |
See special cases below. |
TaskRepository Special Cases
Atomic queue claim (GetNextQueuedAgentTaskAsync): kept as FromSqlRaw / ExecuteSqlRawAsync. The UPDATE ... WHERE id = (SELECT ...) RETURNING is not expressible in LINQ and the atomicity guarantee matters.
Effective tags (GetEffectiveTagsAsync): LINQ via navigation properties:
var taskTags = context.Tasks
.Where(t => t.Id == taskId)
.SelectMany(t => t.Tags);
var listTags = context.Tasks
.Where(t => t.Id == taskId)
.SelectMany(t => t.List.Tags);
return await taskTags.Union(listTags).Distinct().ToListAsync(ct);
FlipAllRunningToFailed: EF Core 7+ bulk update:
await context.Tasks
.Where(t => t.Status == TaskStatus.Running)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Failed), ct);
Status transitions (MarkRunningAsync, MarkDoneAsync, MarkFailedAsync): property updates + SaveChangesAsync.
Removed Code
SqliteConnectionFactory.csSchemaInitializer.csschema/schema.sql- All
ToDb/FromDbenum mapping methods - All manual
DBNull.Valuehandling BindTaskhelper methods
4. Package Changes and DI Registration
ClaudeDo.Data.csproj
- Remove:
Microsoft.Data.Sqlite - Remove: embedded resource for
schema.sql - Add:
Microsoft.EntityFrameworkCore.Sqlite - Add:
Microsoft.EntityFrameworkCore.Design(PrivateAssets="all")
ClaudeDo.Worker.Tests.csproj
- Remove:
Microsoft.Data.Sqlite - Add:
Microsoft.EntityFrameworkCore.Sqlite
App DI (Program.cs)
// Replace SqliteConnectionFactory + singleton repos with:
sc.AddDbContextFactory<ClaudeDoDbContext>(opt =>
opt.UseSqlite($"Data Source={dbPath}"));
sc.AddScoped<ClaudeDoDbContext>(sp =>
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
sc.AddScoped<ListRepository>();
sc.AddScoped<TaskRepository>();
sc.AddScoped<SubtaskRepository>();
sc.AddScoped<TagRepository>();
sc.AddScoped<WorktreeRepository>();
sc.AddScoped<TaskRunRepository>();
// Migrate at startup:
using var initScope = services.CreateScope();
initScope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>().Database.Migrate();
ViewModels are singletons that currently take repositories as constructor parameters. Since repositories become scoped, ViewModels switch to taking IDbContextFactory<ClaudeDoDbContext> and create a fresh context (+ repositories) per operation. Each ViewModel method that touches data does: using var context = _factory.CreateDbContext(); then constructs or resolves the needed repository with that context. This mirrors the current connection-per-call pattern.
Worker DI (Program.cs)
builder.Services.AddDbContext<ClaudeDoDbContext>(opt =>
opt.UseSqlite($"Data Source={cfg.DbPath}"));
builder.Services.AddScoped<ListRepository>();
builder.Services.AddScoped<TaskRepository>();
builder.Services.AddScoped<SubtaskRepository>();
builder.Services.AddScoped<TagRepository>();
builder.Services.AddScoped<WorktreeRepository>();
builder.Services.AddScoped<TaskRunRepository>();
// Migrate at startup after build:
using var scope = app.Services.CreateScope();
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>().Database.Migrate();
Worker has request scopes via SignalR hub invocations, so scoped registration works naturally.
5. Test Infrastructure
DbFixture
DbFixture is rewritten as an EF Core fixture:
- Creates a temp SQLite file per test class.
- Builds
DbContextOptions<ClaudeDoDbContext>withUseSqlite. - Calls
context.Database.Migrate()to apply the schema (also tests that migrations work). - Exposes a
CreateContext()method so each test gets a fresh context instance (avoids change-tracker bleed).
Tests construct repositories by passing in a fresh context from the fixture.
No mocking — tests keep hitting real SQLite, same philosophy as today.
6. Risk and Mitigation
| Risk | Mitigation |
|---|---|
| Big-bang rewrite touches nearly every file in ClaudeDo.Data | Existing tests are the safety net — all must pass after migration |
| Existing databases with schema from schema.sql | Compatibility shim: detect existing tables, mark initial migration as applied |
| Atomic queue claim semantics change | Kept as raw SQL via FromSqlRaw |
| Scoped lifetime vs. singleton ViewModels | IDbContextFactory provides on-demand contexts |
| EF change tracker overhead vs. raw ADO.NET | Negligible for this workload size; use AsNoTracking() for read-only queries |