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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user