forgejo-mcp-broker/internal/store/store.go

83 lines
2.8 KiB
Go
Raw Normal View History

// 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()
}