83 lines
2.8 KiB
Go
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()
|
||
|
|
}
|