fix(data): harden FK pragma per-connection and seed concurrency

- Add SqliteForeignKeyInterceptor (DbConnectionInterceptor) registered via
  OnConfiguring so every IDbContextFactory-created context runs
  PRAGMA foreign_keys=ON, not only the MigrateAndConfigure context.
- DefaultListsSeeder: replace TOCTOU read-then-insert with atomic
  INSERT … SELECT … WHERE NOT EXISTS — one SQLite writer lock, no race.
- AppSettingsRepository.GetAsync: catch DbUpdateException on the
  get-or-create path and re-read so concurrent startup cannot throw.
- Migration 20260609000000_UniqueListName: de-duplicates empty list rows
  (startup-race leftovers) then adds a UNIQUE index on lists.name.
- ForeignKeyTests: verifies ON DELETE SET NULL (blocked_by_task_id) is
  enforced on a fresh DbContext with no manual PRAGMA call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-06-09 10:05:41 +02:00
parent 763732a9b3
commit 7f1a14ab80
8 changed files with 910 additions and 12 deletions

View File

@@ -1,4 +1,3 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Seeding;
@@ -9,17 +8,18 @@ public static class DefaultListsSeeder
public static async Task SeedAsync(ClaudeDoDbContext ctx, CancellationToken ct = default)
{
var existing = await ctx.Lists.Select(l => l.Name).ToListAsync(ct);
var now = DateTime.UtcNow;
foreach (var name in Defaults.Where(n => !existing.Contains(n)))
foreach (var name in Defaults)
{
ctx.Lists.Add(new ListEntity
{
Id = Guid.NewGuid().ToString(),
Name = name,
CreatedAt = now,
});
var id = Guid.NewGuid().ToString();
// Atomic conditional insert: the SELECT ... WHERE NOT EXISTS is a single
// SQLite statement and cannot race — only one writer holds the lock.
await ctx.Database.ExecuteSqlAsync(
$"""
INSERT INTO lists (id, name, created_at, default_commit_type, sort_order)
SELECT {id}, {name}, {now}, 'chore', 0
WHERE NOT EXISTS (SELECT 1 FROM lists WHERE name = {name})
""", ct);
}
await ctx.SaveChangesAsync(ct);
}
}