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
co-authored by Claude Sonnet 4.6
parent 763732a9b3
commit 7f1a14ab80
8 changed files with 910 additions and 12 deletions
@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class UniqueListName : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Remove duplicate list rows that have no tasks — keep the oldest rowid.
// This handles the startup-race case where both App and Worker seeded
// the same default list names concurrently.
migrationBuilder.Sql("""
DELETE FROM lists
WHERE (SELECT COUNT(*) FROM tasks WHERE list_id = lists.id) = 0
AND rowid NOT IN (
SELECT MIN(l2.rowid) FROM lists l2 WHERE l2.name = lists.name
)
""");
migrationBuilder.CreateIndex(
name: "idx_lists_name",
table: "lists",
column: "name",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "idx_lists_name",
table: "lists");
}
}
}