Monorepo with centralized build props, npm workspaces, LlamaSharp AI, SQLite/SQLCipher storage, Svelte frontend, and unified smoke tests. Co-Authored-By: Oz <oz-agent@warp.dev>
218 lines
7.9 KiB
C#
218 lines
7.9 KiB
C#
using Journal.Core.Models;
|
|
using Journal.Core.Services.Database;
|
|
using Microsoft.Data.Sqlite;
|
|
|
|
namespace Journal.Core.Repositories;
|
|
|
|
public sealed class SqliteConversationRepository(IDatabaseSessionService session) : IConversationRepository
|
|
{
|
|
private readonly IDatabaseSessionService _session = session;
|
|
|
|
public List<Conversation> GetAll()
|
|
{
|
|
var conn = _session.GetConnection();
|
|
return ReadAll(conn);
|
|
}
|
|
|
|
public Conversation? GetById(Guid id)
|
|
{
|
|
var conn = _session.GetConnection();
|
|
return ReadById(conn, id);
|
|
}
|
|
|
|
public void Add(Conversation conversation)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(conversation);
|
|
var conn = _session.GetConnection();
|
|
Insert(conn, conversation);
|
|
}
|
|
|
|
public bool Update(Guid id, string? title = null)
|
|
{
|
|
var conn = _session.GetConnection();
|
|
var existing = ReadById(conn, id);
|
|
if (existing is null)
|
|
return false;
|
|
|
|
if (title is not null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(title))
|
|
throw new ArgumentException("Title cannot be empty", nameof(title));
|
|
existing.Title = title.Trim();
|
|
}
|
|
|
|
existing.UpdatedAt = DateTimeOffset.Now;
|
|
UpdateRow(conn, existing);
|
|
return true;
|
|
}
|
|
|
|
public bool Remove(Guid id)
|
|
{
|
|
var conn = _session.GetConnection();
|
|
return Delete(conn, id);
|
|
}
|
|
|
|
public void AddMessage(ConversationMessage message)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(message);
|
|
var conn = _session.GetConnection();
|
|
InsertMessage(conn, message);
|
|
|
|
// Touch conversation updated_at
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "UPDATE conversations SET updated_at = @now WHERE guid = @guid;";
|
|
cmd.Parameters.AddWithValue("@now", DateTimeOffset.Now.ToString("O"));
|
|
cmd.Parameters.AddWithValue("@guid", message.ConversationId.ToString("D"));
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
public List<ConversationMessage> GetMessages(Guid conversationId)
|
|
{
|
|
var conn = _session.GetConnection();
|
|
return ReadMessages(conn, conversationId);
|
|
}
|
|
|
|
// ── Private helpers ──────────────────────────────────────────────
|
|
|
|
private static void Insert(SqliteConnection conn, Conversation c)
|
|
{
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = """
|
|
INSERT INTO conversations (guid, title, created_at, updated_at)
|
|
VALUES (@guid, @title, @createdAt, @updatedAt);
|
|
""";
|
|
cmd.Parameters.AddWithValue("@guid", c.Id.ToString("D"));
|
|
cmd.Parameters.AddWithValue("@title", c.Title);
|
|
cmd.Parameters.AddWithValue("@createdAt", c.CreatedAt.ToString("O"));
|
|
cmd.Parameters.AddWithValue("@updatedAt", c.UpdatedAt.ToString("O"));
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
private static void UpdateRow(SqliteConnection conn, Conversation c)
|
|
{
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = """
|
|
UPDATE conversations SET title = @title, updated_at = @updatedAt
|
|
WHERE guid = @guid;
|
|
""";
|
|
cmd.Parameters.AddWithValue("@guid", c.Id.ToString("D"));
|
|
cmd.Parameters.AddWithValue("@title", c.Title);
|
|
cmd.Parameters.AddWithValue("@updatedAt", c.UpdatedAt.ToString("O"));
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
private static bool Delete(SqliteConnection conn, Guid id)
|
|
{
|
|
using var tx = conn.BeginTransaction();
|
|
|
|
// Get the row id for cascade delete of messages
|
|
var rowId = GetRowId(conn, id);
|
|
if (rowId.HasValue)
|
|
{
|
|
using var delMsgs = conn.CreateCommand();
|
|
delMsgs.CommandText = "DELETE FROM conversation_messages WHERE conversation_id = @id;";
|
|
delMsgs.Parameters.AddWithValue("@id", rowId.Value);
|
|
delMsgs.ExecuteNonQuery();
|
|
}
|
|
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "DELETE FROM conversations WHERE guid = @guid;";
|
|
cmd.Parameters.AddWithValue("@guid", id.ToString("D"));
|
|
var rows = cmd.ExecuteNonQuery();
|
|
|
|
tx.Commit();
|
|
return rows > 0;
|
|
}
|
|
|
|
private static long? GetRowId(SqliteConnection conn, Guid id)
|
|
{
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "SELECT id FROM conversations WHERE guid = @guid;";
|
|
cmd.Parameters.AddWithValue("@guid", id.ToString("D"));
|
|
var result = cmd.ExecuteScalar();
|
|
return result is long rowId ? rowId : null;
|
|
}
|
|
|
|
private static Conversation? ReadById(SqliteConnection conn, Guid id)
|
|
{
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "SELECT guid, title, created_at, updated_at FROM conversations WHERE guid = @guid;";
|
|
cmd.Parameters.AddWithValue("@guid", id.ToString("D"));
|
|
|
|
using var reader = cmd.ExecuteReader();
|
|
return reader.Read() ? MapConversation(reader) : null;
|
|
}
|
|
|
|
private static List<Conversation> ReadAll(SqliteConnection conn)
|
|
{
|
|
var results = new List<Conversation>();
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "SELECT guid, title, created_at, updated_at FROM conversations ORDER BY updated_at DESC;";
|
|
|
|
using var reader = cmd.ExecuteReader();
|
|
while (reader.Read())
|
|
results.Add(MapConversation(reader));
|
|
|
|
return results;
|
|
}
|
|
|
|
private static void InsertMessage(SqliteConnection conn, ConversationMessage m)
|
|
{
|
|
var convRowId = GetRowId(conn, m.ConversationId)
|
|
?? throw new InvalidOperationException($"Conversation {m.ConversationId} not found");
|
|
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = """
|
|
INSERT INTO conversation_messages (guid, conversation_id, role, text, created_at)
|
|
VALUES (@guid, @conversationId, @role, @text, @createdAt);
|
|
""";
|
|
cmd.Parameters.AddWithValue("@guid", m.Id.ToString("D"));
|
|
cmd.Parameters.AddWithValue("@conversationId", convRowId);
|
|
cmd.Parameters.AddWithValue("@role", m.Role);
|
|
cmd.Parameters.AddWithValue("@text", m.Text);
|
|
cmd.Parameters.AddWithValue("@createdAt", m.CreatedAt.ToString("O"));
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
private static List<ConversationMessage> ReadMessages(SqliteConnection conn, Guid conversationId)
|
|
{
|
|
var rowId = GetRowId(conn, conversationId);
|
|
if (!rowId.HasValue)
|
|
return [];
|
|
|
|
var results = new List<ConversationMessage>();
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = """
|
|
SELECT guid, role, text, created_at
|
|
FROM conversation_messages
|
|
WHERE conversation_id = @conversationId
|
|
ORDER BY created_at ASC;
|
|
""";
|
|
cmd.Parameters.AddWithValue("@conversationId", rowId.Value);
|
|
|
|
using var reader = cmd.ExecuteReader();
|
|
while (reader.Read())
|
|
results.Add(MapMessage(reader, conversationId));
|
|
|
|
return results;
|
|
}
|
|
|
|
private static Conversation MapConversation(SqliteDataReader reader)
|
|
{
|
|
var guid = Guid.Parse(reader.GetString(0));
|
|
var title = reader.GetString(1);
|
|
var createdAt = reader.IsDBNull(2) ? DateTimeOffset.MinValue : DateTimeOffset.Parse(reader.GetString(2));
|
|
var updatedAt = reader.IsDBNull(3) ? createdAt : DateTimeOffset.Parse(reader.GetString(3));
|
|
return new Conversation(guid, title, createdAt, updatedAt);
|
|
}
|
|
|
|
private static ConversationMessage MapMessage(SqliteDataReader reader, Guid conversationId)
|
|
{
|
|
var guid = Guid.Parse(reader.GetString(0));
|
|
var role = reader.GetString(1);
|
|
var text = reader.IsDBNull(2) ? "" : reader.GetString(2);
|
|
var createdAt = reader.IsDBNull(3) ? DateTimeOffset.MinValue : DateTimeOffset.Parse(reader.GetString(3));
|
|
return new ConversationMessage(guid, conversationId, role, text, createdAt);
|
|
}
|
|
}
|