Files
foodster/cmd/foodster/migrate.go
T
Esa Kataja c9a63bdd9e Scaffold the Go app: auth, migrations, bundle import
Bring up the stage 1 skeleton described in the PRD, enough that the app
builds, serves, and can be populated with dishes.

- net/http server with shared-password Basic auth, /healthz outside it,
  graceful shutdown, and TZ-aware calendar days
- SQLite via modernc (pure Go, static binary), opened with WAL and a single
  connection
- migration runner: numbered SQL files embedded and applied once each inside
  a transaction, recorded in schema_migrations
- bundle import (PRD §7.3) as a live feature on the Ruoat tab: paste JSON or
  upload a file, get a per-row Finnish report. The same importer is reachable
  as `foodster -import` for repopulating a scratch database
- templ views and hand-written CSS with light-dark() theming; the Datastar
  v1.0.3 client is vendored, since the Go SDK ships no browser asset and a
  CDN would break an offline LAN

Names are normalized to sentence case rather than title case: Finnish
capitalizes only the first word of a phrase, so "Keitetyt perunat" is right
and "Keitetyt Perunat" is not. PRD §6 and §7.3 are amended to match.

Testing is behind make targets rather than ad-hoc commands: `make check` runs
lint, unit tests and scripts/smoke.sh, which exercises auth, static assets
and every import path against a scratch database on a spare port.
2026-09-05 18:15:17 +03:00

75 lines
1.8 KiB
Go

package main
import (
"database/sql"
"embed"
"fmt"
"io/fs"
"log"
"path"
"sort"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// migrate applies every embedded migration that has not run yet, in filename
// order, and records it. Migrations are named NNNN_description.sql; the
// numeric prefix is the ordering, so never renumber one that has shipped.
//
// SQLite runs DDL inside transactions, so a migration either lands whole or
// not at all. There is no down-migration: for a single-household app,
// restoring the database file is the rollback.
func migrate(db *sql.DB) error {
const create = `CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)`
if _, err := db.Exec(create); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
files, err := fs.Glob(migrationsFS, "migrations/*.sql")
if err != nil {
return err
}
sort.Strings(files)
for _, file := range files {
name := path.Base(file)
var applied int
if err := db.QueryRow(
`SELECT count(*) FROM schema_migrations WHERE name = ?`, name,
).Scan(&applied); err != nil {
return fmt.Errorf("check %s: %w", name, err)
}
if applied > 0 {
continue
}
body, err := migrationsFS.ReadFile(file)
if err != nil {
return err
}
tx, err := db.Begin()
if err != nil {
return err
}
if _, err := tx.Exec(string(body)); err != nil {
tx.Rollback()
return fmt.Errorf("migration %s: %w", name, err)
}
if _, err := tx.Exec(`INSERT INTO schema_migrations (name) VALUES (?)`, name); err != nil {
tx.Rollback()
return fmt.Errorf("record %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit %s: %w", name, err)
}
log.Printf("migration applied: %s", name)
}
return nil
}