Check / check (pull_request) Skipped
SQLite transactions started deferred, so two that both read and then wrote could not both proceed, and SQLite refused the second at once with "database is locked" without waiting. TestCreateUserRaceCondition hit this now and then. The database is now opened with _txlock=immediate, so each transaction takes the write lock when it begins and a second one waits for it, and with _busy_timeout=5000, so that wait lasts up to 5 seconds. The driver already defaults to 5000; it is now stated in the code. Model: opus-5-5
241 lines
5.5 KiB
Go
241 lines
5.5 KiB
Go
// Package database provides SQLite database access with logging.
|
|
package database
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
|
"go.uber.org/fx"
|
|
|
|
"sneak.berlin/go/upaas/internal/config"
|
|
"sneak.berlin/go/upaas/internal/logger"
|
|
)
|
|
|
|
// dataDirPermissions is the file permission for the data directory.
|
|
const dataDirPermissions = 0o750
|
|
|
|
// Params contains dependencies for Database.
|
|
type Params struct {
|
|
fx.In
|
|
|
|
Logger *logger.Logger
|
|
Config *config.Config
|
|
}
|
|
|
|
// Database wraps sql.DB with logging and helper methods.
|
|
type Database struct {
|
|
database *sql.DB
|
|
log *slog.Logger
|
|
params *Params
|
|
}
|
|
|
|
// New creates a new Database instance.
|
|
func New(lifecycle fx.Lifecycle, params Params) (*Database, error) {
|
|
database := &Database{
|
|
log: params.Logger.Get(),
|
|
params: ¶ms,
|
|
}
|
|
|
|
// For testing, if lifecycle is nil, connect immediately
|
|
if lifecycle == nil {
|
|
err := database.connect(context.Background())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return database, nil
|
|
}
|
|
|
|
lifecycle.Append(fx.Hook{
|
|
OnStart: func(ctx context.Context) error {
|
|
return database.connect(ctx)
|
|
},
|
|
OnStop: func(_ context.Context) error {
|
|
return database.close()
|
|
},
|
|
})
|
|
|
|
return database, nil
|
|
}
|
|
|
|
// DB returns the underlying sql.DB for direct access.
|
|
func (d *Database) DB() *sql.DB {
|
|
return d.database
|
|
}
|
|
|
|
// Exec executes a query with logging.
|
|
func (d *Database) Exec(
|
|
ctx context.Context,
|
|
query string,
|
|
args ...any,
|
|
) (sql.Result, error) {
|
|
d.log.Debug("database exec", "query", query, "args", args)
|
|
|
|
result, err := d.database.ExecContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("exec failed: %w", err)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// QueryRow executes a query that returns a single row.
|
|
func (d *Database) QueryRow(ctx context.Context, query string, args ...any) *sql.Row {
|
|
d.log.Debug("database query row", "query", query, "args", args)
|
|
|
|
return d.database.QueryRowContext(ctx, query, args...)
|
|
}
|
|
|
|
// Query executes a query that returns multiple rows.
|
|
func (d *Database) Query(
|
|
ctx context.Context,
|
|
query string,
|
|
args ...any,
|
|
) (*sql.Rows, error) {
|
|
d.log.Debug("database query", "query", query, "args", args)
|
|
|
|
rows, err := d.database.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query failed: %w", err)
|
|
}
|
|
|
|
return rows, nil
|
|
}
|
|
|
|
// BeginTx starts a new transaction.
|
|
func (d *Database) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) {
|
|
d.log.Debug("database begin transaction")
|
|
|
|
transaction, err := d.database.BeginTx(ctx, opts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin transaction failed: %w", err)
|
|
}
|
|
|
|
return transaction, nil
|
|
}
|
|
|
|
// Path returns the database file path.
|
|
func (d *Database) Path() string {
|
|
return d.params.Config.DatabasePath()
|
|
}
|
|
|
|
func (d *Database) connect(ctx context.Context) error {
|
|
dbPath := d.params.Config.DatabasePath()
|
|
|
|
// Ensure data directory exists
|
|
dir := filepath.Dir(dbPath)
|
|
|
|
err := os.MkdirAll(dir, dataDirPermissions)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create data directory: %w", err)
|
|
}
|
|
|
|
// Open database with WAL mode and foreign keys. Transactions take the
|
|
// write lock when they begin and wait up to 5s for another writer,
|
|
// instead of failing with "database is locked" when both write.
|
|
dsn := dbPath + "?_journal_mode=WAL&_foreign_keys=on" +
|
|
"&_txlock=immediate&_busy_timeout=5000"
|
|
|
|
database, err := sql.Open("sqlite3", dsn)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open database: %w", err)
|
|
}
|
|
|
|
// Test connection
|
|
err = database.PingContext(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to ping database: %w", err)
|
|
}
|
|
|
|
d.database = database
|
|
d.log.Info("database connected", "path", dbPath)
|
|
|
|
// Run migrations
|
|
err = d.migrate(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to run migrations: %w", err)
|
|
}
|
|
|
|
// Backfill webhook_secret_hash for any rows that have a secret but no hash
|
|
err = d.backfillWebhookSecretHashes(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to backfill webhook secret hashes: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// HashWebhookSecret returns the hex-encoded SHA-256 hash of a webhook secret.
|
|
func HashWebhookSecret(secret string) string {
|
|
sum := sha256.Sum256([]byte(secret))
|
|
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func (d *Database) backfillWebhookSecretHashes(ctx context.Context) error {
|
|
rows, err := d.database.QueryContext(ctx,
|
|
"SELECT id, webhook_secret FROM apps"+
|
|
" WHERE webhook_secret_hash = '' AND webhook_secret != ''")
|
|
if err != nil {
|
|
return fmt.Errorf("querying apps for backfill: %w", err)
|
|
}
|
|
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
type row struct {
|
|
id, secret string
|
|
}
|
|
|
|
var toUpdate []row
|
|
|
|
for rows.Next() {
|
|
var r row
|
|
|
|
scanErr := rows.Scan(&r.id, &r.secret)
|
|
if scanErr != nil {
|
|
return fmt.Errorf("scanning app for backfill: %w", scanErr)
|
|
}
|
|
|
|
toUpdate = append(toUpdate, r)
|
|
}
|
|
|
|
rowsErr := rows.Err()
|
|
if rowsErr != nil {
|
|
return fmt.Errorf("iterating apps for backfill: %w", rowsErr)
|
|
}
|
|
|
|
for _, r := range toUpdate {
|
|
hash := HashWebhookSecret(r.secret)
|
|
|
|
_, updateErr := d.database.ExecContext(ctx,
|
|
"UPDATE apps SET webhook_secret_hash = ? WHERE id = ?", hash, r.id)
|
|
if updateErr != nil {
|
|
return fmt.Errorf("updating webhook_secret_hash for app %s: %w", r.id, updateErr)
|
|
}
|
|
|
|
d.log.Info("backfilled webhook_secret_hash", "app_id", r.id)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (d *Database) close() error {
|
|
if d.database != nil {
|
|
d.log.Info("closing database connection")
|
|
|
|
err := d.database.Close()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to close database: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|