Files
neoirc/internal/db/db.go
T
clawbot 8afd873813
check / check (push) Waiting to run
Move the test-only helpers out of the shipped db package (closes #248)
The server binary carried exported names that only tests call, including
raw SQL access and a way to weaken the password hash.

Delete testing.go, GetDB and SetBcryptCost from the shipped code, so
passwords always hash at bcrypt.DefaultCost. Move GetDB, GetOrCreateChannel,
JoinChannel and HasChannelInvite into export_test.go. Delete
GetClientHostInfo, whose remaining check GetLatestClientForSession covers.

Tests in other packages now build the database with db.New and run raw SQL
through dbtest.Exec. Handler tests and the relay tests' aliceWithHTTPClient
helper need a password only to log in, so they store one hashed at
bcrypt.MinCost. Tests of PASS itself keep the real cost.

Model: opus-5-5
2026-11-03 11:59:17 +01:00

323 lines
6.1 KiB
Go

// Package db provides database access and migration management.
package db
import (
"context"
"database/sql"
"embed"
"errors"
"fmt"
"io/fs"
"log/slog"
"net/url"
"sort"
"strconv"
"strings"
"go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/logger"
_ "github.com/joho/godotenv/autoload" // .env
_ "modernc.org/sqlite" // driver
)
const minMigrationParts = 2
var errForeignKeysInDBURL = errors.New(
"DBURL must not set foreign_keys, which the server always turns on",
)
// SchemaFiles contains embedded SQL migration files.
//
//go:embed schema/*.sql
var SchemaFiles embed.FS
// Params defines the dependencies for creating a Database.
type Params struct {
fx.In
Logger *logger.Logger
Config *config.Config
}
// Database manages the SQLite connection and migrations.
type Database struct {
conn *sql.DB
log *slog.Logger
params *Params
}
// New creates a new Database and registers lifecycle hooks.
func New(
lifecycle fx.Lifecycle,
params Params,
) (*Database, error) {
database := &Database{ //nolint:exhaustruct // conn set in OnStart
params: &params,
log: params.Logger.Get(),
}
database.log.Info("Database instantiated")
lifecycle.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
database.log.Info("Database OnStart Hook")
return database.connect(ctx)
},
OnStop: func(_ context.Context) error {
database.log.Info("Database OnStop Hook")
if database.conn != nil {
closeErr := database.conn.Close()
if closeErr != nil {
return fmt.Errorf(
"close db: %w", closeErr,
)
}
}
return nil
},
})
return database, nil
}
func (database *Database) connect(ctx context.Context) error {
dbURL := database.params.Config.DBURL
if dbURL == "" {
dbURL = config.DefaultDBURL
}
// SQLite keeps the foreign keys setting per connection. The driver
// runs each _pragma parameter on every connection it opens. So the
// one added below also covers the connection the pool opens after
// a cancelled query breaks the old one. A foreign_keys pragma in
// DBURL could undo it, because the driver sorts the pragmas.
_, query, hasQuery := strings.Cut(dbURL, "?")
urlParams, err := url.ParseQuery(query)
if err != nil {
return fmt.Errorf("parse DBURL: %w", err)
}
for _, pragma := range urlParams["_pragma"] {
if strings.Contains(strings.ToLower(pragma), "foreign_keys") {
return fmt.Errorf(
"%w: _pragma=%s", errForeignKeysInDBURL, pragma,
)
}
}
separator := "?"
if hasQuery {
separator = "&"
}
dbURL += separator + "_pragma=foreign_keys(1)"
database.log.Info(
"connecting to database", "url", dbURL,
)
conn, err := sql.Open("sqlite", dbURL)
if err != nil {
return fmt.Errorf("open database: %w", err)
}
err = conn.PingContext(ctx)
if err != nil {
return fmt.Errorf("ping database: %w", err)
}
conn.SetMaxOpenConns(1)
database.conn = conn
database.log.Info("database connected")
return database.runMigrations(ctx)
}
type migration struct {
version int
name string
sql string
}
func (database *Database) runMigrations(
ctx context.Context,
) error {
bootstrap, err := SchemaFiles.ReadFile(
"schema/000.sql",
)
if err != nil {
return fmt.Errorf(
"read bootstrap migration: %w", err,
)
}
_, err = database.conn.ExecContext(
ctx, string(bootstrap),
)
if err != nil {
return fmt.Errorf(
"execute bootstrap migration: %w", err,
)
}
migrations, err := database.loadMigrations()
if err != nil {
return err
}
for _, mig := range migrations {
err = database.applyMigration(ctx, mig)
if err != nil {
return err
}
}
database.log.Info("database migrations complete")
return nil
}
func (database *Database) applyMigration(
ctx context.Context,
mig migration,
) error {
var exists int
err := database.conn.QueryRowContext(ctx,
`SELECT COUNT(*) FROM schema_migrations
WHERE version = ?`,
mig.version,
).Scan(&exists)
if err != nil {
return fmt.Errorf(
"check migration %d: %w", mig.version, err,
)
}
if exists > 0 {
return nil
}
database.log.Info(
"applying migration",
"version", mig.version,
"name", mig.name,
)
return database.execMigration(ctx, mig)
}
func (database *Database) execMigration(
ctx context.Context,
mig migration,
) error {
transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf(
"begin tx for migration %d: %w",
mig.version, err,
)
}
_, err = transaction.ExecContext(ctx, mig.sql)
if err != nil {
_ = transaction.Rollback()
return fmt.Errorf(
"apply migration %d (%s): %w",
mig.version, mig.name, err,
)
}
_, err = transaction.ExecContext(ctx,
`INSERT INTO schema_migrations (version)
VALUES (?)`,
mig.version,
)
if err != nil {
_ = transaction.Rollback()
return fmt.Errorf(
"record migration %d: %w",
mig.version, err,
)
}
err = transaction.Commit()
if err != nil {
return fmt.Errorf(
"commit migration %d: %w",
mig.version, err,
)
}
return nil
}
func (database *Database) loadMigrations() (
[]migration,
error,
) {
entries, err := fs.ReadDir(SchemaFiles, "schema")
if err != nil {
return nil, fmt.Errorf(
"read schema dir: %w", err,
)
}
migrations := make([]migration, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() ||
!strings.HasSuffix(entry.Name(), ".sql") {
continue
}
parts := strings.SplitN(
entry.Name(), "_", minMigrationParts,
)
if len(parts) < minMigrationParts {
continue
}
version, parseErr := strconv.Atoi(parts[0])
if parseErr != nil {
continue
}
// Skip bootstrap migration; it is executed separately.
if version == 0 {
continue
}
content, readErr := SchemaFiles.ReadFile(
"schema/" + entry.Name(),
)
if readErr != nil {
return nil, fmt.Errorf(
"read migration %s: %w",
entry.Name(), readErr,
)
}
migrations = append(migrations, migration{
version: version,
name: entry.Name(),
sql: string(content),
})
}
sort.Slice(migrations, func(i, j int) bool {
return migrations[i].version < migrations[j].version
})
return migrations, nil
}