journal/Journal.Core/Services/JournalDatabaseService.cs
Jacob Schmidt d3781d6c3e refactor: slim Entry.cs, extract shared sidecar client, add entry file repository
- Slim Entry.cs from ~550 to ~330 lines (thin dispatcher only)
- Extract HtmlSanitizer, CommandLogger, EntryFileService from Entry.cs
- Extract PythonSidecarClient from duplicated sidecar plumbing
- Add IEntryFileRepository + DiskEntryFileRepository (mirrors Fragment pattern)
- Move payload records to Dtos/CommandDtos.cs
- Move database result records to Dtos/DatabaseDtos.cs
- Register new services and repository in DI
- All 70/70 smoke tests pass, no behavior changes

Co-Authored-By: Warp <agent@warp.dev>
2026-02-23 21:03:55 -06:00

235 lines
8.5 KiB
C#

using System.Security.Cryptography;
using System.Text;
using Journal.Core.Dtos;
using Microsoft.Data.Sqlite;
namespace Journal.Core.Services;
public sealed class JournalDatabaseService : 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 object SqliteInitLock = new();
private static bool _sqliteInitialized;
private static readonly IReadOnlyList<string> RequiredSchemaTables =
["entries", "sections", "fragments", "tags", "fragment_tags"];
private readonly IJournalConfigService _config;
public JournalDatabaseService(IJournalConfigService config)
{
_config = config;
}
public string GetDatabasePath(string? dataDirectory = null)
{
var directory = string.IsNullOrWhiteSpace(dataDirectory)
? _config.Current.DataDirectory
: dataDirectory;
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,
entry_id INTEGER NOT NULL,
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)
);
"""
};
}
public string WriteSchemaBootstrap(string? dataDirectory = null)
{
var directory = string.IsNullOrWhiteSpace(dataDirectory)
? _config.Current.DataDirectory
: dataDirectory;
Directory.CreateDirectory(directory);
var bootstrapPath = Path.GetFullPath(Path.Combine(directory, "journal_schema.sql"));
var statements = GetSchemaStatements()
.Select(pair => $"-- {pair.Key}\n{pair.Value.Trim()}")
.ToArray();
var content = string.Join("\n\n", statements) + "\n";
File.WriteAllText(bootstrapPath, content);
return bootstrapPath;
}
public JournalDatabaseStatus GetStatus(string password, string? dataDirectory = null)
{
var tables = GetSchemaStatements().Keys.OrderBy(x => x, StringComparer.Ordinal).ToArray();
var bootstrapPath = WriteSchemaBootstrap(dataDirectory);
var runtime = ProbeRuntime(password, dataDirectory);
return new JournalDatabaseStatus(
DatabasePath: GetDatabasePath(dataDirectory),
KeyLengthBytes: DeriveDatabaseKey(password).Length,
Iterations: Iterations,
KeyDerivation: "PBKDF2-HMAC-SHA256",
SchemaTables: tables,
SchemaBootstrapPath: bootstrapPath,
RuntimeReady: runtime.Ready,
RuntimeMessage: runtime.Message);
}
public JournalDatabaseHydrationResult HydrateWorkspace(string password, string? dataDirectory = null)
{
var directory = string.IsNullOrWhiteSpace(dataDirectory)
? _config.Current.DataDirectory
: dataDirectory;
Directory.CreateDirectory(directory);
using var connection = OpenEncryptedConnection(password, directory);
CreateSchema(connection);
var runtimeReady = HasRequiredTables(connection);
var entryFilesProcessed = Directory.GetFiles(directory, "*.md", SearchOption.TopDirectoryOnly).Length;
var schemaPath = WriteSchemaBootstrap(directory);
return new JournalDatabaseHydrationResult(
DatabasePath: GetDatabasePath(directory),
SchemaBootstrapPath: schemaPath,
EntryFilesProcessed: entryFilesProcessed,
RuntimeReady: runtimeReady,
Message: runtimeReady
? "Workspace hydration completed with SQLCipher runtime schema validation."
: "Workspace hydration completed, but required schema tables were not found.");
}
private static void EnsureSqliteInitialized()
{
if (_sqliteInitialized)
return;
lock (SqliteInitLock)
{
if (_sqliteInitialized)
return;
SQLitePCL.Batteries_V2.Init();
_sqliteInitialized = true;
}
}
private SqliteConnection OpenEncryptedConnection(string password, string? dataDirectory = null)
{
if (string.IsNullOrWhiteSpace(password))
throw new ArgumentException("Password cannot be empty.", nameof(password));
EnsureSqliteInitialized();
var connection = new SqliteConnection($"Data Source={GetDatabasePath(dataDirectory)};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;
}
private void CreateSchema(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, string? dataDirectory)
{
try
{
using var connection = OpenEncryptedConnection(password, dataDirectory);
CreateSchema(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}");
}
}
}