forgejo-mcp-broker/internal/store/store.go
Ole-Morten Duesund df2253398b 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>
2026-04-24 17:22:47 +02:00

83 lines
2.8 KiB
Go

// Package store opens the SQLite-backed persistence layer used by the broker
// for OAuth clients, authorization codes, access tokens, and refresh tokens.
//
// Migrations are embedded under migrations/NNNN_name.sql and applied in
// lexicographic order on Open. A schema_migrations table tracks which
// versions have been applied; re-opening an up-to-date database is a no-op.
//
// The current schema (phase 1) only contains a broker_meta table. The real
// OAuth tables ship with phase 2.
package store
import (
"context"
"database/sql"
"fmt"
"net/url"
"path/filepath"
_ "modernc.org/sqlite" // registers the "sqlite" database/sql driver
)
// Store wraps a *sql.DB opened against a SQLite file, with schema migrations
// already applied. It is safe for concurrent use.
type Store struct {
db *sql.DB
path string
}
// Open opens (or creates) the SQLite database at path, enables WAL mode and
// foreign-key enforcement, then applies any pending schema migrations. The
// returned Store must be closed with Close to release file handles.
func Open(ctx context.Context, path string) (*Store, error) {
abs, err := filepath.Abs(path)
if err != nil {
return nil, fmt.Errorf("store: resolve path %q: %w", path, err)
}
db, err := sql.Open("sqlite", buildDSN(abs))
if err != nil {
return nil, fmt.Errorf("store: open %q: %w", abs, err)
}
// Fail early if the path is unreachable (e.g. parent dir missing). sql.Open
// is lazy and won't touch the file until first use.
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, fmt.Errorf("store: ping %q: %w", abs, err)
}
s := &Store{db: db, path: abs}
if err := s.migrate(ctx, migrationsFS, "migrations"); err != nil {
_ = db.Close()
return nil, fmt.Errorf("store: migrate %q: %w", abs, err)
}
return s, nil
}
// DB exposes the underlying *sql.DB for packages that need to issue queries.
func (s *Store) DB() *sql.DB { return s.db }
// Path returns the absolute path of the SQLite file.
func (s *Store) Path() string { return s.path }
// Ping verifies connectivity. Suitable for use in /healthz.
func (s *Store) Ping(ctx context.Context) error {
return s.db.PingContext(ctx)
}
// Close releases the underlying database handle.
func (s *Store) Close() error { return s.db.Close() }
// buildDSN constructs a modernc.org/sqlite DSN with the pragmas we always
// want: WAL for better reader/writer concurrency, foreign-key enforcement
// (off by default in SQLite), and a 5-second busy timeout to absorb brief
// contention without surfacing SQLITE_BUSY to the app.
func buildDSN(path string) string {
q := url.Values{}
q.Add("_pragma", "journal_mode(WAL)")
q.Add("_pragma", "foreign_keys(ON)")
q.Add("_pragma", "busy_timeout(5000)")
q.Add("_pragma", "synchronous(NORMAL)")
return "file:" + path + "?" + q.Encode()
}