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.
75 lines
1.8 KiB
Go
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
|
|
}
|