Skip to content

Commit aa691fe

Browse files
Add lightweight database migration system (#44) (#48)
Replace inline schema constant in DatabaseInitializer with numbered SQL migration files and a SchemaMigrator that tracks applied versions in a schema_version table. Existing databases are handled safely via IF NOT EXISTS clauses in migration 001.
1 parent 3b3d546 commit aa691fe

4 files changed

Lines changed: 194 additions & 102 deletions

File tree

src/SkillServer/Data/DatabaseInitializer.cs

Lines changed: 2 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,11 @@
33
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
44
// </copyright>
55
// -----------------------------------------------------------------------
6-
using Dapper;
7-
using Microsoft.Data.Sqlite;
86

97
namespace SkillServer.Data;
108

119
/// <summary>
12-
/// Initializes the SQLite database schema.
10+
/// Initializes the SQLite database schema via numbered migrations.
1311
/// </summary>
1412
public sealed class DatabaseInitializer
1513
{
@@ -28,104 +26,6 @@ public DatabaseInitializer(IConfiguration configuration, ILogger<DatabaseInitial
2826

2927
public async Task InitializeAsync(CancellationToken ct = default)
3028
{
31-
await using var connection = new SqliteConnection(_connectionString);
32-
await connection.OpenAsync(ct);
33-
34-
_logger.LogInformation("Initializing database schema...");
35-
36-
await connection.ExecuteAsync(Schema);
37-
38-
// Populate FTS from existing data (migration step for existing databases)
39-
await connection.ExecuteAsync("""
40-
INSERT INTO skills_fts(rowid, name, description, category)
41-
SELECT s.id, s.name, sv.description, COALESCE(sv.category, '')
42-
FROM skills s
43-
JOIN skill_versions sv ON sv.skill_id = s.id AND sv.is_latest = 1
44-
WHERE s.id NOT IN (SELECT rowid FROM skills_fts);
45-
""");
46-
47-
_logger.LogInformation("Database schema initialized");
29+
await SchemaMigrator.MigrateAsync(_connectionString, _logger, ct);
4830
}
49-
50-
private const string Schema = """
51-
CREATE TABLE IF NOT EXISTS skills (
52-
id INTEGER PRIMARY KEY AUTOINCREMENT,
53-
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
54-
created_at TEXT NOT NULL,
55-
updated_at TEXT NOT NULL
56-
);
57-
58-
CREATE TABLE IF NOT EXISTS skill_versions (
59-
id INTEGER PRIMARY KEY AUTOINCREMENT,
60-
skill_id INTEGER NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
61-
version TEXT NOT NULL,
62-
description TEXT NOT NULL,
63-
category TEXT,
64-
skill_type TEXT NOT NULL CHECK(skill_type IN ('skill-md', 'archive')),
65-
sha256 TEXT NOT NULL,
66-
size_bytes INTEGER NOT NULL,
67-
published_at TEXT NOT NULL,
68-
is_latest INTEGER NOT NULL DEFAULT 0,
69-
UNIQUE(skill_id, version)
70-
);
71-
72-
CREATE TABLE IF NOT EXISTS skill_files (
73-
id INTEGER PRIMARY KEY AUTOINCREMENT,
74-
skill_version_id INTEGER NOT NULL REFERENCES skill_versions(id) ON DELETE CASCADE,
75-
relative_path TEXT NOT NULL,
76-
sha256 TEXT NOT NULL,
77-
size_bytes INTEGER NOT NULL,
78-
UNIQUE(skill_version_id, relative_path)
79-
);
80-
81-
CREATE INDEX IF NOT EXISTS idx_skill_versions_latest
82-
ON skill_versions(skill_id) WHERE is_latest = 1;
83-
84-
CREATE INDEX IF NOT EXISTS idx_skill_versions_sha256
85-
ON skill_versions(sha256);
86-
87-
CREATE INDEX IF NOT EXISTS idx_skill_files_sha256
88-
ON skill_files(sha256);
89-
90-
CREATE TABLE IF NOT EXISTS api_keys (
91-
id INTEGER PRIMARY KEY AUTOINCREMENT,
92-
label TEXT NOT NULL,
93-
key_hash TEXT NOT NULL UNIQUE,
94-
created_at TEXT NOT NULL,
95-
expires_at TEXT
96-
);
97-
98-
CREATE INDEX IF NOT EXISTS idx_api_keys_hash
99-
ON api_keys(key_hash);
100-
101-
-- Full-text search index for skill discovery
102-
CREATE VIRTUAL TABLE IF NOT EXISTS skills_fts USING fts5(
103-
name,
104-
description,
105-
category
106-
);
107-
108-
-- Keep FTS in sync when new versions are published
109-
CREATE TRIGGER IF NOT EXISTS trg_skills_fts_insert
110-
AFTER INSERT ON skill_versions
111-
WHEN NEW.is_latest = 1
112-
BEGIN
113-
DELETE FROM skills_fts WHERE rowid = NEW.skill_id;
114-
INSERT INTO skills_fts(rowid, name, description, category)
115-
SELECT NEW.skill_id, s.name, NEW.description, COALESCE(NEW.category, '')
116-
FROM skills s WHERE s.id = NEW.skill_id;
117-
END;
118-
119-
-- Keep FTS in sync when versions are deleted
120-
CREATE TRIGGER IF NOT EXISTS trg_skills_fts_delete
121-
AFTER DELETE ON skill_versions
122-
BEGIN
123-
DELETE FROM skills_fts WHERE rowid = OLD.skill_id;
124-
INSERT INTO skills_fts(rowid, name, description, category)
125-
SELECT s.id, s.name, sv.description, COALESCE(sv.category, '')
126-
FROM skills s
127-
JOIN skill_versions sv ON sv.skill_id = s.id AND sv.is_latest = 1
128-
WHERE s.id = OLD.skill_id;
129-
END;
130-
""";
13131
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// -----------------------------------------------------------------------
2+
// <copyright file="SchemaMigrator.cs" company="Petabridge, LLC">
3+
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
4+
// </copyright>
5+
// -----------------------------------------------------------------------
6+
7+
using Microsoft.Data.Sqlite;
8+
9+
namespace SkillServer.Data;
10+
11+
public static class SchemaMigrator
12+
{
13+
public static async Task MigrateAsync(string connectionString, ILogger logger,
14+
CancellationToken ct = default)
15+
{
16+
logger.LogInformation("Starting database schema migration...");
17+
18+
await using var conn = new SqliteConnection(connectionString);
19+
await conn.OpenAsync(ct);
20+
21+
await using (var cmd = conn.CreateCommand())
22+
{
23+
cmd.CommandText = """
24+
CREATE TABLE IF NOT EXISTS schema_version (
25+
version INT PRIMARY KEY,
26+
name TEXT NOT NULL,
27+
applied_at TEXT NOT NULL
28+
);
29+
""";
30+
await cmd.ExecuteNonQueryAsync(ct);
31+
}
32+
33+
var appliedMigrations = new HashSet<int>();
34+
await using (var cmd = conn.CreateCommand())
35+
{
36+
cmd.CommandText = "SELECT version FROM schema_version";
37+
await using var reader = await cmd.ExecuteReaderAsync(ct);
38+
while (await reader.ReadAsync(ct))
39+
{
40+
appliedMigrations.Add(reader.GetInt32(0));
41+
}
42+
}
43+
44+
var migrationsDir = Path.Combine(AppContext.BaseDirectory, "migrations");
45+
if (!Directory.Exists(migrationsDir))
46+
{
47+
logger.LogWarning("Migrations directory not found: {MigrationsDir}", migrationsDir);
48+
return;
49+
}
50+
51+
var migrationFiles = Directory.GetFiles(migrationsDir, "*.sql")
52+
.Select(f => new { Path = f, Name = Path.GetFileName(f) })
53+
.Select(f => new { f.Path, f.Name, Version = ParseVersion(f.Name) })
54+
.Where(f => f.Version != null)
55+
.OrderBy(f => f.Version)
56+
.ToList();
57+
58+
foreach (var migration in migrationFiles)
59+
{
60+
var version = migration.Version!.Value;
61+
if (appliedMigrations.Contains(version))
62+
{
63+
logger.LogInformation("Migration {Name} already applied", migration.Name);
64+
continue;
65+
}
66+
67+
logger.LogInformation("Applying migration: {Name}", migration.Name);
68+
var sql = await File.ReadAllTextAsync(migration.Path, ct);
69+
70+
await using (var cmd = conn.CreateCommand())
71+
{
72+
cmd.CommandText = sql;
73+
await cmd.ExecuteNonQueryAsync(ct);
74+
}
75+
76+
await using (var cmd = conn.CreateCommand())
77+
{
78+
cmd.CommandText = "INSERT INTO schema_version (version, name, applied_at) VALUES (@version, @name, @appliedAt)";
79+
cmd.Parameters.AddWithValue("@version", version);
80+
cmd.Parameters.AddWithValue("@name", migration.Name);
81+
cmd.Parameters.AddWithValue("@appliedAt", DateTimeOffset.UtcNow.ToString("o"));
82+
await cmd.ExecuteNonQueryAsync(ct);
83+
}
84+
85+
logger.LogInformation("Migration {Name} applied successfully", migration.Name);
86+
}
87+
88+
logger.LogInformation("Database schema migration completed successfully");
89+
}
90+
91+
internal static int? ParseVersion(string fileName)
92+
{
93+
var parts = fileName.Split('_', 2);
94+
if (parts.Length < 2) return null;
95+
if (int.TryParse(parts[0], out var version)) return version;
96+
return null;
97+
}
98+
}

src/SkillServer/SkillServer.csproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,10 @@
3939
<PackageReference Include="SharpZipLib" />
4040
</ItemGroup>
4141

42+
<ItemGroup>
43+
<None Update="migrations\**\*.sql">
44+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
45+
</None>
46+
</ItemGroup>
47+
4248
</Project>
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
-- 001_initial_schema.sql: Initial schema (retrofitted from DatabaseInitializer.cs)
2+
3+
CREATE TABLE IF NOT EXISTS skills (
4+
id INTEGER PRIMARY KEY AUTOINCREMENT,
5+
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
6+
created_at TEXT NOT NULL,
7+
updated_at TEXT NOT NULL
8+
);
9+
10+
CREATE TABLE IF NOT EXISTS skill_versions (
11+
id INTEGER PRIMARY KEY AUTOINCREMENT,
12+
skill_id INTEGER NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
13+
version TEXT NOT NULL,
14+
description TEXT NOT NULL,
15+
category TEXT,
16+
skill_type TEXT NOT NULL CHECK(skill_type IN ('skill-md', 'archive')),
17+
sha256 TEXT NOT NULL,
18+
size_bytes INTEGER NOT NULL,
19+
published_at TEXT NOT NULL,
20+
is_latest INTEGER NOT NULL DEFAULT 0,
21+
UNIQUE(skill_id, version)
22+
);
23+
24+
CREATE TABLE IF NOT EXISTS skill_files (
25+
id INTEGER PRIMARY KEY AUTOINCREMENT,
26+
skill_version_id INTEGER NOT NULL REFERENCES skill_versions(id) ON DELETE CASCADE,
27+
relative_path TEXT NOT NULL,
28+
sha256 TEXT NOT NULL,
29+
size_bytes INTEGER NOT NULL,
30+
UNIQUE(skill_version_id, relative_path)
31+
);
32+
33+
CREATE INDEX IF NOT EXISTS idx_skill_versions_latest
34+
ON skill_versions(skill_id) WHERE is_latest = 1;
35+
36+
CREATE INDEX IF NOT EXISTS idx_skill_versions_sha256
37+
ON skill_versions(sha256);
38+
39+
CREATE INDEX IF NOT EXISTS idx_skill_files_sha256
40+
ON skill_files(sha256);
41+
42+
CREATE TABLE IF NOT EXISTS api_keys (
43+
id INTEGER PRIMARY KEY AUTOINCREMENT,
44+
label TEXT NOT NULL,
45+
key_hash TEXT NOT NULL UNIQUE,
46+
created_at TEXT NOT NULL,
47+
expires_at TEXT
48+
);
49+
50+
CREATE INDEX IF NOT EXISTS idx_api_keys_hash
51+
ON api_keys(key_hash);
52+
53+
-- Full-text search index for skill discovery
54+
CREATE VIRTUAL TABLE IF NOT EXISTS skills_fts USING fts5(
55+
name,
56+
description,
57+
category
58+
);
59+
60+
-- Keep FTS in sync when new versions are published
61+
CREATE TRIGGER IF NOT EXISTS trg_skills_fts_insert
62+
AFTER INSERT ON skill_versions
63+
WHEN NEW.is_latest = 1
64+
BEGIN
65+
DELETE FROM skills_fts WHERE rowid = NEW.skill_id;
66+
INSERT INTO skills_fts(rowid, name, description, category)
67+
SELECT NEW.skill_id, s.name, NEW.description, COALESCE(NEW.category, '')
68+
FROM skills s WHERE s.id = NEW.skill_id;
69+
END;
70+
71+
-- Keep FTS in sync when versions are deleted
72+
CREATE TRIGGER IF NOT EXISTS trg_skills_fts_delete
73+
AFTER DELETE ON skill_versions
74+
BEGIN
75+
DELETE FROM skills_fts WHERE rowid = OLD.skill_id;
76+
INSERT INTO skills_fts(rowid, name, description, category)
77+
SELECT s.id, s.name, sv.description, COALESCE(sv.category, '')
78+
FROM skills s
79+
JOIN skill_versions sv ON sv.skill_id = s.id AND sv.is_latest = 1
80+
WHERE s.id = OLD.skill_id;
81+
END;
82+
83+
-- Backfill FTS from existing data (idempotent for existing databases)
84+
INSERT INTO skills_fts(rowid, name, description, category)
85+
SELECT s.id, s.name, sv.description, COALESCE(sv.category, '')
86+
FROM skills s
87+
JOIN skill_versions sv ON sv.skill_id = s.id AND sv.is_latest = 1
88+
WHERE s.id NOT IN (SELECT rowid FROM skills_fts);

0 commit comments

Comments
 (0)