feat(data,worker): add db schema init and signalr hub skeleton

Data layer: Paths helper with ~/%USERPROFILE% expansion, SqliteConnectionFactory
(WAL + foreign keys), SchemaInitializer that applies the embedded schema.sql, and
POCO entities for lists/tasks/tags/worktrees.

Worker: WorkerConfig loader (~/.todo-app/worker.config.json with defaults),
WorkerHub exposing Ping(), and Program.cs wiring Kestrel to 127.0.0.1:<port>,
SignalR at /hub, schema applied on startup.

Pins Microsoft.Data.Sqlite and Microsoft.Extensions.Hosting to 8.x for net8.0
compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mika Kuns
2026-04-13 12:00:47 +02:00
parent 71cfa64427
commit f81ef02273
12 changed files with 298 additions and 3 deletions

View File

@@ -7,7 +7,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.5" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.11" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="..\..\schema\schema.sql" Link="schema.sql" LogicalName="ClaudeDo.Data.schema.sql" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
namespace ClaudeDo.Data.Models;
public sealed class ListEntity
{
public required string Id { get; init; }
public required string Name { get; set; }
public required DateTime CreatedAt { get; init; }
public string? WorkingDir { get; set; }
public string DefaultCommitType { get; set; } = "chore";
}

View File

@@ -0,0 +1,7 @@
namespace ClaudeDo.Data.Models;
public sealed class TagEntity
{
public long Id { get; init; }
public required string Name { get; set; }
}

View File

@@ -0,0 +1,26 @@
namespace ClaudeDo.Data.Models;
public enum TaskStatus
{
Manual,
Queued,
Running,
Done,
Failed,
}
public sealed class TaskEntity
{
public required string Id { get; init; }
public required string ListId { get; init; }
public required string Title { get; set; }
public string? Description { get; set; }
public TaskStatus Status { get; set; } = TaskStatus.Manual;
public DateTime? ScheduledFor { get; set; }
public string? Result { get; set; }
public string? LogPath { get; set; }
public required DateTime CreatedAt { get; init; }
public DateTime? StartedAt { get; set; }
public DateTime? FinishedAt { get; set; }
public string CommitType { get; set; } = "chore";
}

View File

@@ -0,0 +1,21 @@
namespace ClaudeDo.Data.Models;
public enum WorktreeState
{
Active,
Merged,
Discarded,
Kept,
}
public sealed class WorktreeEntity
{
public required string TaskId { get; init; }
public required string Path { get; set; }
public required string BranchName { get; set; }
public required string BaseCommit { get; set; }
public string? HeadCommit { get; set; }
public string? DiffStat { get; set; }
public WorktreeState State { get; set; } = WorktreeState.Active;
public required DateTime CreatedAt { get; init; }
}

View File

@@ -0,0 +1,31 @@
namespace ClaudeDo.Data;
public static class Paths
{
/// <summary>
/// Expands a leading "~" or "%USERPROFILE%" and returns an absolute path.
/// Relative paths are resolved against <paramref name="baseDir"/> (default: current directory).
/// </summary>
public static string Expand(string path, string? baseDir = null)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Path must not be empty.", nameof(path));
var expanded = Environment.ExpandEnvironmentVariables(path);
if (expanded.StartsWith("~", StringComparison.Ordinal))
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
expanded = home + expanded[1..];
}
if (!Path.IsPathRooted(expanded))
expanded = Path.GetFullPath(expanded, baseDir ?? Environment.CurrentDirectory);
return Path.GetFullPath(expanded);
}
/// <summary>~/.todo-app — parent directory for db, logs, config, sandbox, worktrees.</summary>
public static string AppDataRoot() =>
Expand("~/.todo-app");
}

View File

@@ -0,0 +1,41 @@
using System.Reflection;
using Microsoft.Data.Sqlite;
namespace ClaudeDo.Data;
/// <summary>
/// Applies the embedded schema.sql script. Safe to call on every start — the script uses
/// IF NOT EXISTS / INSERT OR IGNORE.
/// </summary>
public static class SchemaInitializer
{
private const string ResourceName = "ClaudeDo.Data.schema.sql";
public static void Apply(SqliteConnectionFactory factory)
{
using var conn = factory.Open();
ApplyTo(conn);
}
public static void ApplyTo(SqliteConnection conn)
{
var sql = LoadScript();
using var tx = conn.BeginTransaction();
using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
tx.Commit();
}
private static string LoadScript()
{
var asm = typeof(SchemaInitializer).Assembly;
using var stream = asm.GetManifestResourceStream(ResourceName)
?? throw new InvalidOperationException(
$"Embedded resource '{ResourceName}' not found in {asm.GetName().Name}. " +
$"Available: {string.Join(", ", asm.GetManifestResourceNames())}");
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
}

View File

@@ -0,0 +1,48 @@
using Microsoft.Data.Sqlite;
namespace ClaudeDo.Data;
/// <summary>
/// Opens <see cref="SqliteConnection"/> instances pointed at <see cref="DbPath"/>.
/// First call ensures the parent directory exists, enables WAL and foreign keys.
/// </summary>
public sealed class SqliteConnectionFactory
{
public string DbPath { get; }
private readonly string _connectionString;
private int _walApplied;
public SqliteConnectionFactory(string dbPath)
{
DbPath = Paths.Expand(dbPath);
Directory.CreateDirectory(Path.GetDirectoryName(DbPath)!);
_connectionString = new SqliteConnectionStringBuilder
{
DataSource = DbPath,
Mode = SqliteOpenMode.ReadWriteCreate,
Cache = SqliteCacheMode.Shared,
}.ToString();
}
public SqliteConnection Open()
{
var conn = new SqliteConnection(_connectionString);
conn.Open();
// WAL is a persistent DB-level setting; applying it once per process is enough,
// but idempotent so we do it defensively on the first connection we hand out.
if (Interlocked.Exchange(ref _walApplied, 1) == 0)
{
using var pragma = conn.CreateCommand();
pragma.CommandText = "PRAGMA journal_mode=WAL;";
pragma.ExecuteNonQuery();
}
using var fk = conn.CreateCommand();
fk.CommandText = "PRAGMA foreign_keys=ON;";
fk.ExecuteNonQuery();
return conn;
}
}