First pass implementation of an SQLite backing store for fetched opportunities

This commit is contained in:
bwilliams committed 2026-10-13 12:52:17 +01:00
1 parent 9b0e6dccfe
commit 045492a148
29 files changed
+3132 -9

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
package cli
import (
"bufio"
"fmt"
"log"
"os"
"strings"
)
// Confirm asks the user the yes/no question s on stdin and reports whether
// they answered yes. "y" and "yes" confirm, "n" and "no" refuse, in any case;
// anything else asks again. It exits the program if stdin can't be read.
func Confirm(s string) bool {
reader := bufio.NewReader(os.Stdin)
for {
fmt.Printf("%s [y/n]: ", s)
response, err := reader.ReadString('\n')
if err != nil {
log.Fatal(err)
}
response = strings.ToLower(strings.TrimSpace(response))
switch response {
case "y", "yes":
return true
case "n", "no":
return false
}
}
}
+139
View File
@@ -0,0 +1,139 @@
package migrate
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"time"
"git.avsdev.uk/Go/job-search/internal/cli"
"git.avsdev.uk/Go/job-search/internal/store"
"github.com/pressly/goose/v3"
"github.com/spf13/cobra"
)
// openMigrator loads the configuration and returns a migrator over the
// store's embedded migrations for the configured database, opened with the
// store's connection settings. The caller must close the returned *sql.DB.
//
// If the database file doesn't exist, create decides between asking whether
// to create it (up) and failing (down, status: nothing to work on).
func openMigrator(
cmd *cobra.Command, opts *cli.Options, create bool, createConfirmed bool,
) (*goose.Provider, *sql.DB, error) {
conf, err := opts.RunSetup(cmd, true, true)
if err != nil {
return nil, nil, fmt.Errorf("no configuration loaded (%w)", err)
}
file := conf.Database.File
if len(file) == 0 {
return nil, nil, errors.New(
"no database file specified. Update your configuration",
)
}
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
if !create {
return nil, nil, fmt.Errorf("database file '%s' does not exist", file)
}
if !createConfirmed {
question := fmt.Sprintf(
"Database file '%s' does not exist. Would you like to create it?", file,
)
if !cli.Confirm(question) {
return nil, nil, errors.New("migrate: no database file")
}
}
} else if err != nil {
return nil, nil, err
}
db, err := store.OpenDB(file)
if err != nil {
return nil, nil, err
}
m, err := store.NewMigrator(db)
if err != nil {
return nil, nil, errors.Join(err, db.Close())
}
return m, db, nil
}
// closeDB closes db when deferred, adding any error to *err.
func closeDB(db *sql.DB, err *error) {
*err = errors.Join(*err, db.Close())
}
// errWriter writes until a write fails, then keeps the error, so a run of
// prints needs checking only once.
type errWriter struct {
w io.Writer
err error
}
// printf writes like fmt.Fprintf, unless an earlier write failed.
func (e *errWriter) printf(format string, a ...any) {
if e.err == nil {
_, e.err = fmt.Fprintf(e.w, format, a...)
}
}
// printResults lists migrations that ran, e.g.
//
// applied 00003_create_release_tags_table.sql (2ms)
func printResults(w io.Writer, results []*goose.MigrationResult) error {
ew := &errWriter{w: w}
for _, r := range results {
verb := "applied"
if r.Direction == "down" {
verb = "reverted"
}
ew.printf(" %-9s %s (%s)\n",
verb, filepath.Base(r.Source.Path), r.Duration.Round(time.Millisecond))
}
return ew.err
}
// failure prints what ran before a failed migration and describes the
// failure. Each migration runs in a transaction, so the failed one has been
// rolled back and the database is at the last version that succeeded.
func failure(w io.Writer, err error) error {
var pe *goose.PartialError
if !errors.As(err, &pe) {
return err
}
printErr := printResults(w, pe.Applied)
name := "unknown migration"
if pe.Failed != nil && pe.Failed.Source != nil {
name = filepath.Base(pe.Failed.Source.Path)
}
return errors.Join(fmt.Errorf(
"migration %s failed and was rolled back; the database is at the last "+
"version that succeeded.\nMigration error: %w",
name, pe.Err), printErr)
}
// printVersion reports where the database ended up.
func printVersion(ctx context.Context, w io.Writer, m *goose.Provider) error {
v, err := m.GetDBVersion(ctx)
if err != nil {
return err
}
if v == 0 {
_, err = fmt.Fprintln(w, "Database cleared")
} else {
_, err = fmt.Fprintf(w, "Database migrated to version %d\n", v)
}
return err
}
+77
View File
@@ -0,0 +1,77 @@
package migrate
import (
"errors"
"fmt"
"log/slog"
"git.avsdev.uk/Go/job-search/internal/cli"
"git.avsdev.uk/Go/job-search/internal/store"
"github.com/spf13/cobra"
)
func commandDown(opts *cli.Options) *cobra.Command {
cmd := &cobra.Command{
Use: "down",
Short: "Migrate SQLite database to a previous version",
Long: `Roll back the most recent migrations.
--steps 0 rolls back every migration which deletes all data and must be
confirmed by providing -yes.`,
Args: cobra.NoArgs,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
slog.Debug("migrate.down.PersistentPreRun")
},
PersistentPostRun: func(cmd *cobra.Command, args []string) {
slog.Debug("migrate.down.PersistentPostRun")
},
RunE: func(cmd *cobra.Command, args []string) (err error) {
slog.Debug("migrate.down.Run")
yes, _ := cmd.Flags().GetBool("yes")
steps, _ := cmd.Flags().GetUint("steps")
m, db, err := openMigrator(cmd, opts, false, false)
if err != nil {
return err
}
defer closeDB(db, &err)
results, err := store.StepDown(cmd.Context(), m, int(steps), yes)
if len(results) == 0 && err == nil {
fmt.Println("No changes made to database (empty)")
return nil
}
if err == store.ErrWouldEmptyDatabase {
return fmt.Errorf(
"%w\n\nTo confirm you wish to proceed, pass the --yes flag", err,
)
}
if printErr := printResults(cmd.OutOrStdout(), results); printErr != nil {
return errors.Join(err, printErr)
}
if err != nil {
return failure(cmd.OutOrStdout(), err)
}
return printVersion(cmd.Context(), cmd.OutOrStdout(), m)
},
}
cmd.Flags().Uint(
"steps",
0,
"number of migrations to roll back. 0 = all (deletes all data)",
)
if err := cmd.MarkFlagRequired("steps"); err != nil {
panic(err) // only fails if the flag isn't defined
}
cmd.Flags().BoolP(
"yes", "y",
false,
"confirm you are intending to roll back every migration",
)
return cmd
}
+42
View File
@@ -0,0 +1,42 @@
// Package migrate provides the "migrate" command and its subcommands, which
// apply, roll back and report the store's migrations (goose, embedded in the
// store package) on the configured SQLite database.
//
// goose runs each migration in its own transaction, so a failed migration is
// rolled back and leaves the database at the previous version.
package migrate
import (
"log/slog"
"git.avsdev.uk/Go/job-search/internal/cli"
"github.com/spf13/cobra"
)
// NewCommand returns the "migrate" command, which migrates the database schema (see
// internal/store).
func NewCommand(opts *cli.Options) *cobra.Command {
cmd := &cobra.Command{
Use: "migrate",
Short: "Migrate SQLite database to latest version",
Long: `Migrate the SQLite database to the latest version.
Running without a subcommand is the same as running "migrate up": every pending
migration is applied.`,
Args: cobra.NoArgs,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
slog.Debug("migrate.PersistentPreRun")
},
PersistentPostRun: func(cmd *cobra.Command, args []string) {
slog.Debug("migrate.PersistentPostRun")
},
RunE: func(cmd *cobra.Command, args []string) error {
slog.Debug("migrate.Run")
return runStepUp(cmd, opts, 0, false)
},
}
cmd.AddCommand(commandUp(opts))
cmd.AddCommand(commandDown(opts))
cmd.AddCommand(commandStatus(opts))
return cmd
}
+94
View File
@@ -0,0 +1,94 @@
package migrate
import (
"context"
"errors"
"io"
"log/slog"
"path/filepath"
"text/tabwriter"
"git.avsdev.uk/Go/job-search/internal/cli"
"git.avsdev.uk/Go/job-search/internal/store"
"github.com/pressly/goose/v3"
"github.com/spf13/cobra"
)
// printStatus lists every migration as applied (with when) or pending,
// after a summary line, e.g.
//
// Database version 4 of 6 (2 pending)
// 1 applied 2026-10-07 10:12:01 00001_create_opportunities_table.sql
// ...
// 5 pending 00005_create_opportunity_classifications_table.sql
func printStatus(ctx context.Context, w io.Writer, m *goose.Provider) error {
statuses, err := m.Status(ctx)
if err != nil {
return err
}
current, err := m.GetDBVersion(ctx)
if err != nil {
return err
}
latest := store.LatestVersion(m)
pending := 0
for _, s := range statuses {
if s.State == goose.StatePending {
pending++
}
}
ew := &errWriter{w: w}
switch {
case current > latest:
ew.printf("Database version %d is newer than this build (latest %d)\n",
current, latest)
case pending == 0:
ew.printf("Database version %d of %d (up-to-date)\n", current, latest)
default:
ew.printf("Database version %d of %d (%d pending)\n", current, latest, pending)
}
if ew.err != nil {
return ew.err
}
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
ew = &errWriter{w: tw}
for _, s := range statuses {
applied := ""
if s.State == goose.StateApplied {
applied = s.AppliedAt.Local().Format("2006-01-02 15:04:05")
}
ew.printf(" %d\t%s\t%s\t%s\n",
s.Source.Version, s.State, applied, filepath.Base(s.Source.Path))
}
return errors.Join(ew.err, tw.Flush())
}
func commandStatus(opts *cli.Options) *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show which migrations have been applied to the SQLite database",
Args: cobra.NoArgs,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
slog.Debug("migrate.status.PersistentPreRun")
},
PersistentPostRun: func(cmd *cobra.Command, args []string) {
slog.Debug("migrate.status.PersistentPostRun")
},
RunE: func(cmd *cobra.Command, args []string) (err error) {
slog.Debug("migrate.status.Run")
m, db, err := openMigrator(cmd, opts, false, false)
if err != nil {
return err
}
defer closeDB(db, &err)
return printStatus(cmd.Context(), cmd.OutOrStdout(), m)
},
}
}
+73
View File
@@ -0,0 +1,73 @@
package migrate
import (
"fmt"
"log/slog"
"git.avsdev.uk/Go/job-search/internal/cli"
"git.avsdev.uk/Go/job-search/internal/store"
"github.com/spf13/cobra"
)
// runStepUp applies steps pending migrations (0 means all), printing each one
// and the resulting version. create says the user already agreed to create a
// missing database file; otherwise they are asked.
func runStepUp(
cmd *cobra.Command, opts *cli.Options, steps int, create bool,
) (err error) {
m, db, err := openMigrator(cmd, opts, true, create)
if err != nil {
return err
}
defer closeDB(db, &err)
results, err := store.StepUp(cmd.Context(), m, int(steps))
if len(results) == 0 && err == nil {
_, err := fmt.Fprintln(cmd.OutOrStdout(), "No changes made to database (up-to-date)")
return err
}
if err := printResults(cmd.OutOrStdout(), results); err != nil {
return err
}
if err != nil {
return failure(cmd.OutOrStdout(), err)
}
return printVersion(cmd.Context(), cmd.OutOrStdout(), m)
}
func commandUp(opts *cli.Options) *cobra.Command {
cmd := &cobra.Command{
Use: "up",
Short: "Migrate SQLite database to the latest version, or by a number of steps",
Args: cobra.NoArgs,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
slog.Debug("migrate.up.PersistentPreRun")
},
PersistentPostRun: func(cmd *cobra.Command, args []string) {
slog.Debug("migrate.up.PersistentPostRun")
},
RunE: func(cmd *cobra.Command, args []string) error {
slog.Debug("migrate.up.Run")
steps, _ := cmd.Flags().GetUint("steps")
create, _ := cmd.Flags().GetBool("create")
return runStepUp(cmd, opts, int(steps), create)
},
}
cmd.Flags().Uint(
"steps",
0,
"number of migrations to apply. 0 = all pending",
)
cmd.Flags().Bool(
"create",
false,
"create the database file if it does not already exist",
)
return cmd
}