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

@@ -18,8 +18,18 @@ public sealed class AppSettingsRepository
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
_context.AppSettings.Add(row);
await _context.SaveChangesAsync(ct);
_context.Entry(row).State = EntityState.Detached;
try
{
await _context.SaveChangesAsync(ct);
_context.Entry(row).State = EntityState.Detached;
}
catch (DbUpdateException)
{
// Concurrent process already inserted the singleton — discard our attempt and re-read.
_context.Entry(row).State = EntityState.Detached;
row = await _context.AppSettings.AsNoTracking()
.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
}
return row;
}