feat(persistence): SQLite factory + versioned Migrator

This commit is contained in:
Claude
2026-08-28 11:30:37 +00:00
parent 878a195774
commit e954732165
4 changed files with 133 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
using Microsoft.Data.Sqlite;
namespace VolcanicCore.Persistence.Migrations;
public interface IMigration
{
int Version { get; } // starts at 1, strictly monotonic
void Apply(SqliteConnection conn);
}

View File

@@ -0,0 +1,42 @@
using Microsoft.Data.Sqlite;
using VolcanicCore.Persistence.Migrations;
namespace VolcanicCore.Persistence;
public sealed class Migrator
{
private readonly IReadOnlyList<IMigration> _migrations;
public Migrator(IEnumerable<IMigration> migrations)
{
_migrations = migrations.OrderBy(m => m.Version).ToArray();
for (var i = 1; i < _migrations.Count; i++)
if (_migrations[i].Version == _migrations[i - 1].Version)
throw new InvalidOperationException($"Duplicate migration version {_migrations[i].Version}");
}
public void Apply(SqliteConnection conn)
{
var current = ReadUserVersion(conn);
foreach (var m in _migrations)
{
if (m.Version <= current) continue;
using var tx = conn.BeginTransaction();
m.Apply(conn);
using (var cmd = conn.CreateCommand())
{
cmd.Transaction = tx;
cmd.CommandText = $"PRAGMA user_version = {m.Version};";
cmd.ExecuteNonQuery();
}
tx.Commit();
}
}
private static int ReadUserVersion(SqliteConnection conn)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "PRAGMA user_version;";
return Convert.ToInt32(cmd.ExecuteScalar());
}
}

View File

@@ -0,0 +1,30 @@
using Microsoft.Data.Sqlite;
namespace VolcanicCore.Persistence;
public sealed class SqliteConnectionFactory
{
private readonly string _connectionString;
public SqliteConnectionFactory(string filePath)
{
_connectionString = new SqliteConnectionStringBuilder
{
DataSource = filePath,
Cache = SqliteCacheMode.Shared,
Pooling = true
}.ToString();
}
public SqliteConnection Open()
{
var conn = new SqliteConnection(_connectionString);
conn.Open();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;";
cmd.ExecuteNonQuery();
}
return conn;
}
}

View File

@@ -0,0 +1,52 @@
using Microsoft.Data.Sqlite;
using VolcanicCore.Persistence;
using VolcanicCore.Persistence.Migrations;
namespace VolcanicCore.Tests;
public class MigratorTests
{
private sealed class M1 : IMigration
{
public int Version => 1;
public void Apply(SqliteConnection conn)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE TABLE t (id INTEGER PRIMARY KEY);";
cmd.ExecuteNonQuery();
}
}
private sealed class M2 : IMigration
{
public int Version => 2;
public void Apply(SqliteConnection conn)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "ALTER TABLE t ADD COLUMN name TEXT;";
cmd.ExecuteNonQuery();
}
}
[Fact]
public void Migrator_applies_missing_migrations_in_order_and_sets_user_version()
{
using var conn = new SqliteConnection("Data Source=:memory:");
conn.Open();
var mig = new Migrator(new IMigration[] { new M2(), new M1() });
mig.Apply(conn);
Assert.Equal(2, ReadUserVersion(conn));
// Re-applying is a no-op.
mig.Apply(conn);
Assert.Equal(2, ReadUserVersion(conn));
}
private static int ReadUserVersion(SqliteConnection conn)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "PRAGMA user_version;";
return Convert.ToInt32(cmd.ExecuteScalar());
}
}