feat(store): SQLite with embedded migrations (forgejo-mcp-broker-9jh)

Implements internal/store on top of modernc.org/sqlite (pure-Go, no CGO).
Open applies any pending migrations, Close releases the handle, Ping
underpins /healthz.

Migration design:
- Files embedded via embed.FS under migrations/NNNN_name.sql
- schema_migrations table tracks applied versions; re-open is a no-op
- Each migration runs in its own transaction: no partial commits
- loadMigrations takes an fs.FS so tests can inject synthetic migration
  sets to exercise rollback and conflict paths

Connection pragmas (set via DSN so they apply to every pooled conn):
- journal_mode=WAL — better reader/writer concurrency
- foreign_keys=ON — off by default in SQLite, we always want them
- busy_timeout=5000 — absorb brief contention without surfacing SQLITE_BUSY
- synchronous=NORMAL — standard WAL pairing

Phase 1 schema (0001_initial.sql) is minimal: a broker_meta table with a
schema_version row. Real OAuth tables ship in phase 2.

Tests: 90.1% coverage across public API and internal migration runner,
including bad SQL rollback, PK-conflict record-step failure, and scan
errors on malformed schema_migrations rows.

Closes forgejo-mcp-broker-9jh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-04-24 17:22:47 +02:00
commit df2253398b
10 changed files with 738 additions and 8 deletions

View file

@ -0,0 +1,100 @@
package store
import (
"strings"
"testing"
"testing/fstest"
)
func TestParseMigrationVersion(t *testing.T) {
cases := []struct {
name string
want int
wantErr string
}{
{"0001_initial.sql", 1, ""},
{"0042_add_oauth_clients.sql", 42, ""},
{"0001.sql", 0, "expected NNNN_name.sql"}, // no underscore
{"_orphan.sql", 0, "expected NNNN_name.sql"}, // empty version
{"abc_notnumeric.sql", 0, "non-numeric version"}, // not a number
{"0000_zero.sql", 0, "version must be > 0"}, // zero rejected
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parseMigrationVersion(tc.name)
switch {
case tc.wantErr == "" && err != nil:
t.Errorf("unexpected error: %v", err)
case tc.wantErr != "" && err == nil:
t.Errorf("expected error containing %q, got nil", tc.wantErr)
case tc.wantErr != "" && !strings.Contains(err.Error(), tc.wantErr):
t.Errorf("error %q does not contain %q", err, tc.wantErr)
case tc.wantErr == "" && got != tc.want:
t.Errorf("got %d, want %d", got, tc.want)
}
})
}
}
func TestLoadMigrations_SortsByVersion(t *testing.T) {
fsys := fstest.MapFS{
"m/0003_c.sql": {Data: []byte("-- c")},
"m/0001_a.sql": {Data: []byte("-- a")},
"m/0002_b.sql": {Data: []byte("-- b")},
}
got, err := loadMigrations(fsys, "m")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 3 {
t.Fatalf("got %d migrations, want 3", len(got))
}
for i, want := range []int{1, 2, 3} {
if got[i].version != want {
t.Errorf("migrations[%d].version = %d, want %d", i, got[i].version, want)
}
}
}
func TestLoadMigrations_SkipsNonSQL(t *testing.T) {
fsys := fstest.MapFS{
"m/0001_a.sql": {Data: []byte("-- a")},
"m/README.md": {Data: []byte("ignore me")},
"m/notes.txt": {Data: []byte("ignore me too")},
}
got, err := loadMigrations(fsys, "m")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 1 {
t.Errorf("got %d migrations, want 1 (non-.sql must be skipped)", len(got))
}
}
func TestLoadMigrations_RejectsDuplicateVersion(t *testing.T) {
fsys := fstest.MapFS{
"m/0001_a.sql": {Data: []byte("-- a")},
"m/0001_b.sql": {Data: []byte("-- b")},
}
_, err := loadMigrations(fsys, "m")
if err == nil || !strings.Contains(err.Error(), "duplicate migration version 1") {
t.Errorf("want duplicate-version error, got %v", err)
}
}
func TestLoadMigrations_RejectsBadFilename(t *testing.T) {
fsys := fstest.MapFS{
"m/nogood.sql": {Data: []byte("-- bad")},
}
_, err := loadMigrations(fsys, "m")
if err == nil || !strings.Contains(err.Error(), "expected NNNN_name.sql") {
t.Errorf("want bad-filename error, got %v", err)
}
}
func TestLoadMigrations_MissingDir(t *testing.T) {
_, err := loadMigrations(fstest.MapFS{}, "nope")
if err == nil || !strings.Contains(err.Error(), "read migrations dir") {
t.Errorf("want missing-dir error, got %v", err)
}
}