- 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>
92 lines
3.9 KiB
C#
92 lines
3.9 KiB
C#
using System.Text.Json;
|
|
using ClaudeDo.Data.Models;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace ClaudeDo.Data.Repositories;
|
|
|
|
public sealed class AppSettingsRepository
|
|
{
|
|
private readonly ClaudeDoDbContext _context;
|
|
|
|
public AppSettingsRepository(ClaudeDoDbContext context) => _context = context;
|
|
|
|
public async Task<AppSettingsEntity> GetAsync(CancellationToken ct = default)
|
|
{
|
|
var row = await _context.AppSettings.AsNoTracking()
|
|
.FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
|
|
if (row is not null) return row;
|
|
|
|
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
|
|
_context.AppSettings.Add(row);
|
|
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;
|
|
}
|
|
|
|
private async Task<AppSettingsEntity> GetOrCreateTrackedRowAsync(CancellationToken ct)
|
|
{
|
|
var row = await _context.AppSettings
|
|
.FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
|
|
if (row is null)
|
|
{
|
|
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
|
|
_context.AppSettings.Add(row);
|
|
}
|
|
return row;
|
|
}
|
|
|
|
public async Task UpdateAsync(AppSettingsEntity updated, CancellationToken ct = default)
|
|
{
|
|
var row = await GetOrCreateTrackedRowAsync(ct);
|
|
|
|
row.DefaultClaudeInstructions = updated.DefaultClaudeInstructions ?? string.Empty;
|
|
row.DefaultModel = string.IsNullOrWhiteSpace(updated.DefaultModel) ? "sonnet" : updated.DefaultModel;
|
|
row.DefaultMaxTurns = updated.DefaultMaxTurns;
|
|
row.DefaultPermissionMode = string.IsNullOrWhiteSpace(updated.DefaultPermissionMode)
|
|
? "auto" : updated.DefaultPermissionMode;
|
|
row.MaxParallelExecutions = updated.MaxParallelExecutions < 1 ? 1 : updated.MaxParallelExecutions;
|
|
row.WorktreeStrategy = string.IsNullOrWhiteSpace(updated.WorktreeStrategy) ? "sibling" : updated.WorktreeStrategy;
|
|
row.CentralWorktreeRoot = string.IsNullOrWhiteSpace(updated.CentralWorktreeRoot)
|
|
? null : updated.CentralWorktreeRoot;
|
|
row.WorktreeAutoCleanupEnabled = updated.WorktreeAutoCleanupEnabled;
|
|
row.WorktreeAutoCleanupDays = updated.WorktreeAutoCleanupDays;
|
|
row.ReportExcludedPaths = string.IsNullOrWhiteSpace(updated.ReportExcludedPaths)
|
|
? null : updated.ReportExcludedPaths;
|
|
row.StandupWeekday = updated.StandupWeekday;
|
|
row.DailyPrepMaxTasks = updated.DailyPrepMaxTasks < 1 ? 1 : updated.DailyPrepMaxTasks;
|
|
|
|
await _context.SaveChangesAsync(ct);
|
|
}
|
|
|
|
public async Task<List<string>> GetRepoImportFoldersAsync(CancellationToken ct = default)
|
|
{
|
|
var json = await _context.AppSettings.AsNoTracking()
|
|
.Where(s => s.Id == AppSettingsEntity.SingletonId)
|
|
.Select(s => s.RepoImportFolders)
|
|
.FirstOrDefaultAsync(ct);
|
|
|
|
if (string.IsNullOrWhiteSpace(json)) return new List<string>();
|
|
try { return JsonSerializer.Deserialize<List<string>>(json) ?? new List<string>(); }
|
|
catch (JsonException) { return new List<string>(); }
|
|
}
|
|
|
|
public async Task SetRepoImportFoldersAsync(IEnumerable<string> folders, CancellationToken ct = default)
|
|
{
|
|
var list = folders.ToList();
|
|
var row = await GetOrCreateTrackedRowAsync(ct);
|
|
|
|
row.RepoImportFolders = list.Count == 0 ? null : JsonSerializer.Serialize(list);
|
|
await _context.SaveChangesAsync(ct);
|
|
}
|
|
}
|