feat(data): add task numbers schema, allocator, and backfill migration

TaskEntity.Number is a global, monotonically increasing, never-reused
integer (displayed as #123), allocated from AppSettingsEntity.NextTaskNumber
via a single UPDATE...RETURNING statement rather than MAX(number)+1, which
would reissue a deleted task's number. Both insert paths (TaskRepository.
AddAsync and CreateChildAsync) route through the new TaskNumberAllocator,
with a bounded retry on a unique-index collision. One migration adds the
columns, backfills existing rows in creation order, and creates the unique
index afterwards. Data-layer only; MCP/UI wiring is later slices.
This commit is contained in:
mika kuns
2026-08-11 10:49:06 +02:00
parent 31a9e87b56
commit 9e46c96b24
25 changed files with 1416 additions and 50 deletions
+46
View File
@@ -0,0 +1,46 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data;
// Allocates TaskEntity.Number and inserts the task. Never MAX(number)+1 -- that would reissue a
// deleted task's number. Allocation is a single atomic UPDATE...RETURNING against the
// app_settings singleton row, which SQLite's single-writer model already serializes.
//
// The allocation and the insert are deliberately two separate statements, each committed on its
// own, rather than one shared transaction: a shared transaction would roll the counter bump back
// together with a failed insert, so a retry would hand out the exact same (still colliding)
// number every time and the bounded retry below would be pointless. Committing the bump
// unconditionally means a retry after a collision always advances to a fresh number, at the cost
// of burning the collided number as an unused gap -- which is fine, gaps are expected.
public static class TaskNumberAllocator
{
private const int MaxAttempts = 5;
public static async Task AddWithNumberAsync(
ClaudeDoDbContext context, TaskEntity entity, CancellationToken ct = default)
{
for (var attempt = 1; ; attempt++)
{
var settings = await context.AppSettings.FromSqlRaw(
"""
UPDATE app_settings SET next_task_number = next_task_number + 1
WHERE id = {0}
RETURNING *
""", AppSettingsEntity.SingletonId).AsNoTracking().ToListAsync(ct);
entity.Number = settings[0].NextTaskNumber - 1;
context.Tasks.Add(entity);
try
{
await context.SaveChangesAsync(ct);
return;
}
catch (DbUpdateException) when (attempt < MaxAttempts)
{
context.Entry(entity).State = EntityState.Detached;
}
}
}
}