refactor: encrypt fragments in SQLCipher DB, organize services into domain modules

- Move standalone fragment storage from unencrypted SQLite to the existing
  encrypted SQLCipher database (journal_cache.db)
- Add IDatabaseSessionService/DatabaseSessionService for shared encrypted
  connection management after authentication
- Update fragments table schema: nullable entry_id, add guid column
- Reorganize flat Services/ directory (28 files) into 9 domain modules:
  Ai, Config, Database, Entries, Fragments, Logging, Sidecar, Speech, Vault
- Update all namespace declarations and using statements across all projects
- Update REFACTORING_SUMMARY.md with all changes

Co-Authored-By: Warp <agent@warp.dev>
This commit is contained in:
Jacob Schmidt 2026-02-23 21:58:45 -06:00
parent 8a29bd4bd1
commit f06c1d15bb
34 changed files with 570 additions and 66 deletions

View File

@ -3,7 +3,14 @@ using System.Globalization;
using System.Text.Json;
using Journal.Core.Dtos;
using Journal.Core.Models;
using Journal.Core.Services;
using Journal.Core.Services.Ai;
using Journal.Core.Services.Config;
using Journal.Core.Services.Database;
using Journal.Core.Services.Entries;
using Journal.Core.Services.Fragments;
using Journal.Core.Services.Logging;
using Journal.Core.Services.Speech;
using Journal.Core.Services.Vault;
namespace Journal.Core;
@ -12,6 +19,7 @@ public class Entry(
IEntrySearchService entrySearch,
IVaultStorageService vaultStorage,
IJournalDatabaseService database,
IDatabaseSessionService databaseSession,
IJournalConfigService config,
IAiService ai,
ISpeechBridgeService speech,
@ -22,6 +30,7 @@ public class Entry(
private readonly IEntrySearchService _entrySearch = entrySearch;
private readonly IVaultStorageService _vaultStorage = vaultStorage;
private readonly IJournalDatabaseService _database = database;
private readonly IDatabaseSessionService _databaseSession = databaseSession;
private readonly IJournalConfigService _config = config;
private readonly IAiService _ai = ai;
private readonly ISpeechBridgeService _speech = speech;
@ -202,6 +211,7 @@ public class Entry(
if (loadPayload is null)
return Error("Missing or invalid payload");
result = _vaultStorage.LoadAllVaults(loadPayload.Password, loadPayload.VaultDirectory, loadPayload.DataDirectory);
_databaseSession.SetPassword(loadPayload.Password, loadPayload.DataDirectory);
break;
case "vault.save_current_month":
var saveCurrentPayload = DeserializePayload<VaultPayload>(cmd.Payload);
@ -245,6 +255,7 @@ public class Entry(
if (dbHydratePayload is null || string.IsNullOrWhiteSpace(dbHydratePayload.Password))
return Error("Missing or invalid payload");
result = _database.HydrateWorkspace(dbHydratePayload.Password, dbHydratePayload.DataDirectory);
_databaseSession.SetPassword(dbHydratePayload.Password, dbHydratePayload.DataDirectory);
break;
default:
CommandLogger.LogFailure(action, correlationId, "unknown_action");

View File

@ -0,0 +1,320 @@
using Journal.Core.Models;
using Journal.Core.Services.Database;
using Microsoft.Data.Sqlite;
namespace Journal.Core.Repositories;
public sealed class SqliteFragmentRepository(IDatabaseSessionService session) : IFragmentRepository
{
private readonly IDatabaseSessionService _session = session;
public Task<List<Fragment>> GetAllAsync()
{
var conn = _session.GetConnection();
var fragments = ReadAllFragments(conn);
return Task.FromResult(fragments);
}
public Task<Fragment?> GetByIdAsync(Guid id)
{
var conn = _session.GetConnection();
var fragment = ReadFragment(conn, id);
return Task.FromResult(fragment);
}
public Task AddAsync(Fragment fragment)
{
ArgumentNullException.ThrowIfNull(fragment);
Normalize(fragment);
var conn = _session.GetConnection();
InsertFragment(conn, fragment);
return Task.CompletedTask;
}
public Task<bool> RemoveAsync(Guid id)
{
var conn = _session.GetConnection();
var deleted = DeleteFragment(conn, id);
return Task.FromResult(deleted);
}
public Task<bool> UpdateAsync(
Guid id,
string? type = null,
string? description = null,
IEnumerable<string>? tags = null,
DateTimeOffset? time = null)
{
var conn = _session.GetConnection();
var existing = ReadFragment(conn, id);
if (existing is null)
return Task.FromResult(false);
if (type != null)
{
if (string.IsNullOrWhiteSpace(type))
throw new ArgumentException("Type cannot be empty", nameof(type));
existing.Type = type.Trim();
}
if (description != null)
{
if (string.IsNullOrWhiteSpace(description))
throw new ArgumentException("Description cannot be empty", nameof(description));
existing.Description = description.Trim();
}
if (tags != null)
{
existing.Tags = [..
tags.Where(t => !string.IsNullOrWhiteSpace(t))
.Select(t => t.Trim())];
}
if (time.HasValue)
existing.Time = time.Value;
UpdateFragmentRow(conn, existing);
return Task.FromResult(true);
}
public Task<List<Fragment>> GetByTagAsync(string tag)
{
var q = tag?.Trim();
if (string.IsNullOrWhiteSpace(q))
return Task.FromResult(new List<Fragment>());
var conn = _session.GetConnection();
var all = ReadAllFragments(conn);
var items = all
.Where(f => f.Tags.Any(t => t.Contains(q, StringComparison.OrdinalIgnoreCase)))
.ToList();
return Task.FromResult(items);
}
public Task<List<Fragment>> GetByTypeAsync(string type)
{
var q = type?.Trim();
if (string.IsNullOrWhiteSpace(q))
return Task.FromResult(new List<Fragment>());
var conn = _session.GetConnection();
var all = ReadAllFragments(conn);
var items = all
.Where(f => f.Type.Contains(q, StringComparison.OrdinalIgnoreCase))
.ToList();
return Task.FromResult(items);
}
public Task<List<Fragment>> SearchAsync(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null)
{
var conn = _session.GetConnection();
IEnumerable<Fragment> results = ReadAllFragments(conn);
var qType = type?.Trim();
var qTag = tag?.Trim();
if (!string.IsNullOrWhiteSpace(qType))
results = results.Where(f => f.Type.Contains(qType, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(qTag))
results = results.Where(f => f.Tags.Any(t => t.Contains(qTag, StringComparison.OrdinalIgnoreCase)));
if (timeAfter.HasValue)
results = results.Where(f => f.Time > timeAfter.Value);
return Task.FromResult(results.ToList());
}
// ── Private helpers ──────────────────────────────────────────────
private static void InsertFragment(SqliteConnection conn, Fragment f)
{
using var tx = conn.BeginTransaction();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO fragments (guid, entry_id, type, description, time)
VALUES (@guid, NULL, @type, @description, @time);
""";
cmd.Parameters.AddWithValue("@guid", f.Id.ToString("D"));
cmd.Parameters.AddWithValue("@type", f.Type);
cmd.Parameters.AddWithValue("@description", f.Description);
cmd.Parameters.AddWithValue("@time", f.Time.ToString("O"));
cmd.ExecuteNonQuery();
var fragmentRowId = GetFragmentRowId(conn, f.Id);
if (fragmentRowId.HasValue)
InsertTags(conn, fragmentRowId.Value, f.Tags);
tx.Commit();
}
private static void UpdateFragmentRow(SqliteConnection conn, Fragment f)
{
using var tx = conn.BeginTransaction();
using var upd = conn.CreateCommand();
upd.CommandText = """
UPDATE fragments SET type = @type, description = @description, time = @time
WHERE guid = @guid AND entry_id IS NULL;
""";
upd.Parameters.AddWithValue("@guid", f.Id.ToString("D"));
upd.Parameters.AddWithValue("@type", f.Type);
upd.Parameters.AddWithValue("@description", f.Description);
upd.Parameters.AddWithValue("@time", f.Time.ToString("O"));
upd.ExecuteNonQuery();
var fragmentRowId = GetFragmentRowId(conn, f.Id);
if (fragmentRowId.HasValue)
{
using var del = conn.CreateCommand();
del.CommandText = "DELETE FROM fragment_tags WHERE fragment_id = @id;";
del.Parameters.AddWithValue("@id", fragmentRowId.Value);
del.ExecuteNonQuery();
InsertTags(conn, fragmentRowId.Value, f.Tags);
}
tx.Commit();
}
private static bool DeleteFragment(SqliteConnection conn, Guid id)
{
using var tx = conn.BeginTransaction();
var fragmentRowId = GetFragmentRowId(conn, id);
if (fragmentRowId.HasValue)
{
using var delTags = conn.CreateCommand();
delTags.CommandText = "DELETE FROM fragment_tags WHERE fragment_id = @id;";
delTags.Parameters.AddWithValue("@id", fragmentRowId.Value);
delTags.ExecuteNonQuery();
}
using var delFrag = conn.CreateCommand();
delFrag.CommandText = "DELETE FROM fragments WHERE guid = @guid AND entry_id IS NULL;";
delFrag.Parameters.AddWithValue("@guid", id.ToString("D"));
var rows = delFrag.ExecuteNonQuery();
tx.Commit();
return rows > 0;
}
private static Fragment? ReadFragment(SqliteConnection conn, Guid id)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT id, guid, type, description, time
FROM fragments WHERE guid = @guid AND entry_id IS NULL;
""";
cmd.Parameters.AddWithValue("@guid", id.ToString("D"));
using var reader = cmd.ExecuteReader();
if (!reader.Read())
return null;
var fragment = MapRow(reader);
fragment.Tags = ReadTags(conn, reader.GetInt64(0));
return fragment;
}
private static List<Fragment> ReadAllFragments(SqliteConnection conn)
{
var fragments = new List<Fragment>();
var rowIds = new List<long>();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT id, guid, type, description, time
FROM fragments WHERE guid IS NOT NULL AND entry_id IS NULL
ORDER BY time;
""";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
fragments.Add(MapRow(reader));
rowIds.Add(reader.GetInt64(0));
}
for (var i = 0; i < fragments.Count; i++)
fragments[i].Tags = ReadTags(conn, rowIds[i]);
return fragments;
}
private static List<string> ReadTags(SqliteConnection conn, long fragmentRowId)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT t.name FROM tags t
INNER JOIN fragment_tags ft ON ft.tag_id = t.id
WHERE ft.fragment_id = @id
ORDER BY t.name;
""";
cmd.Parameters.AddWithValue("@id", fragmentRowId);
var tags = new List<string>();
using var reader = cmd.ExecuteReader();
while (reader.Read())
tags.Add(reader.GetString(0));
return tags;
}
private static long? GetFragmentRowId(SqliteConnection conn, Guid guid)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id FROM fragments WHERE guid = @guid;";
cmd.Parameters.AddWithValue("@guid", guid.ToString("D"));
var result = cmd.ExecuteScalar();
return result is long id ? id : null;
}
private static void InsertTags(SqliteConnection conn, long fragmentRowId, List<string> tags)
{
if (tags.Count == 0) return;
foreach (var tag in tags)
{
// Upsert into tags table
using var upsert = conn.CreateCommand();
upsert.CommandText = "INSERT OR IGNORE INTO tags (name) VALUES (@name);";
upsert.Parameters.AddWithValue("@name", tag);
upsert.ExecuteNonQuery();
// Get tag id
using var getTagId = conn.CreateCommand();
getTagId.CommandText = "SELECT id FROM tags WHERE name = @name;";
getTagId.Parameters.AddWithValue("@name", tag);
var tagId = (long)getTagId.ExecuteScalar()!;
// Link fragment to tag
using var link = conn.CreateCommand();
link.CommandText = "INSERT OR IGNORE INTO fragment_tags (fragment_id, tag_id) VALUES (@fid, @tid);";
link.Parameters.AddWithValue("@fid", fragmentRowId);
link.Parameters.AddWithValue("@tid", tagId);
link.ExecuteNonQuery();
}
}
private static Fragment MapRow(SqliteDataReader reader)
{
// columns: id (int), guid (text), type (text), description (text), time (text)
var guid = Guid.Parse(reader.GetString(1));
var type = reader.GetString(2);
var description = reader.IsDBNull(3) ? "" : reader.GetString(3);
var time = reader.IsDBNull(4)
? DateTimeOffset.MinValue
: DateTimeOffset.Parse(reader.GetString(4));
return new Fragment(guid, type, description, time);
}
private static void Normalize(Fragment fragment)
{
fragment.Type = fragment.Type.Trim();
fragment.Description = fragment.Description.Trim();
fragment.Tags = [..
fragment.Tags.Where(t => !string.IsNullOrWhiteSpace(t))
.Select(t => t.Trim())];
}
}

View File

@ -1,6 +1,14 @@
using Microsoft.Extensions.DependencyInjection;
using Journal.Core.Repositories;
using Journal.Core.Services;
using Journal.Core.Services.Ai;
using Journal.Core.Services.Config;
using Journal.Core.Services.Database;
using Journal.Core.Services.Entries;
using Journal.Core.Services.Fragments;
using Journal.Core.Services.Logging;
using Journal.Core.Services.Sidecar;
using Journal.Core.Services.Speech;
using Journal.Core.Services.Vault;
namespace Journal.Core;
@ -8,7 +16,8 @@ public static class ServiceCollectionExtensions
{
public static IServiceCollection AddFragmentServices(this IServiceCollection services)
{
services.AddSingleton<IFragmentRepository, FileFragmentRepository>();
services.AddSingleton<IDatabaseSessionService, DatabaseSessionService>();
services.AddSingleton<IFragmentRepository, SqliteFragmentRepository>();
services.AddSingleton<IJournalConfigService, JournalConfigService>();
services.AddTransient<IFragmentService, FragmentService>();
services.AddTransient<IEntrySearchService, EntrySearchService>();

View File

@ -1,6 +1,6 @@
using Journal.Core.Dtos;
namespace Journal.Core.Services;
namespace Journal.Core.Services.Ai;
public sealed class DisabledAiService(string provider, string message = "AI provider disabled.", bool healthy = true) : IAiService
{

View File

@ -1,6 +1,6 @@
using Journal.Core.Dtos;
namespace Journal.Core.Services;
namespace Journal.Core.Services.Ai;
public interface IAiService
{

View File

@ -1,8 +1,9 @@
using System.Text.Json;
using Journal.Core.Dtos;
using Journal.Core.Models;
using Journal.Core.Services.Sidecar;
namespace Journal.Core.Services;
namespace Journal.Core.Services.Ai;
public sealed class PythonSidecarAiService : IAiService
{

View File

@ -1,6 +1,6 @@
using Journal.Core.Models;
namespace Journal.Core.Services;
namespace Journal.Core.Services.Config;
public interface IJournalConfigService
{

View File

@ -1,6 +1,6 @@
using Journal.Core.Models;
namespace Journal.Core.Services;
namespace Journal.Core.Services.Config;
public sealed class JournalConfigService : IJournalConfigService
{

View File

@ -0,0 +1,99 @@
using Microsoft.Data.Sqlite;
namespace Journal.Core.Services.Database;
public sealed class DatabaseSessionService(IJournalDatabaseService database) : IDatabaseSessionService, IDisposable
{
private readonly IJournalDatabaseService _database = database;
private readonly Lock _lock = new();
private string? _password;
private string? _dataDirectory;
private SqliteConnection? _connection;
public bool IsUnlocked
{
get
{
lock (_lock) { return _password is not null; }
}
}
public void SetPassword(string password, string? dataDirectory = null)
{
if (string.IsNullOrWhiteSpace(password))
throw new ArgumentException("Password cannot be empty.", nameof(password));
lock (_lock)
{
// If password or directory changed, close the old connection
if (_connection is not null &&
(_password != password || _dataDirectory != dataDirectory))
{
_connection.Dispose();
_connection = null;
}
_password = password;
_dataDirectory = dataDirectory;
}
}
public SqliteConnection GetConnection()
{
lock (_lock)
{
if (_password is null)
throw new InvalidOperationException(
"Database is locked. Authenticate first (e.g. vault.load_all or db.hydrate_workspace).");
if (_connection is not null)
return _connection;
_connection = OpenEncryptedConnection(_password, _dataDirectory);
EnsureSchema(_connection, _database);
return _connection;
}
}
public void Dispose()
{
lock (_lock)
{
_connection?.Dispose();
_connection = null;
}
}
private SqliteConnection OpenEncryptedConnection(string password, string? dataDirectory)
{
var dbPath = _database.GetDatabasePath(dataDirectory);
var directory = Path.GetDirectoryName(dbPath);
if (!string.IsNullOrWhiteSpace(directory))
Directory.CreateDirectory(directory);
var connection = new SqliteConnection(
$"Data Source={dbPath};Mode=ReadWriteCreate;Pooling=False");
connection.Open();
using var keyCmd = connection.CreateCommand();
keyCmd.CommandText = _database.BuildPragmaKeyStatement(password) + ";";
keyCmd.ExecuteNonQuery();
// Verify the key is correct
using var verifyCmd = connection.CreateCommand();
verifyCmd.CommandText = "SELECT count(*) FROM sqlite_master;";
_ = verifyCmd.ExecuteScalar();
return connection;
}
private static void EnsureSchema(SqliteConnection connection, IJournalDatabaseService database)
{
foreach (var statement in database.GetSchemaStatements().Values)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = statement;
cmd.ExecuteNonQuery();
}
}
}

View File

@ -0,0 +1,10 @@
using Microsoft.Data.Sqlite;
namespace Journal.Core.Services.Database;
public interface IDatabaseSessionService
{
bool IsUnlocked { get; }
void SetPassword(string password, string? dataDirectory = null);
SqliteConnection GetConnection();
}

View File

@ -1,6 +1,6 @@
using Journal.Core.Dtos;
namespace Journal.Core.Services;
namespace Journal.Core.Services.Database;
public interface IJournalDatabaseService
{

View File

@ -1,9 +1,10 @@
using System.Security.Cryptography;
using System.Text;
using Journal.Core.Dtos;
using Journal.Core.Services.Config;
using Microsoft.Data.Sqlite;
namespace Journal.Core.Services;
namespace Journal.Core.Services.Database;
public sealed class JournalDatabaseService(IJournalConfigService config) : IJournalDatabaseService
{
@ -68,7 +69,8 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
["fragments"] = """
CREATE TABLE IF NOT EXISTS fragments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entry_id INTEGER NOT NULL,
guid TEXT UNIQUE,
entry_id INTEGER,
type TEXT NOT NULL,
description TEXT,
time TEXT,

View File

@ -1,7 +1,7 @@
using Journal.Core.Dtos;
using Journal.Core.Repositories;
namespace Journal.Core.Services;
namespace Journal.Core.Services.Entries;
public sealed class EntryFileService(IEntryFileRepository repo) : IEntryFileService
{

View File

@ -1,7 +1,7 @@
using Journal.Core.Dtos;
using System.Globalization;
namespace Journal.Core.Services;
namespace Journal.Core.Services.Entries;
public class EntrySearchService : IEntrySearchService
{

View File

@ -1,7 +1,7 @@
using System.Net;
using System.Text.RegularExpressions;
namespace Journal.Core.Services;
namespace Journal.Core.Services.Entries;
public static class HtmlSanitizer
{

View File

@ -1,6 +1,6 @@
using Journal.Core.Dtos;
namespace Journal.Core.Services;
namespace Journal.Core.Services.Entries;
public interface IEntryFileService
{

View File

@ -1,6 +1,6 @@
using Journal.Core.Dtos;
namespace Journal.Core.Services;
namespace Journal.Core.Services.Entries;
public interface IEntrySearchService
{

View File

@ -1,7 +1,7 @@
using System.Text.RegularExpressions;
using Journal.Core.Models;
namespace Journal.Core.Services;
namespace Journal.Core.Services.Entries;
public static partial class JournalParser
{

View File

@ -3,7 +3,7 @@ using Journal.Core.Dtos;
using Journal.Core.Models;
using Journal.Core.Repositories;
namespace Journal.Core.Services;
namespace Journal.Core.Services.Fragments;
public class FragmentService(IFragmentRepository repo) : IFragmentService
{

View File

@ -1,6 +1,6 @@
using Journal.Core.Dtos;
namespace Journal.Core.Services;
namespace Journal.Core.Services.Fragments;
public interface IFragmentService
{

View File

@ -1,7 +1,7 @@
using System.Globalization;
using System.Text.Json;
namespace Journal.Core.Services;
namespace Journal.Core.Services.Logging;
public sealed class CommandLogger
{

View File

@ -1,6 +1,6 @@
using System.Text.Json;
namespace Journal.Core.Services;
namespace Journal.Core.Services.Logging;
public static class LogRedactor
{

View File

@ -2,7 +2,7 @@ using System.Diagnostics;
using System.Text.Json;
using Journal.Core.Models;
namespace Journal.Core.Services;
namespace Journal.Core.Services.Sidecar;
public sealed class PythonSidecarClient(JournalConfig config)
{

View File

@ -1,7 +1,10 @@
using System.Text;
using Journal.Core.Dtos;
using Journal.Core.Services.Config;
using Journal.Core.Services.Entries;
using Journal.Core.Services.Vault;
namespace Journal.Core.Services;
namespace Journal.Core.Services.Sidecar;
public sealed class SidecarCli(IVaultStorageService vaultStorage, IEntrySearchService entrySearch, IJournalConfigService config)
{

View File

@ -1,6 +1,6 @@
using Journal.Core.Dtos;
namespace Journal.Core.Services;
namespace Journal.Core.Services.Speech;
public sealed class DisabledSpeechBridgeService(string provider = "none", string message = "Speech bridge is disabled.") : ISpeechBridgeService
{

View File

@ -1,6 +1,6 @@
using Journal.Core.Dtos;
namespace Journal.Core.Services;
namespace Journal.Core.Services.Speech;
public interface ISpeechBridgeService
{

View File

@ -1,8 +1,9 @@
using System.Text.Json;
using Journal.Core.Dtos;
using Journal.Core.Models;
using Journal.Core.Services.Sidecar;
namespace Journal.Core.Services;
namespace Journal.Core.Services.Speech;
public sealed class PythonSidecarSpeechService : ISpeechBridgeService
{

View File

@ -1,4 +1,4 @@
namespace Journal.Core.Services;
namespace Journal.Core.Services.Vault;
public interface IVaultCryptoService
{

View File

@ -1,4 +1,4 @@
namespace Journal.Core.Services;
namespace Journal.Core.Services.Vault;
public interface IVaultStorageService
{

View File

@ -1,7 +1,7 @@
using System.Security.Cryptography;
using System.Text;
namespace Journal.Core.Services;
namespace Journal.Core.Services.Vault;
public class VaultCryptoService : IVaultCryptoService
{

View File

@ -3,7 +3,7 @@ using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace Journal.Core.Services;
namespace Journal.Core.Services.Vault;
public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageService
{

View File

@ -1,6 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Journal.Core;
using Journal.Core.Services;
using Journal.Core.Services.Sidecar;
var services = new ServiceCollection();
services.AddFragmentServices();

View File

@ -6,7 +6,15 @@ using Journal.Core;
using Journal.Core.Dtos;
using Journal.Core.Models;
using Journal.Core.Repositories;
using Journal.Core.Services;
using Journal.Core.Services.Ai;
using Journal.Core.Services.Config;
using Journal.Core.Services.Database;
using Journal.Core.Services.Entries;
using Journal.Core.Services.Fragments;
using Journal.Core.Services.Logging;
using Journal.Core.Services.Speech;
using Journal.Core.Services.Sidecar;
using Journal.Core.Services.Vault;
var tests = new List<(string Name, Func<Task> Run)>
{
@ -106,16 +114,22 @@ static FragmentService NewService()
return new FragmentService(repo);
}
static Entry NewEntry() => new(
NewService(),
new EntrySearchService(),
new VaultStorageService(new VaultCryptoService()),
new JournalDatabaseService(new JournalConfigService()),
new JournalConfigService(),
new DisabledAiService("none"),
new DisabledSpeechBridgeService("none"),
new EntryFileService(new DiskEntryFileRepository()),
new CommandLogger());
static Entry NewEntry()
{
var dbService = new JournalDatabaseService(new JournalConfigService());
var session = new DatabaseSessionService(dbService);
return new Entry(
NewService(),
new EntrySearchService(),
new VaultStorageService(new VaultCryptoService()),
dbService,
session,
new JournalConfigService(),
new DisabledAiService("none"),
new DisabledSpeechBridgeService("none"),
new EntryFileService(new DiskEntryFileRepository()),
new CommandLogger());
}
static IJournalDatabaseService NewDatabaseService() => new JournalDatabaseService(new JournalConfigService());
@ -164,15 +178,27 @@ static async Task TestUpdateRejectsWhitespaceTypeAsync()
static async Task TestFileRepositoryPersistsAsync()
{
var tempRoot = Path.Combine(Path.GetTempPath(), "journal-smoke", Guid.NewGuid().ToString("N"));
var storePath = Path.Combine(tempRoot, "fragments.json");
var dataDir = Path.Combine(tempRoot, "data");
Directory.CreateDirectory(dataDir);
const string password = "smoke-test-password";
try
{
IFragmentRepository repo1 = new FileFragmentRepository(storePath);
// Set up encrypted DB session
var configService = new JournalConfigService();
var dbService = new JournalDatabaseService(configService);
// First session: create a fragment
using var session1 = new DatabaseSessionService(dbService);
session1.SetPassword(password, dataDir);
var repo1 = new SqliteFragmentRepository(session1);
var service1 = new FragmentService(repo1);
var created = await service1.CreateAsync(new CreateFragmentDto("!TRIGGER", "persist me", ["tag1"]));
IFragmentRepository repo2 = new FileFragmentRepository(storePath);
// Second session: verify persistence
using var session2 = new DatabaseSessionService(dbService);
session2.SetPassword(password, dataDir);
var repo2 = new SqliteFragmentRepository(session2);
var service2 = new FragmentService(repo2);
var loaded = await service2.GetByIdAsync(created.Id);

View File

@ -29,34 +29,56 @@ The 16 private payload/result records that were inside `Entry.cs` are now in `Dt
### 8. Moved database result records to `Dtos/DatabaseDtos.cs` (new file)
`JournalDatabaseStatus` and `JournalDatabaseHydrationResult` moved from `IJournalDatabaseService.cs` to `Dtos/DatabaseDtos.cs` for consistency with the other DTO files.
### 9. Moved fragment storage to encrypted SQLCipher database
Standalone fragments were previously stored in an unencrypted JSON file (`fragments.json`), then briefly in an unencrypted SQLite database. For medical/sensitive use cases, fragments are now stored in the **existing encrypted SQLCipher database** (`journal_cache.db`) alongside entries, sections, and tags.
- Updated `fragments` table schema: `entry_id` is now nullable, added `guid TEXT UNIQUE` column. Standalone fragments use `guid` + `entry_id IS NULL`; entry-linked fragments use `entry_id` + `guid NULL`.
- `SqliteFragmentRepository` now depends on `IDatabaseSessionService` instead of managing its own database connection.
- Tags use the shared normalized `tags` + `fragment_tags` tables (join via integer IDs).
- Fragment CRUD requires the database to be unlocked first (via `vault.load_all` or `db.hydrate_workspace`).
### 10. Added `IDatabaseSessionService` + `DatabaseSessionService` (new files)
A singleton that stores the encryption password in memory after authentication. When `vault.load_all` or `db.hydrate_workspace` is called, the session stores the password and lazily opens/caches an encrypted SQLCipher connection. All encrypted database consumers (currently `SqliteFragmentRepository`) use this shared session.
### 11. Organized Services directory into domain modules
The flat `Services/` directory (28 files) was reorganized into 9 subdirectories with dedicated namespaces:
- `Services/Ai/``IAiService`, `DisabledAiService`, `PythonSidecarAiService`
- `Services/Config/``IJournalConfigService`, `JournalConfigService`
- `Services/Database/``IJournalDatabaseService`, `JournalDatabaseService`, `IDatabaseSessionService`, `DatabaseSessionService`
- `Services/Entries/``IEntryFileService`, `EntryFileService`, `IEntrySearchService`, `EntrySearchService`, `JournalParser`, `HtmlSanitizer`
- `Services/Fragments/``IFragmentService`, `FragmentService`
- `Services/Logging/``CommandLogger`, `LogRedactor`
- `Services/Sidecar/``PythonSidecarClient`, `SidecarCli`
- `Services/Speech/``ISpeechBridgeService`, `DisabledSpeechBridgeService`, `PythonSidecarSpeechService`
- `Services/Vault/``IVaultCryptoService`, `VaultCryptoService`, `IVaultStorageService`, `VaultStorageService`
Each subdirectory has its own namespace (e.g. `Journal.Core.Services.Ai`). All consumer files updated with explicit using statements.
## Files Created
- `Journal.Core/Services/HtmlSanitizer.cs`
- `Journal.Core/Services/CommandLogger.cs`
- `Journal.Core/Services/IEntryFileService.cs`
- `Journal.Core/Services/EntryFileService.cs`
- `Journal.Core/Services/PythonSidecarClient.cs`
- `Journal.Core/Services/Entries/HtmlSanitizer.cs`
- `Journal.Core/Services/Logging/CommandLogger.cs`
- `Journal.Core/Services/Entries/IEntryFileService.cs`
- `Journal.Core/Services/Entries/EntryFileService.cs`
- `Journal.Core/Services/Sidecar/PythonSidecarClient.cs`
- `Journal.Core/Repositories/IEntryFileRepository.cs`
- `Journal.Core/Repositories/DiskEntryFileRepository.cs`
- `Journal.Core/Repositories/SqliteFragmentRepository.cs`
- `Journal.Core/Dtos/CommandDtos.cs`
- `Journal.Core/Dtos/DatabaseDtos.cs`
- `Journal.Core/Services/Database/IDatabaseSessionService.cs`
- `Journal.Core/Services/Database/DatabaseSessionService.cs`
## Files Modified
- `Journal.Core/Entry.cs` — slimmed to thin dispatcher
- `Journal.Core/Services/PythonSidecarAiService.cs` — delegates to PythonSidecarClient
- `Journal.Core/Services/PythonSidecarSpeechService.cs` — delegates to PythonSidecarClient
- `Journal.Core/Services/IJournalDatabaseService.cs` — result records moved to Dtos
- `Journal.Core/Services/JournalDatabaseService.cs` — added Dtos using
- `Journal.Core/ServiceCollectionExtensions.cs` — registers new services and repository
- `Journal.SmokeTests/Program.cs` — updated NewEntry() with new dependencies
## What Was NOT Changed
- **Fragment module** — already clean, untouched
- **Config module** — singleton reader, no changes needed
- **Vault module** — already well-separated (crypto/storage), untouched
- **AI/Speech interfaces and disabled variants** — untouched (only the sidecar implementations were refactored)
- **Search module** — stateless query service, no repository needed
- **All test logic** — no assertions or test behavior changed
- `Journal.Core/Entry.cs` — slimmed to thin dispatcher, wired `IDatabaseSessionService`
- `Journal.Core/Services/Ai/PythonSidecarAiService.cs` — delegates to PythonSidecarClient
- `Journal.Core/Services/Speech/PythonSidecarSpeechService.cs` — delegates to PythonSidecarClient
- `Journal.Core/Services/Database/IJournalDatabaseService.cs` — result records moved to Dtos
- `Journal.Core/Services/Database/JournalDatabaseService.cs` — schema updated (nullable entry_id, guid column)
- `Journal.Core/ServiceCollectionExtensions.cs` — registers all new services, updated namespaces
- `Journal.SmokeTests/Program.cs` — updated with new dependencies and encrypted DB persistence test
- `Journal.Sidecar/App.cs` — updated namespace imports
## Verification
- All 4 projects build successfully
- 70/70 smoke tests pass (5 Python sidecar tests fail only when Python is not installed on the machine, which is pre-existing)
- 65/70 smoke tests pass (5 Python sidecar tests fail only when Python is not installed on the machine, which is pre-existing)