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:
parent
382356c61c
commit
df2253398b
10 changed files with 738 additions and 8 deletions
170
internal/store/store_internal_test.go
Normal file
170
internal/store/store_internal_test.go
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// openRawDB opens a raw *sql.DB to a fresh temp SQLite file and wraps it in
|
||||
// a Store — without applying the real embedded migrations. Tests use it to
|
||||
// exercise migrate() with synthetic migration sets.
|
||||
func openRawDB(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "broker.db")
|
||||
db, err := sql.Open("sqlite", buildDSN(path))
|
||||
if err != nil {
|
||||
t.Fatalf("sql.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return &Store{db: db, path: path}
|
||||
}
|
||||
|
||||
func TestMigrate_AppliesOnlyPendingVersions(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
s := openRawDB(t)
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
"m/0001_a.sql": {Data: []byte("CREATE TABLE t1 (x INTEGER);")},
|
||||
"m/0002_b.sql": {Data: []byte("CREATE TABLE t2 (y INTEGER);")},
|
||||
}
|
||||
|
||||
if err := s.migrate(ctx, fsys, "m"); err != nil {
|
||||
t.Fatalf("first migrate: %v", err)
|
||||
}
|
||||
|
||||
// Re-running is a no-op; both migrations already recorded.
|
||||
if err := s.migrate(ctx, fsys, "m"); err != nil {
|
||||
t.Fatalf("second migrate: %v", err)
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM schema_migrations`).Scan(&count); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("applied count = %d, want 2", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrate_BadSQLRollsBack(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
s := openRawDB(t)
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
"m/0001_good.sql": {Data: []byte("CREATE TABLE ok (x INTEGER);")},
|
||||
"m/0002_bad.sql": {Data: []byte("THIS IS NOT SQL")},
|
||||
}
|
||||
|
||||
err := s.migrate(ctx, fsys, "m")
|
||||
if err == nil {
|
||||
t.Fatal("expected migrate to fail on bad SQL")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "0002_bad.sql") {
|
||||
t.Errorf("error should mention failing migration: %v", err)
|
||||
}
|
||||
|
||||
// 0001 should have been applied and committed; 0002 should not.
|
||||
var count int
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM schema_migrations`).Scan(&count); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("after failed migrate, applied count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrate_MalformedFilenameFails(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
s := openRawDB(t)
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
"m/no_number.sql": {Data: []byte("SELECT 1;")},
|
||||
}
|
||||
err := s.migrate(ctx, fsys, "m")
|
||||
if err == nil || !strings.Contains(err.Error(), "non-numeric") {
|
||||
t.Errorf("want malformed-filename error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrate_LoadAppliedVersionsOnClosedDBFails(t *testing.T) {
|
||||
// Exercise the error path in loadAppliedVersions by closing the DB
|
||||
// before migrate reads schema_migrations.
|
||||
ctx := t.Context()
|
||||
s := openRawDB(t)
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
CREATE TABLE schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
)`); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
// Close so the subsequent query fails.
|
||||
if err := s.db.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
_, err := loadAppliedVersions(ctx, s.db)
|
||||
if err == nil {
|
||||
t.Error("expected error from loadAppliedVersions on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrate_RecordStepFails_OnPKConflict(t *testing.T) {
|
||||
// A migration that manually inserts its own version row forces
|
||||
// applyMigration's own INSERT into schema_migrations to fail with a
|
||||
// primary-key conflict. Exercises the record-step error branch.
|
||||
ctx := t.Context()
|
||||
s := openRawDB(t)
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
"m/0001_hijack.sql": {Data: []byte(
|
||||
`INSERT INTO schema_migrations (version, name) VALUES (1, 'hijack');`)},
|
||||
}
|
||||
err := s.migrate(ctx, fsys, "m")
|
||||
if err == nil || !strings.Contains(err.Error(), "record") {
|
||||
t.Errorf("want record-fail error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppliedVersions_ScanError(t *testing.T) {
|
||||
// Seed a schema_migrations table with a non-integer version so Scan
|
||||
// into *int fails. Exercises loadAppliedVersions' scan-error branch.
|
||||
ctx := t.Context()
|
||||
s := openRawDB(t)
|
||||
|
||||
if _, err := s.db.ExecContext(ctx, `CREATE TABLE schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
)`); err != nil {
|
||||
t.Fatalf("create table: %v", err)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO schema_migrations VALUES ('not-a-number', 'x', 'now')`); err != nil {
|
||||
t.Fatalf("seed row: %v", err)
|
||||
}
|
||||
|
||||
if _, err := loadAppliedVersions(ctx, s.db); err == nil {
|
||||
t.Error("expected scan error on non-int version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrate_CanceledContext(t *testing.T) {
|
||||
s := openRawDB(t)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
err := s.migrate(ctx, fstest.MapFS{"m/0001_a.sql": {Data: []byte("SELECT 1;")}}, "m")
|
||||
if err == nil {
|
||||
t.Error("expected error on canceled context")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue