using System.Globalization; using System.Text.Json; using ClawdDotNet.Core.Storage; using Microsoft.Data.Sqlite; namespace ClawdDotNet.Core.Cases; public interface ICaseRepository { Task SaveAsync(CaseRecord record, CancellationToken ct); Task GetAsync(string id, CancellationToken ct); Task> ListOpenAsync(CancellationToken ct); /// The newest cases first, open or closed. Task> ListRecentAsync(int limit, CancellationToken ct); Task SaveDecisionAsync(Decision decision, CancellationToken ct); Task GetDecisionAsync(string id, CancellationToken ct); Task> ListDecisionsAsync(CancellationToken ct); } /// /// #689: Cases and decisions in the instance's database. The record is stored whole as JSON — /// its shape follows the rules and will grow — with the columns the queries need beside it. /// public sealed class SqliteCaseRepository(SqliteStorage storage) : ICaseRepository { private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) { Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } }; public Task SaveAsync(CaseRecord record, CancellationToken ct) => storage.WriteAsync(async conn => { using var cmd = conn.CreateCommand(); cmd.CommandText = """ INSERT INTO Cases (Id, Kind, Status, Holder, OpenedUnitId, OpenedAt, DeadlineUtc, Json) VALUES (@id, @kind, @status, @holder, @unit, @openedAt, @deadline, @json) ON CONFLICT(Id) DO UPDATE SET Kind = @kind, Status = @status, Holder = @holder, OpenedUnitId = @unit, OpenedAt = @openedAt, DeadlineUtc = @deadline, Json = @json """; cmd.Parameters.AddWithValue("@id", record.Id); cmd.Parameters.AddWithValue("@kind", record.Kind.ToString()); cmd.Parameters.AddWithValue("@status", record.Status.ToString()); cmd.Parameters.AddWithValue("@holder", record.Holder); cmd.Parameters.AddWithValue("@unit", (object?)record.OpenedUnitId ?? DBNull.Value); cmd.Parameters.AddWithValue("@openedAt", Format(record.OpenedAt)); cmd.Parameters.AddWithValue("@deadline", Format(record.DeadlineUtc)); cmd.Parameters.AddWithValue("@json", JsonSerializer.Serialize(record, Json)); await cmd.ExecuteNonQueryAsync(ct); }, ct); public async Task GetAsync(string id, CancellationToken ct) => (await QueryAsync("SELECT Json FROM Cases WHERE Id = @id", ct, ("@id", id))).FirstOrDefault(); public Task> ListOpenAsync(CancellationToken ct) => QueryAsync("SELECT Json FROM Cases WHERE Status = 'Open' ORDER BY OpenedAt", ct); public Task> ListRecentAsync(int limit, CancellationToken ct) => QueryAsync("SELECT Json FROM Cases ORDER BY OpenedAt DESC LIMIT @limit", ct, ("@limit", limit)); public Task SaveDecisionAsync(Decision decision, CancellationToken ct) => storage.WriteAsync(async conn => { using var cmd = conn.CreateCommand(); cmd.CommandText = """ INSERT INTO Decisions (Id, ScopeUnitId, DecidedAt, Json) VALUES (@id, @scope, @at, @json) ON CONFLICT(Id) DO UPDATE SET ScopeUnitId = @scope, DecidedAt = @at, Json = @json """; cmd.Parameters.AddWithValue("@id", decision.Id); cmd.Parameters.AddWithValue("@scope", (object?)decision.ScopeUnitId ?? DBNull.Value); cmd.Parameters.AddWithValue("@at", Format(decision.DecidedAt)); cmd.Parameters.AddWithValue("@json", JsonSerializer.Serialize(decision, Json)); await cmd.ExecuteNonQueryAsync(ct); }, ct); public async Task GetDecisionAsync(string id, CancellationToken ct) => (await QueryDecisionsAsync("SELECT Json FROM Decisions WHERE Id = @id", ct, ("@id", id))).FirstOrDefault(); public Task> ListDecisionsAsync(CancellationToken ct) => QueryDecisionsAsync("SELECT Json FROM Decisions ORDER BY DecidedAt DESC", ct); private async Task> QueryAsync(string sql, CancellationToken ct, params (string Name, object Value)[] args) { var result = new List(); await foreach (var json in ReadJsonAsync(sql, ct, args)) if (JsonSerializer.Deserialize(json, Json) is { } record) result.Add(record); return result; } private async Task> QueryDecisionsAsync(string sql, CancellationToken ct, params (string Name, object Value)[] args) { var result = new List(); await foreach (var json in ReadJsonAsync(sql, ct, args)) if (JsonSerializer.Deserialize(json, Json) is { } decision) result.Add(decision); return result; } private async IAsyncEnumerable ReadJsonAsync(string sql, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct, params (string Name, object Value)[] args) { await using var conn = await storage.OpenConnectionAsync(ct); using var cmd = conn.CreateCommand(); cmd.CommandText = sql; foreach (var (name, value) in args) cmd.Parameters.AddWithValue(name, value); await using SqliteDataReader reader = await cmd.ExecuteReaderAsync(ct); while (await reader.ReadAsync(ct)) yield return reader.GetString(0); } private static string Format(DateTime value) => value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture); }