Pinterest-style visual bookmarking app with: - URL metadata extraction (OG/Twitter meta, oEmbed fallback) - Image caching in Postgres with 480px thumbnails - Multi-tag filtering with Ctrl/Cmd for OR mode - Fuzzy tag suggestions and inline tag editing - Browser console auth() with first-use password setup - Brutalist UI with Commit Mono font and Pico CSS - Light/dark mode via browser preference
130 lines
2.7 KiB
Go
130 lines
2.7 KiB
Go
package migrations
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"embed"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
"github.com/pressly/goose/v3"
|
|
)
|
|
|
|
//go:embed sql/*.sql
|
|
var FS embed.FS
|
|
|
|
const DefaultURL = "postgres://postgres:postgres@localhost:5432/lookbook?sslmode=disable"
|
|
|
|
// Up applies all available migrations using the provided database URL.
|
|
func Up(ctx context.Context, dbURL string, logger *slog.Logger) error {
|
|
url := dbURL
|
|
if url == "" {
|
|
url = DefaultURL
|
|
}
|
|
|
|
db, err := openDB(url, logger)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
|
|
if err := goose.UpContext(ctx, db, "sql"); err != nil {
|
|
return fmt.Errorf("apply migrations: %w", err)
|
|
}
|
|
|
|
logger.Info("database migrated")
|
|
return nil
|
|
}
|
|
|
|
// Down rolls back migrations. If targetVersion < 0, it steps back one migration; otherwise it migrates down to the target version.
|
|
func Down(ctx context.Context, dbURL string, targetVersion int64, logger *slog.Logger) error {
|
|
url := dbURL
|
|
if url == "" {
|
|
url = DefaultURL
|
|
}
|
|
|
|
db, err := openDB(url, logger)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
|
|
if targetVersion < 0 {
|
|
if err := goose.DownContext(ctx, db, "sql"); err != nil {
|
|
return fmt.Errorf("rollback one: %w", err)
|
|
}
|
|
logger.Info("rolled back one migration")
|
|
return nil
|
|
}
|
|
|
|
if err := goose.DownToContext(ctx, db, "sql", targetVersion); err != nil {
|
|
return fmt.Errorf("rollback to version %d: %w", targetVersion, err)
|
|
}
|
|
logger.Info("rolled back to version", slog.Int64("version", targetVersion))
|
|
return nil
|
|
}
|
|
|
|
func CheckPending(ctx context.Context, dbURL string, logger *slog.Logger) (int, error) {
|
|
url := dbURL
|
|
if url == "" {
|
|
url = DefaultURL
|
|
}
|
|
|
|
db, err := openDB(url, logger)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer db.Close()
|
|
|
|
current, err := goose.GetDBVersionContext(ctx, db)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("get db version: %w", err)
|
|
}
|
|
|
|
migrations, err := goose.CollectMigrations("sql", 0, goose.MaxVersion)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("collect migrations: %w", err)
|
|
}
|
|
|
|
pending := 0
|
|
for _, m := range migrations {
|
|
if m.Version > current {
|
|
pending++
|
|
}
|
|
}
|
|
|
|
return pending, nil
|
|
}
|
|
|
|
// slogLogger adapts slog to goose's minimal logging interface.
|
|
type slogLogger struct {
|
|
logger *slog.Logger
|
|
}
|
|
|
|
func (l slogLogger) Printf(format string, v ...any) {
|
|
l.logger.Info(fmt.Sprintf(format, v...))
|
|
}
|
|
|
|
func (l slogLogger) Fatalf(format string, v ...any) {
|
|
l.logger.Error(fmt.Sprintf(format, v...))
|
|
os.Exit(1)
|
|
}
|
|
|
|
func openDB(url string, logger *slog.Logger) (*sql.DB, error) {
|
|
goose.SetBaseFS(FS)
|
|
goose.SetLogger(slogLogger{logger: logger})
|
|
|
|
db, err := sql.Open("pgx", url)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open db: %w", err)
|
|
}
|
|
|
|
if err := db.Ping(); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("ping db: %w", err)
|
|
}
|
|
|
|
return db, nil
|
|
}
|