journal/Journal.Core/Repositories/SqliteConversationRepository.cs
Jacob Schmidt 0d77300c22 feat: Project Journal backend monorepo
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>
2026-03-02 20:56:26 -06:00

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);
}
}