journal/Journal.Core/Services/Database/JournalDatabaseService.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

286 lines
10 KiB
C#

using System.Security.Cryptography;
using System.Text;
using System.Globalization;
using Journal.Core.Dtos;
using Journal.Core.Services.Config;
using Microsoft.Data.Sqlite;
namespace Journal.Core.Services.Database;
public sealed class JournalDatabaseService(IJournalConfigService config) : IJournalDatabaseService
{
public const int KeySize = 32;
public const int Iterations = 600_000;
private static readonly byte[] DatabaseKeySalt = Encoding.UTF8.GetBytes("a_fixed_salt_for_the_db_key_deriv");
private static readonly Lock SqliteInitLock = new();
private static bool _sqliteInitialized;
private static readonly IReadOnlyList<string> RequiredSchemaTables =
["entries", "sections", "fragments", "tags", "fragment_tags", "lists", "todo_lists", "todo_items", "entry_documents", "conversations", "conversation_messages"];
private readonly IJournalConfigService _config = config;
public string GetDatabasePath()
{
var directory = ResolveDatabaseDirectory();
Directory.CreateDirectory(directory);
return Path.GetFullPath(Path.Combine(directory, _config.Current.DatabaseFilename));
}
public byte[] DeriveDatabaseKey(string password)
{
if (string.IsNullOrWhiteSpace(password))
throw new ArgumentException("Password cannot be empty.", nameof(password));
return Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(password),
DatabaseKeySalt,
Iterations,
HashAlgorithmName.SHA256,
KeySize);
}
public string BuildPragmaKeyStatement(string password)
{
var dbKeyHex = Convert.ToHexString(DeriveDatabaseKey(password)).ToLowerInvariant();
return $"PRAGMA key = \"x'{dbKeyHex}'\"";
}
public IReadOnlyDictionary<string, string> GetSchemaStatements()
{
return new Dictionary<string, string>(StringComparer.Ordinal)
{
["entries"] = """
CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL UNIQUE
);
""",
["sections"] = """
CREATE TABLE IF NOT EXISTS sections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entry_id INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT,
FOREIGN KEY (entry_id) REFERENCES entries (id)
);
""",
["fragments"] = """
CREATE TABLE IF NOT EXISTS fragments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT UNIQUE,
entry_id INTEGER,
type TEXT NOT NULL,
description TEXT,
time TEXT,
FOREIGN KEY (entry_id) REFERENCES entries (id)
);
""",
["tags"] = """
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
);
""",
["fragment_tags"] = """
CREATE TABLE IF NOT EXISTS fragment_tags (
fragment_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY (fragment_id, tag_id),
FOREIGN KEY (fragment_id) REFERENCES fragments (id),
FOREIGN KEY (tag_id) REFERENCES tags (id)
);
""",
["lists"] = """
CREATE TABLE IF NOT EXISTS lists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT UNIQUE,
label TEXT NOT NULL,
content TEXT,
created_at TEXT,
updated_at TEXT
);
""",
["todo_lists"] = """
CREATE TABLE IF NOT EXISTS todo_lists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT UNIQUE,
label TEXT NOT NULL,
created_at TEXT
);
""",
["todo_items"] = """
CREATE TABLE IF NOT EXISTS todo_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT UNIQUE,
list_id INTEGER NOT NULL,
text TEXT NOT NULL,
done INTEGER DEFAULT 0,
sort_order INTEGER DEFAULT 0,
FOREIGN KEY (list_id) REFERENCES todo_lists (id)
);
""",
["entry_documents"] = """
CREATE TABLE IF NOT EXISTS entry_documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT UNIQUE,
file_name TEXT NOT NULL UNIQUE,
content TEXT NOT NULL,
is_template INTEGER NOT NULL DEFAULT 0,
updated_at TEXT
);
""",
["conversations"] = """
CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT UNIQUE,
title TEXT NOT NULL,
created_at TEXT,
updated_at TEXT
);
""",
["conversation_messages"] = """
CREATE TABLE IF NOT EXISTS conversation_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT UNIQUE,
conversation_id INTEGER NOT NULL,
role TEXT NOT NULL,
text TEXT NOT NULL,
created_at TEXT,
FOREIGN KEY (conversation_id) REFERENCES conversations (id)
);
"""
};
}
public JournalDatabaseStatus GetStatus(string password)
{
var tables = GetSchemaStatements().Keys.OrderBy(x => x, StringComparer.Ordinal).ToArray();
var (Ready, Message) = ProbeRuntime(password);
return new JournalDatabaseStatus(
DatabasePath: GetDatabasePath(),
KeyLengthBytes: DeriveDatabaseKey(password).Length,
Iterations: Iterations,
KeyDerivation: "PBKDF2-HMAC-SHA256",
SchemaTables: tables,
RuntimeReady: Ready,
RuntimeMessage: Message);
}
public JournalDatabaseHydrationResult HydrateWorkspace(string password)
{
using var connection = OpenEncryptedConnection(password);
EnsureSchema(connection);
var runtimeReady = HasRequiredTables(connection);
var entryDocumentsProcessed = CountEntryDocuments(connection);
return new JournalDatabaseHydrationResult(
DatabasePath: GetDatabasePath(),
EntryFilesProcessed: entryDocumentsProcessed,
RuntimeReady: runtimeReady,
Message: runtimeReady
? "Workspace hydration completed with SQLCipher runtime schema validation and document store readiness."
: "Workspace hydration completed, but required SQLCipher schema tables were not found.");
}
private static void EnsureSqliteInitialized()
{
if (_sqliteInitialized)
return;
lock (SqliteInitLock)
{
if (_sqliteInitialized)
return;
SQLitePCL.Batteries_V2.Init();
_sqliteInitialized = true;
}
}
public SqliteConnection OpenEncryptedConnection(string password)
{
if (string.IsNullOrWhiteSpace(password))
throw new ArgumentException("Password cannot be empty.", nameof(password));
EnsureSqliteInitialized();
var connection = new SqliteConnection($"Data Source={GetDatabasePath()};Mode=ReadWriteCreate;Pooling=False");
connection.Open();
using var keyCmd = connection.CreateCommand();
keyCmd.CommandText = BuildPragmaKeyStatement(password) + ";";
keyCmd.ExecuteNonQuery();
using var verifyCmd = connection.CreateCommand();
verifyCmd.CommandText = "SELECT count(*) FROM sqlite_master;";
_ = verifyCmd.ExecuteScalar();
return connection;
}
public void EnsureSchema(SqliteConnection connection)
{
foreach (var statement in GetSchemaStatements().Values)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = statement;
cmd.ExecuteNonQuery();
}
}
private static bool HasRequiredTables(SqliteConnection connection)
{
var existing = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table'";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
if (!reader.IsDBNull(0))
existing.Add(reader.GetString(0));
}
return RequiredSchemaTables.All(existing.Contains);
}
private (bool Ready, string Message) ProbeRuntime(string password)
{
try
{
using var connection = OpenEncryptedConnection(password);
EnsureSchema(connection);
var ready = HasRequiredTables(connection);
return ready
? (true, "SQLCipher runtime is available and schema tables are present.")
: (false, "SQLCipher runtime opened, but required schema tables are missing.");
}
catch (Exception ex)
{
return (false, $"SQLCipher runtime check failed: {ex.Message}");
}
}
private static int CountEntryDocuments(SqliteConnection connection)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM entry_documents;";
var scalar = cmd.ExecuteScalar();
if (scalar is null || scalar is DBNull)
return 0;
return Convert.ToInt32(scalar, CultureInfo.InvariantCulture);
}
private string ResolveDatabaseDirectory()
{
var overrideDir = Environment.GetEnvironmentVariable("JOURNAL_DATABASE_DIR");
if (!string.IsNullOrWhiteSpace(overrideDir))
return Path.GetFullPath(overrideDir);
return Path.GetFullPath(Path.Combine(_config.Current.VaultDirectory, "db"));
}
}