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
83
internal/store/store.go
Normal file
83
internal/store/store.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// 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()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue