From e9ee4e768a8c7a6ed58b53d70c0e928eca7f5284 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 00:54:02 -0400 Subject: [PATCH 1/5] Feature 02 task 1: SQLite connection and embedded migration runner (#10) Co-authored-by: dev_agent Co-committed-by: dev_agent --- cmd/server/main.go | 15 +++ go.mod | 19 ++- go.sum | 50 ++++++++ internal/store/migrate.go | 105 +++++++++++++++++ internal/store/migrate_test.go | 206 +++++++++++++++++++++++++++++++++ internal/store/store.go | 79 +++++++++++++ internal/store/store_test.go | 116 +++++++++++++++++++ migrations/0000_bootstrap.sql | 4 + migrations/embed.go | 10 ++ 9 files changed, 602 insertions(+), 2 deletions(-) create mode 100644 internal/store/migrate.go create mode 100644 internal/store/migrate_test.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go create mode 100644 migrations/0000_bootstrap.sql create mode 100644 migrations/embed.go diff --git a/cmd/server/main.go b/cmd/server/main.go index bbf388b..ec9f141 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -15,6 +15,8 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" ) // version is set at build time with -ldflags "-X main.version=". @@ -38,6 +40,19 @@ func run(cfg config.Config, logger *slog.Logger) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + db, err := store.Open(ctx, cfg.DataDir) + if err != nil { + return err + } + defer func() { + if err := db.Close(); err != nil { + logger.Error("close database", "error", err) + } + }() + if err := store.Migrate(ctx, db.Writer, migrations.FS); err != nil { + return err + } + router, err := server.NewRouter(logger, version, frontend.Assets()) if err != nil { return err diff --git a/go.mod b/go.mod index 37e681e..3c014a7 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,20 @@ module code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest -go 1.25 +go 1.25.0 -require github.com/go-chi/chi/v5 v5.3.1 +require ( + github.com/go-chi/chi/v5 v5.3.1 + modernc.org/sqlite v1.59.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.75.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect +) diff --git a/go.sum b/go.sum index bdb0eaa..1d524f4 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,52 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew= +modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A= +modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/store/migrate.go b/internal/store/migrate.go new file mode 100644 index 0000000..2ac5dec --- /dev/null +++ b/internal/store/migrate.go @@ -0,0 +1,105 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "io/fs" + "strings" + "time" +) + +const createSchemaMigrations = `CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TEXT NOT NULL +)` + +// Migrate applies every *.sql file at the root of migrations that is not yet +// recorded in schema_migrations, in filename order. Each migration runs in its +// own transaction together with the insert that records it, so a failing +// migration leaves the database at the previous version. A version is the +// file name without its .sql extension. +// +// The database must not hold a version unknown to migrations: that means it +// was migrated by a newer build, and Migrate refuses to continue. +func Migrate(ctx context.Context, db *sql.DB, migrations fs.FS) error { + files, err := fs.Glob(migrations, "*.sql") + if err != nil { + return fmt.Errorf("migrate: list migrations: %w", err) + } + if len(files) == 0 { + return fmt.Errorf("migrate: no migrations found") + } + + if _, err := db.ExecContext(ctx, createSchemaMigrations); err != nil { + return fmt.Errorf("migrate: create schema_migrations: %w", err) + } + applied, err := appliedVersions(ctx, db) + if err != nil { + return err + } + + known := make(map[string]bool, len(files)) + for _, name := range files { + known[strings.TrimSuffix(name, ".sql")] = true + } + for version := range applied { + if !known[version] { + return fmt.Errorf("migrate: database has version %q, which this build does not know", version) + } + } + + for _, name := range files { // fs.Glob returns names in lexical order + version := strings.TrimSuffix(name, ".sql") + if applied[version] { + continue + } + body, err := fs.ReadFile(migrations, name) + if err != nil { + return fmt.Errorf("migrate: read %s: %w", name, err) + } + if err := apply(ctx, db, version, string(body)); err != nil { + return fmt.Errorf("migrate: apply %s: %w", name, err) + } + } + return nil +} + +func appliedVersions(ctx context.Context, db *sql.DB) (map[string]bool, error) { + rows, err := db.QueryContext(ctx, `SELECT version FROM schema_migrations`) + if err != nil { + return nil, fmt.Errorf("migrate: read schema_migrations: %w", err) + } + defer rows.Close() + applied := make(map[string]bool) + for rows.Next() { + var v string + if err := rows.Scan(&v); err != nil { + return nil, fmt.Errorf("migrate: read schema_migrations: %w", err) + } + applied[v] = true + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("migrate: read schema_migrations: %w", err) + } + return applied, nil +} + +func apply(ctx context.Context, db *sql.DB, version, body string) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() // no-op once committed + + if _, err := tx.ExecContext(ctx, body); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, + version, time.Now().UTC().Format(time.RFC3339), + ); err != nil { + return err + } + return tx.Commit() +} diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go new file mode 100644 index 0000000..35a0824 --- /dev/null +++ b/internal/store/migrate_test.go @@ -0,0 +1,206 @@ +package store + +import ( + "context" + "io/fs" + "strings" + "testing" + "testing/fstest" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +var testMigrations = fstest.MapFS{ + "0001_first.sql": {Data: []byte("CREATE TABLE first (id INTEGER PRIMARY KEY);")}, + "0002_second.sql": {Data: []byte("CREATE TABLE second (id INTEGER PRIMARY KEY);\nCREATE TABLE third (id INTEGER PRIMARY KEY);")}, + "README.md": {Data: []byte("not a migration")}, +} + +type migrationRow struct { + version string + appliedAt string +} + +func migrationRows(t *testing.T, s *Store) []migrationRow { + t.Helper() + rows, err := s.Reader.Query("SELECT version, applied_at FROM schema_migrations ORDER BY version") + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var out []migrationRow + for rows.Next() { + var r migrationRow + if err := rows.Scan(&r.version, &r.appliedAt); err != nil { + t.Fatal(err) + } + out = append(out, r) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return out +} + +func versions(rows []migrationRow) string { + vs := make([]string, len(rows)) + for i, r := range rows { + vs[i] = r.version + } + return strings.Join(vs, ",") +} + +func tableExists(t *testing.T, s *Store, name string) bool { + t.Helper() + var n int + if err := s.Reader.QueryRow("SELECT count(*) FROM sqlite_schema WHERE type = 'table' AND name = ?", name).Scan(&n); err != nil { + t.Fatal(err) + } + return n == 1 +} + +func TestMigrateFreshDatabaseReachesLatest(t *testing.T) { + s := openTestStore(t) + if err := Migrate(context.Background(), s.Writer, testMigrations); err != nil { + t.Fatal(err) + } + rows := migrationRows(t, s) + if got := versions(rows); got != "0001_first,0002_second" { + t.Fatalf("versions = %q", got) + } + for _, r := range rows { + if _, err := time.Parse(time.RFC3339, r.appliedAt); err != nil || !strings.HasSuffix(r.appliedAt, "Z") { + t.Errorf("applied_at %q is not UTC RFC 3339", r.appliedAt) + } + } + for _, table := range []string{"first", "second", "third"} { + if !tableExists(t, s, table) { + t.Errorf("table %s missing", table) + } + } +} + +func TestMigrateSecondRunIsNoOp(t *testing.T) { + ctx := context.Background() + s := openTestStore(t) + if err := Migrate(ctx, s.Writer, testMigrations); err != nil { + t.Fatal(err) + } + before := migrationRows(t, s) + if err := Migrate(ctx, s.Writer, testMigrations); err != nil { + t.Fatalf("second run: %v", err) + } + after := migrationRows(t, s) + if len(after) != len(before) { + t.Fatalf("rows after second run = %v, want %v", after, before) + } + for i := range before { + if after[i] != before[i] { + t.Errorf("row %d changed: %v -> %v", i, before[i], after[i]) + } + } +} + +func TestMigrateBrokenMigrationRollsBack(t *testing.T) { + ctx := context.Background() + s := openTestStore(t) + if err := Migrate(ctx, s.Writer, testMigrations); err != nil { + t.Fatal(err) + } + + broken := fstest.MapFS{"0003_broken.sql": {Data: []byte( + // The first statement succeeds; the second is invalid, so the + // table created by the first must be rolled back with it. + "CREATE TABLE fourth (id INTEGER PRIMARY KEY);\nCREATE TABLE oops (;", + )}} + for name, f := range testMigrations { + broken[name] = f + } + err := Migrate(ctx, s.Writer, broken) + if err == nil { + t.Fatal("broken migration returned no error") + } + if !strings.Contains(err.Error(), "0003_broken.sql") { + t.Errorf("error %q does not name the failing migration", err) + } + if got := versions(migrationRows(t, s)); got != "0001_first,0002_second" { + t.Errorf("versions after failure = %q, want previous version intact", got) + } + if tableExists(t, s, "fourth") { + t.Error("table from the failed migration was not rolled back") + } + for _, table := range []string{"first", "second", "third"} { + if !tableExists(t, s, table) { + t.Errorf("previous table %s missing after failed migration", table) + } + } +} + +func TestMigrateAppliesInFilenameOrder(t *testing.T) { + s := openTestStore(t) + // 0002 depends on 0001; MapFS iteration order is random, so this only + // passes if migrations are sorted by name. + ordered := fstest.MapFS{ + "0002_child.sql": {Data: []byte("CREATE TABLE child (parent_id INTEGER REFERENCES parent(id)); INSERT INTO child VALUES (1);")}, + "0001_parent.sql": {Data: []byte("CREATE TABLE parent (id INTEGER PRIMARY KEY); INSERT INTO parent VALUES (1);")}, + } + if err := Migrate(context.Background(), s.Writer, ordered); err != nil { + t.Fatal(err) + } +} + +func TestMigrateUnknownAppliedVersionFails(t *testing.T) { + ctx := context.Background() + s := openTestStore(t) + if err := Migrate(ctx, s.Writer, testMigrations); err != nil { + t.Fatal(err) + } + older := fstest.MapFS{"0001_first.sql": testMigrations["0001_first.sql"]} + if err := Migrate(ctx, s.Writer, older); err == nil { + t.Fatal("migrating a database newer than the build returned no error") + } +} + +func TestMigrateNoMigrationsFails(t *testing.T) { + s := openTestStore(t) + if err := Migrate(context.Background(), s.Writer, fstest.MapFS{}); err == nil { + t.Fatal("empty migrations returned no error") + } +} + +func TestMigrateEmbeddedMigrations(t *testing.T) { + s := openTestStore(t) + if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + if got := versions(migrationRows(t, s)); got != "0000_bootstrap" { + t.Errorf("versions = %q, want 0000_bootstrap", got) + } +} + +func TestMigrateEmbeddedThenNewMigrationApplies(t *testing.T) { + ctx := context.Background() + s := openTestStore(t) + if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + bootstrap, err := fs.ReadFile(migrations.FS, "0000_bootstrap.sql") + if err != nil { + t.Fatal(err) + } + // A later build adds 0001_init.sql alongside the unchanged bootstrap. + upgraded := fstest.MapFS{ + "0000_bootstrap.sql": {Data: bootstrap}, + "0001_init.sql": {Data: []byte("CREATE TABLE later (id INTEGER PRIMARY KEY);")}, + } + if err := Migrate(ctx, s.Writer, upgraded); err != nil { + t.Fatal(err) + } + if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init" { + t.Errorf("versions = %q, want 0000_bootstrap,0001_init", got) + } + if !tableExists(t, s, "later") { + t.Error("table from 0001_init was not created") + } +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..e3748d2 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,79 @@ +// Package store owns the MealQuest SQLite database: one writer connection, +// a read pool, and the embedded schema migrations. +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/url" + "path/filepath" + + _ "modernc.org/sqlite" // registers the "sqlite" database/sql driver +) + +// FileName is the database file inside the data directory. +const FileName = "mealquest.db" + +// readPoolSize is the maximum number of open read connections. +const readPoolSize = 4 + +// connPragmas are applied by the driver to every new connection. +var connPragmas = []string{ + "busy_timeout(5000)", + "journal_mode(WAL)", + "foreign_keys(1)", +} + +// Store holds the database handles. All writes go through Writer, which has +// exactly one connection; Reader is a pool of read-only connections. +type Store struct { + Writer *sql.DB + Reader *sql.DB +} + +// Open opens /mealquest.db, creating it if absent, and verifies both +// handles can connect. It does not apply migrations; call Migrate for that. +func Open(ctx context.Context, dataDir string) (*Store, error) { + path, err := filepath.Abs(filepath.Join(dataDir, FileName)) + if err != nil { + return nil, fmt.Errorf("store: resolve database path: %w", err) + } + + // The writer is opened and pinged first so the file exists and is in WAL + // mode before any read-only connection touches it. + writer, err := openDB(ctx, path, 1, url.Values{"_txlock": {"immediate"}}) + if err != nil { + return nil, fmt.Errorf("store: open writer: %w", err) + } + reader, err := openDB(ctx, path, readPoolSize, url.Values{"_pragma": {"query_only(1)"}}) + if err != nil { + return nil, errors.Join(fmt.Errorf("store: open reader: %w", err), writer.Close()) + } + return &Store{Writer: writer, Reader: reader}, nil +} + +// Close closes both handles. +func (s *Store) Close() error { + return errors.Join(s.Reader.Close(), s.Writer.Close()) +} + +func openDB(ctx context.Context, path string, maxConns int, extra url.Values) (*sql.DB, error) { + q := url.Values{"_pragma": connPragmas} + for k, vs := range extra { + q[k] = append(q[k], vs...) + } + dsn := (&url.URL{Scheme: "file", Path: path, RawQuery: q.Encode()}).String() + + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(maxConns) + db.SetMaxIdleConns(maxConns) + if err := db.PingContext(ctx); err != nil { + return nil, errors.Join(err, db.Close()) + } + return db, nil +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..1125ce0 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,116 @@ +package store + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "testing" +) + +func openTestStore(t *testing.T) *Store { + t.Helper() + s, err := Open(context.Background(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := s.Close(); err != nil { + t.Error(err) + } + }) + return s +} + +func TestOpenCreatesDatabaseFile(t *testing.T) { + dir := t.TempDir() + s, err := Open(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if _, err := os.Stat(filepath.Join(dir, FileName)); err != nil { + t.Fatalf("database file: %v", err) + } +} + +func TestOpenMissingDirFails(t *testing.T) { + _, err := Open(context.Background(), filepath.Join(t.TempDir(), "missing")) + if err == nil { + t.Fatal("Open succeeded on a missing data directory") + } +} + +func TestPoolSizes(t *testing.T) { + s := openTestStore(t) + if got := s.Writer.Stats().MaxOpenConnections; got != 1 { + t.Errorf("writer max open connections = %d, want 1", got) + } + if got := s.Reader.Stats().MaxOpenConnections; got != readPoolSize { + t.Errorf("reader max open connections = %d, want %d", got, readPoolSize) + } +} + +// TestPragmasOnEveryConnection holds every reader connection open at once so +// each one is a distinct connection, then checks the pragmas on each and on +// the writer. +func TestPragmasOnEveryConnection(t *testing.T) { + ctx := context.Background() + s := openTestStore(t) + + conns := []*sql.Conn{} + for range readPoolSize { + c, err := s.Reader.Conn(ctx) + if err != nil { + t.Fatal(err) + } + defer c.Close() + conns = append(conns, c) + } + w, err := s.Writer.Conn(ctx) + if err != nil { + t.Fatal(err) + } + defer w.Close() + conns = append(conns, w) + + for i, c := range conns { + var fk, busy int + var journal string + if err := c.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&fk); err != nil { + t.Fatal(err) + } + if err := c.QueryRowContext(ctx, "PRAGMA busy_timeout").Scan(&busy); err != nil { + t.Fatal(err) + } + if err := c.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&journal); err != nil { + t.Fatal(err) + } + if fk != 1 || busy != 5000 || journal != "wal" { + t.Errorf("connection %d: foreign_keys=%d busy_timeout=%d journal_mode=%q, want 1, 5000, wal", i, fk, busy, journal) + } + } +} + +func TestReaderIsReadOnly(t *testing.T) { + s := openTestStore(t) + if _, err := s.Reader.Exec("CREATE TABLE t (id INTEGER)"); err == nil { + t.Fatal("write through the reader succeeded") + } + if _, err := s.Writer.Exec("CREATE TABLE t (id INTEGER)"); err != nil { + t.Fatalf("write through the writer: %v", err) + } +} + +func TestForeignKeysEnforced(t *testing.T) { + s := openTestStore(t) + if _, err := s.Writer.Exec(` + CREATE TABLE parent (id INTEGER PRIMARY KEY); + CREATE TABLE child (parent_id INTEGER NOT NULL REFERENCES parent(id)); + `); err != nil { + t.Fatal(err) + } + if _, err := s.Writer.Exec("INSERT INTO child (parent_id) VALUES (42)"); err == nil { + t.Fatal("insert violating a foreign key succeeded") + } +} diff --git a/migrations/0000_bootstrap.sql b/migrations/0000_bootstrap.sql new file mode 100644 index 0000000..a21b38f --- /dev/null +++ b/migrations/0000_bootstrap.sql @@ -0,0 +1,4 @@ +-- 0000_bootstrap: immutable no-op bootstrap migration. It lets the migrations +-- embed compile and proves the runner end to end. Never edit or remove it: +-- applied versions are recorded by filename and never re-run. The initial v1 +-- schema arrives as a new migration, 0001_init.sql (Feature 02 task 3). diff --git a/migrations/embed.go b/migrations/embed.go new file mode 100644 index 0000000..a9cb022 --- /dev/null +++ b/migrations/embed.go @@ -0,0 +1,10 @@ +// Package migrations embeds the SQL schema migrations applied at boot by +// internal/store. Files are named NNNN_slug.sql and applied in filename order. +package migrations + +import "embed" + +// FS holds every *.sql migration in this directory. +// +//go:embed *.sql +var FS embed.FS From b06d1680a387142d0660eed9ac1f56280decc25e Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 08:54:55 -0400 Subject: [PATCH 2/5] Feature 02 task 2: Store helpers: transactions, ULIDs and time conventions (#11) Co-authored-by: dev_agent Co-committed-by: dev_agent --- go.mod | 1 + go.sum | 3 + internal/store/id.go | 27 ++++++++ internal/store/id_test.go | 25 +++++++ internal/store/time.go | 119 ++++++++++++++++++++++++++++++++++ internal/store/time_test.go | 126 ++++++++++++++++++++++++++++++++++++ internal/store/tx.go | 37 +++++++++++ internal/store/tx_test.go | 84 ++++++++++++++++++++++++ 8 files changed, 422 insertions(+) create mode 100644 internal/store/id.go create mode 100644 internal/store/id_test.go create mode 100644 internal/store/time.go create mode 100644 internal/store/time_test.go create mode 100644 internal/store/tx.go create mode 100644 internal/store/tx_test.go diff --git a/go.mod b/go.mod index 3c014a7..a517797 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( github.com/go-chi/chi/v5 v5.3.1 + github.com/oklog/ulid/v2 v2.1.2 modernc.org/sqlite v1.59.0 ) diff --git a/go.sum b/go.sum index 1d524f4..3a07785 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,9 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= +github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= diff --git a/internal/store/id.go b/internal/store/id.go new file mode 100644 index 0000000..28bafde --- /dev/null +++ b/internal/store/id.go @@ -0,0 +1,27 @@ +package store + +import ( + "crypto/rand" + "sync" + "time" + + "github.com/oklog/ulid/v2" +) + +var ( + idMu sync.Mutex + idEntropy = ulid.Monotonic(rand.Reader, 0) + idLastMS uint64 +) + +// NewID returns a new ULID as its canonical 26-character string. IDs from +// this process sort strictly in creation order, including within one +// millisecond and across a backwards wall-clock step. It panics if the +// monotonic entropy overflows within a millisecond. +func NewID() string { + idMu.Lock() + defer idMu.Unlock() + ms := max(ulid.Timestamp(time.Now()), idLastMS) + idLastMS = ms + return ulid.MustNew(ms, idEntropy).String() +} diff --git a/internal/store/id_test.go b/internal/store/id_test.go new file mode 100644 index 0000000..14f5f7d --- /dev/null +++ b/internal/store/id_test.go @@ -0,0 +1,25 @@ +package store + +import ( + "testing" + + "github.com/oklog/ulid/v2" +) + +func TestNewIDStrictlyIncreasing(t *testing.T) { + const n = 10000 + prev := NewID() + for range n { + id := NewID() + if len(id) != ulid.EncodedSize { + t.Fatalf("NewID() = %q, want %d characters", id, ulid.EncodedSize) + } + if _, err := ulid.ParseStrict(id); err != nil { + t.Fatalf("NewID() = %q: %v", id, err) + } + if id <= prev { + t.Fatalf("NewID() = %q, not after previous %q", id, prev) + } + prev = id + } +} diff --git a/internal/store/time.go b/internal/store/time.go new file mode 100644 index 0000000..e558bc2 --- /dev/null +++ b/internal/store/time.go @@ -0,0 +1,119 @@ +package store + +import ( + "database/sql/driver" + "errors" + "fmt" + "time" +) + +// timestampLayout is RFC 3339 in UTC with fixed millisecond precision, so +// stored timestamps sort lexicographically in time order. +const timestampLayout = "2006-01-02T15:04:05.000Z" + +// Timestamp is an instant stored as UTC RFC 3339 TEXT with millisecond +// precision. Sub-millisecond precision is dropped on write. Use +// sql.Null[Timestamp] for a nullable column. +type Timestamp struct { + time.Time +} + +// Now returns the current instant as a Timestamp, in UTC at millisecond +// precision, so it equals its stored form. +func Now() Timestamp { + return Timestamp{time.Now().UTC().Truncate(time.Millisecond)} +} + +// String returns the stored TEXT form. +func (t Timestamp) String() string { + return t.UTC().Format(timestampLayout) +} + +// Value implements driver.Valuer. The zero Timestamp is an error, as is a UTC +// year outside 0000–9999, which the four-digit stored form cannot represent. +func (t Timestamp) Value() (driver.Value, error) { + if t.IsZero() { + return nil, errors.New("store: zero Timestamp") + } + if y := t.UTC().Year(); y < 0 || y > 9999 { + return nil, fmt.Errorf("store: Timestamp year %d outside 0000-9999", y) + } + return t.String(), nil +} + +// Scan implements sql.Scanner. It accepts only TEXT in the stored form, and +// rejects the zero instant, which Value refuses to write. +func (t *Timestamp) Scan(src any) error { + s, ok := src.(string) + if !ok { + return fmt.Errorf("store: scan Timestamp: want TEXT, got %T", src) + } + parsed, err := time.Parse(timestampLayout, s) + if err != nil { + return fmt.Errorf("store: scan Timestamp %q: %w", s, err) + } + if parsed.Format(timestampLayout) != s { + return fmt.Errorf("store: scan Timestamp %q: want %s", s, timestampLayout) + } + if parsed.IsZero() { + return fmt.Errorf("store: scan Timestamp %q: zero Timestamp", s) + } + t.Time = parsed + return nil +} + +// localDateLayout is the household-local calendar date form. +const localDateLayout = "2006-01-02" + +// LocalDate is a household-local calendar date with no time or zone, stored +// as YYYY-MM-DD TEXT. The zero LocalDate is invalid and cannot be stored. +type LocalDate struct { + year int + month time.Month + day int +} + +// ParseLocalDate parses a YYYY-MM-DD date, rejecting any other form and any +// date that does not exist on the calendar. +func ParseLocalDate(s string) (LocalDate, error) { + t, err := time.Parse(localDateLayout, s) + if err != nil { + return LocalDate{}, fmt.Errorf("store: invalid date %q: %w", s, err) + } + if t.Format(localDateLayout) != s { + return LocalDate{}, fmt.Errorf("store: invalid date %q: want YYYY-MM-DD", s) + } + return LocalDate{t.Year(), t.Month(), t.Day()}, nil +} + +// String returns the YYYY-MM-DD form. +func (d LocalDate) String() string { + return fmt.Sprintf("%04d-%02d-%02d", d.year, int(d.month), d.day) +} + +// IsZero reports whether d is the zero LocalDate. +func (d LocalDate) IsZero() bool { + return d == LocalDate{} +} + +// Value implements driver.Valuer. The zero LocalDate is an error. +func (d LocalDate) Value() (driver.Value, error) { + if d.IsZero() { + return nil, errors.New("store: zero LocalDate") + } + return d.String(), nil +} + +// Scan implements sql.Scanner. It accepts only YYYY-MM-DD TEXT. +func (d *LocalDate) Scan(src any) error { + s, ok := src.(string) + if !ok { + return fmt.Errorf("store: scan LocalDate: want TEXT, got %T", src) + } + parsed, err := ParseLocalDate(s) + if err != nil { + return fmt.Errorf("store: scan LocalDate: %w", err) + } + *d = parsed + return nil +} diff --git a/internal/store/time_test.go b/internal/store/time_test.go new file mode 100644 index 0000000..e809633 --- /dev/null +++ b/internal/store/time_test.go @@ -0,0 +1,126 @@ +package store + +import ( + "context" + "testing" + "time" +) + +func openTypesTestStore(t *testing.T) *Store { + t.Helper() + s := openTestStore(t) + if _, err := s.Writer.Exec("CREATE TABLE v (val TEXT)"); err != nil { + t.Fatal(err) + } + return s +} + +// roundTrip writes in through the writer, then reads it back into out and +// returns the stored SQLite type and raw text. +func roundTrip(t *testing.T, s *Store, in, out any) (typ, raw string) { + t.Helper() + ctx := context.Background() + if _, err := s.Writer.ExecContext(ctx, "DELETE FROM v"); err != nil { + t.Fatal(err) + } + if _, err := s.Writer.ExecContext(ctx, "INSERT INTO v (val) VALUES (?)", in); err != nil { + t.Fatal(err) + } + if err := s.Reader.QueryRowContext(ctx, "SELECT typeof(val), val, val FROM v").Scan(&typ, &raw, out); err != nil { + t.Fatal(err) + } + return typ, raw +} + +func TestTimestampRoundTrip(t *testing.T) { + s := openTypesTestStore(t) + zone := time.FixedZone("EDT", -4*60*60) + in := Timestamp{time.Date(2026, 10, 3, 8, 37, 5, 123456789, zone)} + + var out Timestamp + typ, raw := roundTrip(t, s, in, &out) + if typ != "text" || raw != "2026-10-03T12:37:05.123Z" { + t.Fatalf("stored %s %q, want text %q", typ, raw, "2026-10-03T12:37:05.123Z") + } + if want := in.Truncate(time.Millisecond); !out.Equal(want) { + t.Fatalf("read back %v, want %v", out, want) + } + if out.Location() != time.UTC { + t.Fatalf("read back location %v, want UTC", out.Location()) + } + + now := Now() + roundTrip(t, s, now, &out) + if out != now { + t.Fatalf("Now() round trip = %#v, want %#v", out, now) + } +} + +func TestTimestampRejects(t *testing.T) { + if _, err := (Timestamp{}).Value(); err == nil { + t.Error("zero Timestamp Value succeeded") + } + for _, tm := range []time.Time{ + time.Date(10000, 1, 1, 0, 0, 0, 0, time.UTC), + time.Date(-1, 12, 31, 0, 0, 0, 0, time.UTC), + time.Date(9999, 12, 31, 23, 0, 0, 0, time.FixedZone("west", -2*3600)), + } { + if v, err := (Timestamp{tm}).Value(); err == nil { + t.Errorf("Timestamp{%v}.Value() = %v, want error", tm, v) + } + } + for _, src := range []any{ + nil, int64(0), []byte("2026-10-03T12:37:05.123Z"), "2026-10-03T12:37:05Z", + "2026-10-03T08:37:05.123-04:00", "2026-10-03T12:37:05,123Z", "garbage", + "0001-01-01T00:00:00.000Z", + } { + var ts Timestamp + if err := ts.Scan(src); err == nil { + t.Errorf("Scan(%#v) succeeded", src) + } + } +} + +func TestLocalDateRoundTrip(t *testing.T) { + s := openTypesTestStore(t) + for _, str := range []string{"2026-10-03", "2028-02-29", "0001-01-01", "9999-12-31"} { + in, err := ParseLocalDate(str) + if err != nil { + t.Fatalf("ParseLocalDate(%q): %v", str, err) + } + if in.String() != str { + t.Fatalf("String() = %q, want %q", in.String(), str) + } + var out LocalDate + typ, raw := roundTrip(t, s, in, &out) + if typ != "text" || raw != str { + t.Fatalf("stored %s %q, want text %q", typ, raw, str) + } + if out != in { + t.Fatalf("read back %v, want %v", out, in) + } + } +} + +func TestLocalDateRejectsInvalid(t *testing.T) { + for _, str := range []string{ + "", "2026-02-30", "2026-02-29", "2026-13-01", "2026-00-10", "2026-10-00", + "2026-10-32", "2026-1-03", "2026-10-3", "26-10-03", "2026/10/03", + "2026-10-03T00:00:00Z", " 2026-10-03", "20261003", "not-a-date", + } { + if d, err := ParseLocalDate(str); err == nil { + t.Errorf("ParseLocalDate(%q) = %v, want error", str, d) + } + var d LocalDate + if err := d.Scan(str); err == nil { + t.Errorf("Scan(%q) succeeded", str) + } + } + if _, err := (LocalDate{}).Value(); err == nil { + t.Error("zero LocalDate Value succeeded") + } + var d LocalDate + if err := d.Scan(nil); err == nil { + t.Error("Scan(nil) succeeded") + } +} diff --git a/internal/store/tx.go b/internal/store/tx.go new file mode 100644 index 0000000..bb53661 --- /dev/null +++ b/internal/store/tx.go @@ -0,0 +1,37 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +// WithTx runs fn in a transaction on the writer. It commits when fn returns +// nil and rolls back when fn returns an error, panics or exits the goroutine. +// A panic is not recovered: it propagates unchanged after the rollback. +func (s *Store) WithTx(ctx context.Context, fn func(tx *sql.Tx) error) error { + tx, err := s.Writer.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("store: begin transaction: %w", err) + } + finished := false + defer func() { + if !finished { + _ = tx.Rollback() // fn panicked; the panic carries on regardless + } + }() + + if err := fn(tx); err != nil { + finished = true + if rbErr := tx.Rollback(); rbErr != nil { + return errors.Join(err, fmt.Errorf("store: roll back transaction: %w", rbErr)) + } + return err + } + finished = true + if err := tx.Commit(); err != nil { + return fmt.Errorf("store: commit transaction: %w", err) + } + return nil +} diff --git a/internal/store/tx_test.go b/internal/store/tx_test.go new file mode 100644 index 0000000..a8c196e --- /dev/null +++ b/internal/store/tx_test.go @@ -0,0 +1,84 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "testing" +) + +func openTxTestStore(t *testing.T) *Store { + t.Helper() + s := openTestStore(t) + if _, err := s.Writer.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY)"); err != nil { + t.Fatal(err) + } + return s +} + +func countRows(t *testing.T, s *Store) int { + t.Helper() + var n int + if err := s.Reader.QueryRow("SELECT count(*) FROM t").Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +func insertRow(tx *sql.Tx) error { + _, err := tx.Exec("INSERT INTO t (id) VALUES (1)") + return err +} + +func TestWithTxCommitsOnNil(t *testing.T) { + s := openTxTestStore(t) + if err := s.WithTx(context.Background(), insertRow); err != nil { + t.Fatal(err) + } + if n := countRows(t, s); n != 1 { + t.Fatalf("rows after commit = %d, want 1", n) + } +} + +func TestWithTxRollsBackOnError(t *testing.T) { + s := openTxTestStore(t) + errBoom := errors.New("boom") + err := s.WithTx(context.Background(), func(tx *sql.Tx) error { + if err := insertRow(tx); err != nil { + t.Fatal(err) + } + return errBoom + }) + if !errors.Is(err, errBoom) { + t.Fatalf("WithTx error = %v, want %v", err, errBoom) + } + if n := countRows(t, s); n != 0 { + t.Fatalf("rows after rollback = %d, want 0", n) + } +} + +func TestWithTxRollsBackOnPanic(t *testing.T) { + s := openTxTestStore(t) + const value = "kaboom" + func() { + defer func() { + if p := recover(); p != value { + t.Fatalf("recovered %v, want panic %q to propagate", p, value) + } + }() + _ = s.WithTx(context.Background(), func(tx *sql.Tx) error { + if err := insertRow(tx); err != nil { + t.Fatal(err) + } + panic(value) + }) + t.Fatal("WithTx returned instead of panicking") + }() + if n := countRows(t, s); n != 0 { + t.Fatalf("rows after panic = %d, want 0", n) + } + // The single writer connection must have been released. + if err := s.WithTx(context.Background(), insertRow); err != nil { + t.Fatalf("WithTx after panic: %v", err) + } +} From fa7c6b721822b5b784a410c13b159d6e5e417377 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 09:06:21 -0400 Subject: [PATCH 3/5] Feature 02 task 3: Initial v1 schema migration (#12) Co-authored-by: dev_agent Co-committed-by: dev_agent --- internal/store/migrate_test.go | 21 +- internal/store/schema_test.go | 556 +++++++++++++++++++++++++++++++++ migrations/0001_init.sql | 333 ++++++++++++++++++++ 3 files changed, 898 insertions(+), 12 deletions(-) create mode 100644 internal/store/schema_test.go create mode 100644 migrations/0001_init.sql diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index 35a0824..99d381c 100644 --- a/internal/store/migrate_test.go +++ b/internal/store/migrate_test.go @@ -174,33 +174,30 @@ func TestMigrateEmbeddedMigrations(t *testing.T) { if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil { t.Fatal(err) } - if got := versions(migrationRows(t, s)); got != "0000_bootstrap" { - t.Errorf("versions = %q, want 0000_bootstrap", got) + if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init" { + t.Errorf("versions = %q, want 0000_bootstrap,0001_init", got) } } -func TestMigrateEmbeddedThenNewMigrationApplies(t *testing.T) { +func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) { ctx := context.Background() s := openTestStore(t) - if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { - t.Fatal(err) - } bootstrap, err := fs.ReadFile(migrations.FS, "0000_bootstrap.sql") if err != nil { t.Fatal(err) } - // A later build adds 0001_init.sql alongside the unchanged bootstrap. - upgraded := fstest.MapFS{ - "0000_bootstrap.sql": {Data: bootstrap}, - "0001_init.sql": {Data: []byte("CREATE TABLE later (id INTEGER PRIMARY KEY);")}, + // A database migrated by the bootstrap-only build upgrades to the + // embedded set, which adds 0001_init.sql alongside the unchanged bootstrap. + if err := Migrate(ctx, s.Writer, fstest.MapFS{"0000_bootstrap.sql": {Data: bootstrap}}); err != nil { + t.Fatal(err) } - if err := Migrate(ctx, s.Writer, upgraded); err != nil { + if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { t.Fatal(err) } if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init" { t.Errorf("versions = %q, want 0000_bootstrap,0001_init", got) } - if !tableExists(t, s, "later") { + if !tableExists(t, s, "dinners") { t.Error("table from 0001_init was not created") } } diff --git a/internal/store/schema_test.go b/internal/store/schema_test.go new file mode 100644 index 0000000..ed61047 --- /dev/null +++ b/internal/store/schema_test.go @@ -0,0 +1,556 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "slices" + "strings" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +// schemaTables is every table in the v1 data model, in insert order: parents +// before the children that reference them. +var schemaTables = []string{ + "ai_runs", + "recipes", + "recipe_ingredients", + "recipe_steps", + "photos", + "dinners", + "suggestion_batches", + "suggestions", + "week_plans", + "plan_days", + "rotation_reviews", + "shopping_lists", + "shopping_groups", + "shopping_items", + "notification_prefs", + "push_subscriptions", + "notification_outbox", + "jobs", + "schedule_runs", + "sessions", + // Single-row settings tables; row is always id 1. + "household_settings", + "ai_settings", + "recipe_search_settings", + "integrations_google", + "chat_settings", +} + +var singleRowTables = []string{ + "household_settings", + "ai_settings", + "recipe_search_settings", + "integrations_google", + "chat_settings", +} + +const testTime = "2026-10-03T12:00:00Z" + +// rowID is the id of the i-th test row of table. The schema stores ids as +// opaque TEXT, so deterministic ids keep the fixtures readable. +func rowID(table string, i int) string { return fmt.Sprintf("%s-%d", table, i) } + +// testRow returns a valid row for table. Rows with different i differ in +// every unique column; references point at row 0 of the parent table. +func testRow(table string, i int) map[string]any { + ts := map[string]any{"created_at": testTime, "updated_at": testTime} + r := map[string]any{"id": rowID(table, i)} + switch table { + case "household_settings": + r = map[string]any{"id": 1, "timezone": "America/New_York"} + case "ai_runs": + r["skill"] = "suggest" + r["skill_version"] = "1" + r["provider"] = "anthropic" + r["model"] = "claude" + r["temperature"] = 0.7 + r["status"] = "ok" + r["request_redacted"] = "{}" + case "recipes": + r["source_url"] = fmt.Sprintf("https://example.com/recipe/%d", i) + r["title"] = "Chili" + r["raw_jsonld"] = "{}" + r["fetched_at"] = testTime + case "recipe_ingredients": + r["recipe_id"] = rowID("recipes", 0) + r["position"] = i + r["raw_text"] = "1 onion" + case "recipe_steps": + r["recipe_id"] = rowID("recipes", 0) + r["position"] = i + r["text"] = "Chop the onion." + case "photos": + r["source_url"] = "https://example.com/chili.jpg" + r["local_path"] = "photos/chili.jpg" + r["width"] = 800 + r["height"] = 600 + r["sha256"] = "abc" + r["fetched_at"] = testTime + case "dinners": + r["name"] = "Chili" + r["normalized_name"] = "chili" + r["status"] = "regular" + r["origin"] = "regular" + r["method"] = "crockpot" + r["difficulty"] = "easy" + r["is_favorite"] = 0 + r["recipe_id"] = rowID("recipes", 0) + r["photo_id"] = rowID("photos", 0) + r["grounding_status"] = "grounded" + case "suggestion_batches": + r["trigger"] = "scheduled" + r["status"] = "done" + r["ai_run_id"] = rowID("ai_runs", 0) + case "suggestions": + r["dinner_id"] = rowID("dinners", 0) + r["batch_id"] = rowID("suggestion_batches", 0) + r["reason"] = "Like your chili." + r["cited_regular_ids"] = "[]" + r["status"] = "pending" + case "week_plans": + r["week_start"] = fmt.Sprintf("2026-%02d-05", i+1) + r["status"] = "draft" + r["ai_run_id"] = rowID("ai_runs", 0) + case "plan_days": + r["plan_id"] = rowID("week_plans", 0) + r["date"] = fmt.Sprintf("2026-01-%02d", i+5) + r["dinner_id"] = rowID("dinners", 0) + r["is_new"] = 0 + case "rotation_reviews": + r["dinner_id"] = rowID("dinners", 0) + r["plan_date"] = fmt.Sprintf("2026-01-%02d", i+5) + r["status"] = "decided" + r["decision"] = "keep" + case "shopping_lists": + r["week_start"] = fmt.Sprintf("2026-%02d-05", i+1) + r["plan_id"] = rowID("week_plans", 0) + r["title"] = "Supply run" + r["status"] = "current" + case "shopping_groups": + r["list_id"] = rowID("shopping_lists", 0) + r["dinner_id"] = rowID("dinners", 0) + r["position"] = i + case "shopping_items": + r["group_id"] = rowID("shopping_groups", 0) + r["name"] = "Onion" + r["needed"] = 1 + r["position"] = i + case "ai_settings": + r = map[string]any{"id": 1, "provider": "ollama", "base_url": "http://ollama:11434", "model": "llama", "temperature": 0.7} + case "recipe_search_settings": + r = map[string]any{"id": 1, "provider": "searxng", "base_url": "http://searxng:8080"} + case "integrations_google": + r = map[string]any{"id": 1, "refresh_token_ct": []byte{1}, "connected_at": testTime} + case "chat_settings": + r = map[string]any{"id": 1, "webhook_url_ct": []byte{1}, "last_test_status": "ok"} + case "notification_prefs": + events := []string{"plan_ready", "list_ready", "day_of_reminder", "new_suggestions", "rotation_review"} + r = map[string]any{"event": events[i], "push": 1, "chat": 0} + case "push_subscriptions": + r["endpoint"] = fmt.Sprintf("https://push.example.com/%d", i) + r["p256dh"] = "key" + r["auth"] = "auth" + r["failure_count"] = 0 + case "notification_outbox": + r["event_key"] = fmt.Sprintf("plan_ready:2026-W%02d", i+1) + r["channel"] = "push" + r["payload"] = "{}" + r["status"] = "pending" + r["attempts"] = 0 + case "jobs": + r["kind"] = "suggest" + r["status"] = "pending" + r["payload"] = "{}" + case "schedule_runs": + r["schedule"] = "plan_draft" + r["period_key"] = fmt.Sprintf("2026-W%02d", i+1) + r["created_at"] = testTime + return r + case "sessions": + return map[string]any{ + "id_hash": fmt.Sprintf("hash-%d", i), + "subject": "sub", + "email": "kitchen@example.com", + "name": "Our kitchen", + "groups": `["mealquest-admin"]`, + "csrf_token": "csrf", + "created_at": testTime, + "last_seen_at": testTime, + "expires_at": testTime, + } + default: + panic("testRow: unknown table " + table) + } + for k, v := range ts { + r[k] = v + } + return r +} + +type execer interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + +func insertSchemaRow(db execer, table string, row map[string]any) error { + cols := make([]string, 0, len(row)) + for c := range row { + cols = append(cols, c) + } + slices.Sort(cols) + quoted := make([]string, len(cols)) + args := make([]any, len(cols)) + for i, c := range cols { + quoted[i] = `"` + c + `"` + args[i] = row[c] + } + q := fmt.Sprintf(`INSERT INTO "%s" (%s) VALUES (%s)`, + table, strings.Join(quoted, ", "), strings.TrimSuffix(strings.Repeat("?, ", len(cols)), ", ")) + _, err := db.ExecContext(context.Background(), q, args...) + return err +} + +// migratedStore opens a temp store migrated with the embedded migrations. +func migratedStore(t *testing.T) *Store { + t.Helper() + s := openTestStore(t) + if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + return s +} + +// seededStore is a migrated store holding row 0 of every multi-row table. +// Single-row tables are left empty so tests can insert their only row. +func seededStore(t *testing.T) *Store { + t.Helper() + s := migratedStore(t) + for _, table := range schemaTables { + if slices.Contains(singleRowTables, table) { + continue + } + if err := insertSchemaRow(s.Writer, table, testRow(table, 0)); err != nil { + t.Fatalf("seed %s: %v", table, err) + } + } + return s +} + +// inRolledBackTx runs fn in a writer transaction that is always rolled back, +// so each case starts from the seeded state. +func inRolledBackTx(t *testing.T, s *Store, fn func(tx *sql.Tx)) { + t.Helper() + tx, err := s.Writer.BeginTx(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := tx.Rollback(); err != nil { + t.Fatal(err) + } + }() + fn(tx) +} + +func wantConstraintError(t *testing.T, err error, kind, what string) { + t.Helper() + if err == nil { + t.Fatalf("%s: accepted, want %s constraint failure", what, kind) + } + if !strings.Contains(err.Error(), kind+" constraint failed") { + t.Fatalf("%s: error %q, want %s constraint failure", what, err, kind) + } +} + +func TestSchemaTablesExist(t *testing.T) { + s := migratedStore(t) + if len(schemaTables) != 25 { + t.Fatalf("schemaTables lists %d tables, want 25", len(schemaTables)) + } + for _, table := range schemaTables { + if !tableExists(t, s, table) { + t.Errorf("table %s missing", table) + } + } +} + +func TestSchemaForeignKeysAreValid(t *testing.T) { + s := seededStore(t) + rows, err := s.Writer.Query("PRAGMA foreign_key_check") + if err != nil { + t.Fatal(err) + } + defer rows.Close() + if rows.Next() { + t.Error("foreign_key_check reported violations in the seeded data") + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } +} + +func TestSchemaEnumChecksRejectInvalidValues(t *testing.T) { + cases := []struct { + table, column string + bad any + }{ + {"household_settings", "plan_draft_dow", 7}, + {"household_settings", "suggestions_dow", -1}, + {"household_settings", "chat_autosend_dow", 7}, + {"household_settings", "plan_draft_dow", 5.5}, + {"household_settings", "suggestions_dow", 0.5}, + {"household_settings", "chat_autosend_dow", 3.5}, + {"ai_runs", "provider", "openai"}, + {"ai_runs", "status", "done"}, + {"dinners", "status", "deck"}, + {"dinners", "origin", "ai"}, + {"dinners", "method", "microwave"}, + {"dinners", "difficulty", "extreme"}, + {"dinners", "is_favorite", 2}, + {"dinners", "grounding_status", "unknown"}, + {"suggestion_batches", "trigger", "cron"}, + {"suggestion_batches", "status", "ok"}, + {"suggestions", "status", "maybe"}, + {"week_plans", "status", "final"}, + {"plan_days", "is_new", 2}, + {"rotation_reviews", "status", "open"}, + {"rotation_reviews", "decision", "promote"}, + {"shopping_lists", "status", "frozen"}, + {"shopping_items", "needed", 2}, + {"ai_settings", "provider", "openai"}, + {"recipe_search_settings", "provider", "google"}, + {"chat_settings", "last_test_status", "pending"}, + {"notification_prefs", "event", "weekly_digest"}, + {"notification_prefs", "push", 2}, + {"notification_prefs", "chat", 2}, + {"notification_outbox", "channel", "email"}, + {"notification_outbox", "status", "queued"}, + {"jobs", "status", "queued"}, + {"schedule_runs", "schedule", "hourly"}, + } + s := seededStore(t) + for _, c := range cases { + t.Run(fmt.Sprintf("%s.%s=%v", c.table, c.column, c.bad), func(t *testing.T) { + row := testRow(c.table, 1) + if slices.Contains(singleRowTables, c.table) { + row = testRow(c.table, 0) + } + inRolledBackTx(t, s, func(tx *sql.Tx) { + if err := insertSchemaRow(tx, c.table, row); err != nil { + t.Fatalf("valid row rejected: %v", err) + } + }) + row[c.column] = c.bad + inRolledBackTx(t, s, func(tx *sql.Tx) { + wantConstraintError(t, insertSchemaRow(tx, c.table, row), "CHECK", fmt.Sprintf("%s.%s = %v", c.table, c.column, c.bad)) + }) + }) + } +} + +func TestSchemaRotationReviewEventAllowed(t *testing.T) { + s := migratedStore(t) + if err := insertSchemaRow(s.Writer, "notification_prefs", testRow("notification_prefs", 4)); err != nil { + t.Fatalf("rotation_review event rejected: %v", err) + } +} + +func TestSchemaDinnerStatuses(t *testing.T) { + s := seededStore(t) + for _, status := range []string{"suggested", "quest_log", "tried", "regular", "archived"} { + inRolledBackTx(t, s, func(tx *sql.Tx) { + row := testRow("dinners", 1) + row["status"] = status + if err := insertSchemaRow(tx, "dinners", row); err != nil { + t.Errorf("status %s rejected: %v", status, err) + } + }) + } +} + +func TestSchemaUniqueConstraintsRejectDuplicates(t *testing.T) { + cases := []struct { + table string + columns []string + }{ + {"recipes", []string{"source_url"}}, + {"week_plans", []string{"week_start"}}, + {"plan_days", []string{"plan_id", "date"}}, + {"rotation_reviews", []string{"dinner_id", "plan_date"}}, + {"shopping_lists", []string{"week_start"}}, + {"push_subscriptions", []string{"endpoint"}}, + {"notification_outbox", []string{"event_key", "channel"}}, + {"schedule_runs", []string{"schedule", "period_key"}}, + {"notification_prefs", []string{"event"}}, + } + s := seededStore(t) + for _, c := range cases { + t.Run(c.table+"("+strings.Join(c.columns, ",")+")", func(t *testing.T) { + row := testRow(c.table, 1) + inRolledBackTx(t, s, func(tx *sql.Tx) { + if err := insertSchemaRow(tx, c.table, row); err != nil { + t.Fatalf("distinct row rejected: %v", err) + } + }) + seeded := testRow(c.table, 0) + for _, col := range c.columns { + row[col] = seeded[col] + } + inRolledBackTx(t, s, func(tx *sql.Tx) { + wantConstraintError(t, insertSchemaRow(tx, c.table, row), "UNIQUE", "duplicate "+c.table) + }) + }) + } +} + +// TestSchemaCompositeUniqueAllowsPartialOverlap shows each composite UNIQUE +// spans all its columns: a row sharing only some of them with the seeded row +// is accepted. (Sharing only the other columns is covered by the distinct row +// in TestSchemaUniqueConstraintsRejectDuplicates.) +func TestSchemaCompositeUniqueAllowsPartialOverlap(t *testing.T) { + cases := []struct { + name string + table string + shared string // column copied from the seeded row + parent string // row 1 of this table is inserted first, if set + set map[string]any // columns that differ from the seeded row + }{ + {"same date, different plan", "plan_days", "date", "week_plans", map[string]any{"plan_id": rowID("week_plans", 1)}}, + {"same plan_date, different dinner", "rotation_reviews", "plan_date", "dinners", map[string]any{"dinner_id": rowID("dinners", 1)}}, + {"same event_key, different channel", "notification_outbox", "event_key", "", map[string]any{"channel": "chat"}}, + {"same period_key, different schedule", "schedule_runs", "period_key", "", map[string]any{"schedule": "week_rollover"}}, + } + s := seededStore(t) + for _, c := range cases { + t.Run(c.table+": "+c.name, func(t *testing.T) { + row := testRow(c.table, 1) + row[c.shared] = testRow(c.table, 0)[c.shared] + for k, v := range c.set { + row[k] = v + } + inRolledBackTx(t, s, func(tx *sql.Tx) { + if c.parent != "" { + if err := insertSchemaRow(tx, c.parent, testRow(c.parent, 1)); err != nil { + t.Fatalf("insert parent: %v", err) + } + } + if err := insertSchemaRow(tx, c.table, row); err != nil { + t.Fatalf("row rejected: %v", err) + } + }) + }) + } +} + +// TestSchemaTextPrimaryKeysRejectNull guards against SQLite's legacy +// acceptance of NULL in non-INTEGER primary keys. +func TestSchemaTextPrimaryKeysRejectNull(t *testing.T) { + s := seededStore(t) + ctx := context.Background() + for _, table := range schemaTables { + if slices.Contains(singleRowTables, table) { + continue + } + key := "id" + switch table { + case "notification_prefs": + key = "event" + case "sessions": + key = "id_hash" + } + t.Run(table+"."+key, func(t *testing.T) { + inRolledBackTx(t, s, func(tx *sql.Tx) { + // Two NULL-key rows: neither may be accepted, so NULL can + // never stand in for a key, once or repeatedly. + for i := 1; i <= 2; i++ { + row := testRow(table, i) + row[key] = nil + wantConstraintError(t, insertSchemaRow(tx, table, row), "NOT NULL", fmt.Sprintf("%s.%s = NULL (row %d)", table, key, i)) + } + var n int + if err := tx.QueryRowContext(ctx, fmt.Sprintf(`SELECT count(*) FROM "%s" WHERE "%s" IS NULL`, table, key)).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 0 { + t.Errorf("%d rows with NULL %s", n, key) + } + }) + }) + } +} + +func TestSchemaSingleRowTablesRejectSecondRow(t *testing.T) { + s := migratedStore(t) + for _, table := range singleRowTables { + if err := insertSchemaRow(s.Writer, table, testRow(table, 0)); err != nil { + t.Fatalf("%s: %v", table, err) + } + row := testRow(table, 0) + row["id"] = 2 + wantConstraintError(t, insertSchemaRow(s.Writer, table, row), "CHECK", table+" second row") + } +} + +func TestSchemaDeleteParentWithChildrenRefused(t *testing.T) { + cases := []struct{ parent, child, column string }{ + {"recipes", "recipe_ingredients", "recipe_id"}, + {"recipes", "recipe_steps", "recipe_id"}, + {"recipes", "dinners", "recipe_id"}, + {"photos", "dinners", "photo_id"}, + {"dinners", "suggestions", "dinner_id"}, + {"suggestion_batches", "suggestions", "batch_id"}, + {"week_plans", "plan_days", "plan_id"}, + {"dinners", "plan_days", "dinner_id"}, + {"dinners", "rotation_reviews", "dinner_id"}, + {"week_plans", "shopping_lists", "plan_id"}, + {"shopping_lists", "shopping_groups", "list_id"}, + {"dinners", "shopping_groups", "dinner_id"}, + {"shopping_groups", "shopping_items", "group_id"}, + // ai_runs too: the purge must clear references before deleting. + {"ai_runs", "suggestion_batches", "ai_run_id"}, + {"ai_runs", "week_plans", "ai_run_id"}, + } + s := seededStore(t) + for _, c := range cases { + t.Run(c.parent+"<-"+c.child+"."+c.column, func(t *testing.T) { + // A fresh parent with exactly one child, through this column only. + parentID := rowID(c.parent, 1) + child := testRow(c.child, 1) + child[c.column] = parentID + del := fmt.Sprintf(`DELETE FROM "%s" WHERE id = ?`, c.parent) + ctx := context.Background() + + inRolledBackTx(t, s, func(tx *sql.Tx) { + if err := insertSchemaRow(tx, c.parent, testRow(c.parent, 1)); err != nil { + t.Fatalf("insert parent: %v", err) + } + if err := insertSchemaRow(tx, c.child, child); err != nil { + t.Fatalf("insert child: %v", err) + } + _, err := tx.ExecContext(ctx, del, parentID) + wantConstraintError(t, err, "FOREIGN KEY", "delete "+c.parent) + var n int + if err := tx.QueryRowContext(ctx, fmt.Sprintf(`SELECT count(*) FROM "%s" WHERE id = ?`, c.parent), parentID).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Errorf("parent row gone after refused delete") + } + }) + // Control: without the child the same delete succeeds. + inRolledBackTx(t, s, func(tx *sql.Tx) { + if err := insertSchemaRow(tx, c.parent, testRow(c.parent, 1)); err != nil { + t.Fatalf("insert parent: %v", err) + } + if _, err := tx.ExecContext(ctx, del, parentID); err != nil { + t.Fatalf("delete childless parent: %v", err) + } + }) + }) + } +} diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql new file mode 100644 index 0000000..80bfe04 --- /dev/null +++ b/migrations/0001_init.sql @@ -0,0 +1,333 @@ +-- 0001_init: the initial v1 schema (CTXD CONTEXT.MD data model). +-- +-- Conventions: +-- * Row ids are ULIDs stored as TEXT NOT NULL PRIMARY KEY (SQLite would +-- otherwise accept NULL in a TEXT primary key). Single-row settings tables +-- use id INTEGER PRIMARY KEY CHECK (id = 1) so a second row is refused. +-- * Timestamps are UTC RFC 3339 TEXT. Household-local dates (week_start, +-- plan day) are YYYY-MM-DD TEXT; household-local times are HH:MM TEXT. +-- * Columns ending in _ct only ever hold secretbox ciphertext. +-- * Every enum column has a CHECK. Booleans are INTEGER CHECK (x IN (0, 1)). +-- Days of the week are INTEGER IN (0, 1, 2, 3, 4, 5, 6), Sunday = 0 (Go +-- time.Weekday); IN rather than BETWEEN so fractional values are refused. +-- * Foreign keys use the default NO ACTION, so deleting a parent that still +-- has children is refused. This includes ai_run_id: the 30-day ai_runs +-- purge must clear those references explicitly before deleting. +-- * Defaults exist only where CTXD names one (household_settings times and +-- shopping_items.needed). +-- +-- Enum value sets not spelled out in CONTEXT.MD, chosen here: +-- ai_settings.provider, ai_runs.provider: openai_compatible, anthropic, ollama +-- ai_runs.status: running, ok, error +-- chat_settings.last_test_status: ok, failed +-- jobs.status, suggestion_batches.status: pending, running, done, failed +-- notification_outbox.status: pending, sent, failed +-- schedule_runs.schedule: suggestions_weekly, plan_draft, +-- shopping_chat_autosend, day_of_reminder, rotation_check, week_rollover, +-- ai_runs_purge +-- jobs.kind and ai_runs.skill are open identifiers that grow with features, +-- not enums, so they carry no CHECK. + +CREATE TABLE household_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + timezone TEXT NOT NULL, + dinner_time TEXT NOT NULL DEFAULT '17:30', + reminder_time TEXT NOT NULL DEFAULT '16:30', + review_time TEXT NOT NULL DEFAULT '09:00', + plan_draft_dow INTEGER NOT NULL DEFAULT 5 CHECK (plan_draft_dow IN (0, 1, 2, 3, 4, 5, 6)), + plan_draft_time TEXT NOT NULL DEFAULT '09:00', + suggestions_dow INTEGER NOT NULL DEFAULT 0 CHECK (suggestions_dow IN (0, 1, 2, 3, 4, 5, 6)), + suggestions_time TEXT NOT NULL DEFAULT '08:00', + chat_autosend_dow INTEGER NOT NULL DEFAULT 6 CHECK (chat_autosend_dow IN (0, 1, 2, 3, 4, 5, 6)), + chat_autosend_time TEXT NOT NULL DEFAULT '08:00', + onboarding_completed_at TEXT, + updates_seen_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE ai_runs ( + id TEXT NOT NULL PRIMARY KEY, + skill TEXT NOT NULL, + skill_version TEXT NOT NULL, + provider TEXT NOT NULL CHECK (provider IN ('openai_compatible', 'anthropic', 'ollama')), + model TEXT NOT NULL, + temperature REAL NOT NULL, + status TEXT NOT NULL CHECK (status IN ('running', 'ok', 'error')), + latency_ms INTEGER, + tokens_in INTEGER, + tokens_out INTEGER, + error TEXT, + request_redacted TEXT NOT NULL, + response_redacted TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE recipes ( + id TEXT NOT NULL PRIMARY KEY, + source_url TEXT NOT NULL UNIQUE, + site_name TEXT, + title TEXT NOT NULL, + total_minutes INTEGER, + prep_minutes INTEGER, + cook_minutes INTEGER, + yield TEXT, + raw_jsonld TEXT NOT NULL, + fetched_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE recipe_ingredients ( + id TEXT NOT NULL PRIMARY KEY, + recipe_id TEXT NOT NULL REFERENCES recipes (id), + position INTEGER NOT NULL, + raw_text TEXT NOT NULL, + name TEXT, + qty TEXT, + unit TEXT, + note TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE recipe_steps ( + id TEXT NOT NULL PRIMARY KEY, + recipe_id TEXT NOT NULL REFERENCES recipes (id), + position INTEGER NOT NULL, + text TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE photos ( + id TEXT NOT NULL PRIMARY KEY, + source_url TEXT NOT NULL, + local_path TEXT NOT NULL, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + sha256 TEXT NOT NULL, + fetched_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE dinners ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + normalized_name TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('suggested', 'quest_log', 'tried', 'regular', 'archived')), + origin TEXT NOT NULL CHECK (origin IN ('regular', 'side_quest', 'ingredients', 'quick', 'manual')), + method TEXT CHECK (method IN ('stovetop', 'oven', 'crockpot', 'grill', 'other')), + minutes_total INTEGER, + difficulty TEXT CHECK (difficulty IN ('easy', 'medium', 'hard')), + cuisine TEXT, + is_favorite INTEGER NOT NULL CHECK (is_favorite IN (0, 1)), + recipe_id TEXT REFERENCES recipes (id), + photo_id TEXT REFERENCES photos (id), + grounding_status TEXT NOT NULL CHECK (grounding_status IN ('pending', 'grounded', 'failed', 'manual')), + updated_by TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE suggestion_batches ( + id TEXT NOT NULL PRIMARY KEY, + trigger TEXT NOT NULL CHECK (trigger IN ('scheduled', 'manual', 'replenish')), + status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'done', 'failed')), + ai_run_id TEXT REFERENCES ai_runs (id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE suggestions ( + id TEXT NOT NULL PRIMARY KEY, + dinner_id TEXT NOT NULL REFERENCES dinners (id), + batch_id TEXT NOT NULL REFERENCES suggestion_batches (id), + reason TEXT NOT NULL, + cited_regular_ids TEXT NOT NULL, -- JSON array of dinner ids + status TEXT NOT NULL CHECK (status IN ('pending', 'accepted', 'declined')), + decided_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE week_plans ( + id TEXT NOT NULL PRIMARY KEY, + week_start TEXT NOT NULL UNIQUE, + status TEXT NOT NULL CHECK (status IN ('draft', 'synced', 'changed')), + ai_note TEXT, + ai_run_id TEXT REFERENCES ai_runs (id), + calendar_synced_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE plan_days ( + id TEXT NOT NULL PRIMARY KEY, + plan_id TEXT NOT NULL REFERENCES week_plans (id), + date TEXT NOT NULL, + dinner_id TEXT REFERENCES dinners (id), + is_new INTEGER NOT NULL CHECK (is_new IN (0, 1)), + calendar_event_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (plan_id, date) +); + +CREATE TABLE rotation_reviews ( + id TEXT NOT NULL PRIMARY KEY, + dinner_id TEXT NOT NULL REFERENCES dinners (id), + plan_date TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'decided')), + decision TEXT CHECK (decision IN ('keep', 'drop', 'not_made')), + decided_at TEXT, + updated_by TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (dinner_id, plan_date) +); + +CREATE TABLE shopping_lists ( + id TEXT NOT NULL PRIMARY KEY, + week_start TEXT NOT NULL UNIQUE, + plan_id TEXT NOT NULL REFERENCES week_plans (id), + title TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('current', 'archived')), + sent_to_chat_at TEXT, + frozen_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE shopping_groups ( + id TEXT NOT NULL PRIMARY KEY, + list_id TEXT NOT NULL REFERENCES shopping_lists (id), + dinner_id TEXT NOT NULL REFERENCES dinners (id), + position INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE shopping_items ( + id TEXT NOT NULL PRIMARY KEY, + group_id TEXT NOT NULL REFERENCES shopping_groups (id), + name TEXT NOT NULL, + qty TEXT, + needed INTEGER NOT NULL DEFAULT 1 CHECK (needed IN (0, 1)), + position INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE ai_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + provider TEXT NOT NULL CHECK (provider IN ('openai_compatible', 'anthropic', 'ollama')), + base_url TEXT NOT NULL, + model TEXT NOT NULL, + temperature REAL NOT NULL, + system_prompt TEXT, + api_key_ct BLOB, + api_key_last4 TEXT, + key_verified_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE recipe_search_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + provider TEXT NOT NULL CHECK (provider IN ('searxng', 'brave')), + base_url TEXT, + api_key_ct BLOB, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE integrations_google ( + id INTEGER PRIMARY KEY CHECK (id = 1), + refresh_token_ct BLOB NOT NULL, + calendar_id TEXT, + calendar_name TEXT, + connected_at TEXT NOT NULL, + last_sync_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE chat_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + webhook_url_ct BLOB NOT NULL, + space_label TEXT, + last_test_at TEXT, + last_test_status TEXT CHECK (last_test_status IN ('ok', 'failed')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE notification_prefs ( + event TEXT NOT NULL PRIMARY KEY CHECK (event IN ('plan_ready', 'list_ready', 'day_of_reminder', 'new_suggestions', 'rotation_review')), + push INTEGER NOT NULL CHECK (push IN (0, 1)), + chat INTEGER NOT NULL CHECK (chat IN (0, 1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE push_subscriptions ( + id TEXT NOT NULL PRIMARY KEY, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + user_agent TEXT, + failure_count INTEGER NOT NULL, + last_success_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE notification_outbox ( + id TEXT NOT NULL PRIMARY KEY, + event_key TEXT NOT NULL, + channel TEXT NOT NULL CHECK (channel IN ('push', 'chat')), + payload TEXT NOT NULL, -- JSON + status TEXT NOT NULL CHECK (status IN ('pending', 'sent', 'failed')), + attempts INTEGER NOT NULL, + next_attempt_at TEXT, + last_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (event_key, channel) +); + +CREATE TABLE jobs ( + id TEXT NOT NULL PRIMARY KEY, + kind TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'done', 'failed')), + payload TEXT NOT NULL, -- JSON + result TEXT, -- JSON, written progressively + error TEXT, + started_at TEXT, + finished_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE schedule_runs ( + id TEXT NOT NULL PRIMARY KEY, + schedule TEXT NOT NULL CHECK (schedule IN ('suggestions_weekly', 'plan_draft', 'shopping_chat_autosend', 'day_of_reminder', 'rotation_check', 'week_rollover', 'ai_runs_purge')), + period_key TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE (schedule, period_key) +); + +CREATE TABLE sessions ( + id_hash TEXT NOT NULL PRIMARY KEY, + subject TEXT NOT NULL, + email TEXT NOT NULL, + name TEXT NOT NULL, + groups TEXT NOT NULL, -- JSON array + csrf_token TEXT NOT NULL, + created_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); From 17a69f4de77e3d89c92e310cd8db53116aec3292 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 09:15:40 -0400 Subject: [PATCH 4/5] Feature 02 task 4: sqlc configuration and core queries (#13) Co-authored-by: dev_agent Co-committed-by: dev_agent --- .gitea/workflows/ci.yml | 9 +- Makefile | 30 +- internal/db/db.go | 31 ++ internal/db/db_test.go | 564 ++++++++++++++++++++++++++++++++ internal/db/dinners.sql.go | 321 ++++++++++++++++++ internal/db/models.go | 309 +++++++++++++++++ internal/db/photos.sql.go | 86 +++++ internal/db/querier.go | 65 ++++ internal/db/recipes.sql.go | 298 +++++++++++++++++ internal/db/settings.sql.go | 428 ++++++++++++++++++++++++ migrations/queries/dinners.sql | 57 ++++ migrations/queries/photos.sql | 17 + migrations/queries/recipes.sql | 65 ++++ migrations/queries/settings.sql | 128 ++++++++ sqlc.yaml | 15 + 15 files changed, 2421 insertions(+), 2 deletions(-) create mode 100644 internal/db/db.go create mode 100644 internal/db/db_test.go create mode 100644 internal/db/dinners.sql.go create mode 100644 internal/db/models.go create mode 100644 internal/db/photos.sql.go create mode 100644 internal/db/querier.go create mode 100644 internal/db/recipes.sql.go create mode 100644 internal/db/settings.sql.go create mode 100644 migrations/queries/dinners.sql create mode 100644 migrations/queries/photos.sql create mode 100644 migrations/queries/recipes.sql create mode 100644 migrations/queries/settings.sql create mode 100644 sqlc.yaml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 40baf62..d475990 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -5,7 +5,9 @@ # The Makefile is the single build definition: CI runs `make test` and # `make build` so it fails whenever either target would, and the binary # carries the Makefile's -trimpath and version-stamped -ldflags (the -# `make build` log shows the -X main.version=... value). +# `make build` log shows the -X main.version=... value). `make sqlc-check` +# fails when the committed internal/db code is stale against sqlc.yaml, +# migrations/ and migrations/queries/. name: CI on: [push, pull_request] @@ -28,5 +30,10 @@ jobs: - name: make test run: make test + - name: make sqlc-check + run: | + make sqlc-install + make sqlc-check SQLC="$(go env GOPATH)/bin/sqlc" + - name: make build run: make build diff --git a/Makefile b/Makefile index e9f157a..4356184 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,9 @@ # make test gofmt check, go vet, go test -race, npm typecheck and test. # make build Build the PWA, embed it, build the static binary bin/mealquest. # make docker Build the container image from ./Dockerfile. +# make sqlc Regenerate internal/db from sqlc.yaml with the pinned sqlc. +# make sqlc-check Regenerate; fail if internal/db differs from the commit. +# make sqlc-install Install the pinned sqlc into the Go bin directory. FRONTEND_DIST := internal/frontend/dist # Dirty means a tracked change outside the generated embed output, which @@ -14,7 +17,13 @@ VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)$(if $(G IMAGE ?= mealquest:$(VERSION) LDFLAGS := -s -w -X main.version=$(VERSION) -.PHONY: dev test go-test web-test build web embed check-embed-clean docker +# sqlc is a dev tool only, never a go.mod dependency. Generated code records +# the version that wrote it, so every run must use exactly this one. +SQLC_VERSION := v1.31.1 +SQLC ?= sqlc +SQLC_OUT := internal/db + +.PHONY: dev test go-test web-test build web embed check-embed-clean docker sqlc sqlc-check sqlc-install # Run both servers; Ctrl-C (or either exiting) stops both. The binary is built # first and run directly because `go run` would orphan its child on kill. @@ -58,3 +67,22 @@ check-embed-clean: docker: docker build --build-arg VERSION=$(VERSION) -t $(IMAGE) . + +# sqlc $(SQLC_VERSION) needs Go 1.26 to build; GOTOOLCHAIN=auto lets go fetch +# it for this install only, even where the environment pins the local Go. +sqlc-install: + GOTOOLCHAIN=auto go install github.com/sqlc-dev/sqlc/cmd/sqlc@$(SQLC_VERSION) + +# Previously generated files are removed first so a dropped query file does +# not leave its stale output behind; hand-written files are kept. +sqlc: + @v="$$($(SQLC) version)"; \ + if [ "$$v" != "$(SQLC_VERSION)" ]; then echo "sqlc $(SQLC_VERSION) required, $(SQLC) is $$v"; exit 1; fi + find $(SQLC_OUT) -maxdepth 1 -name '*.go' -exec grep -l -x '// Code generated by sqlc. DO NOT EDIT.' {} + | xargs -r rm -- + $(SQLC) generate + +# Fails on any difference from the committed generated code, including new +# or deleted files. +sqlc-check: sqlc + @drift="$$(git status --porcelain --untracked-files=all -- $(SQLC_OUT))"; \ + if [ -n "$$drift" ]; then echo "internal/db is stale; run make sqlc and commit:"; echo "$$drift"; git diff -- $(SQLC_OUT); exit 1; fi diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..f43598b --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/db/db_test.go b/internal/db/db_test.go new file mode 100644 index 0000000..bdbba38 --- /dev/null +++ b/internal/db/db_test.go @@ -0,0 +1,564 @@ +package db_test + +import ( + "context" + "database/sql" + "errors" + "reflect" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +const ( + created = "2026-10-03T12:00:00.000Z" + updated = "2026-10-04T12:00:00.000Z" +) + +// openQueries returns queries on the writer of a freshly migrated database +// in a temp directory. +func openQueries(t *testing.T) (*store.Store, *db.Queries) { + t.Helper() + ctx := context.Background() + s, err := store.Open(ctx, t.TempDir()) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { + if err := s.Close(); err != nil { + t.Errorf("Close: %v", err) + } + }) + if err := store.Migrate(ctx, s.Writer, migrations.FS); err != nil { + t.Fatalf("Migrate: %v", err) + } + return s, db.New(s.Writer) +} + +func str(s string) sql.NullString { return sql.NullString{String: s, Valid: true} } +func num(n int64) sql.NullInt64 { return sql.NullInt64{Int64: n, Valid: true} } + +func mustNoRows(t *testing.T, what string, err error) { + t.Helper() + if !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("%s: got %v, want sql.ErrNoRows", what, err) + } +} + +func createPhoto(t *testing.T, q *db.Queries, sha string) db.Photo { + t.Helper() + p, err := q.CreatePhoto(context.Background(), db.CreatePhotoParams{ + ID: store.NewID(), + SourceUrl: "https://example.com/" + sha + ".jpg", + LocalPath: "photos/" + sha + ".jpg", + Width: 800, + Height: 600, + Sha256: sha, + FetchedAt: created, + CreatedAt: created, + UpdatedAt: created, + }) + if err != nil { + t.Fatalf("CreatePhoto: %v", err) + } + return p +} + +func upsertRecipe(t *testing.T, q *db.Queries, id, url, title, at string) db.Recipe { + t.Helper() + r, err := q.UpsertRecipe(context.Background(), db.UpsertRecipeParams{ + ID: id, + SourceUrl: url, + SiteName: str("Example"), + Title: title, + TotalMinutes: num(30), + PrepMinutes: num(10), + CookMinutes: num(20), + Yield: str("4 servings"), + RawJsonld: `{"@type":"Recipe"}`, + FetchedAt: at, + CreatedAt: at, + UpdatedAt: at, + }) + if err != nil { + t.Fatalf("UpsertRecipe: %v", err) + } + return r +} + +func TestDinners(t *testing.T) { + ctx := context.Background() + _, q := openQueries(t) + recipe := upsertRecipe(t, q, store.NewID(), "https://example.com/tacos", "Tacos", created) + photo := createPhoto(t, q, "aa11") + + create := db.CreateDinnerParams{ + ID: store.NewID(), + Name: "Taco Tuesday", + NormalizedName: "taco tuesday", + Status: "regular", + Origin: "regular", + Method: str("stovetop"), + MinutesTotal: num(30), + Difficulty: str("easy"), + Cuisine: str("Mexican"), + IsFavorite: 0, + RecipeID: str(recipe.ID), + PhotoID: str(photo.ID), + GroundingStatus: "grounded", + UpdatedBy: str("alex@example.com"), + CreatedAt: created, + UpdatedAt: created, + } + want := db.Dinner(create) + got, err := q.CreateDinner(ctx, create) + if err != nil { + t.Fatalf("CreateDinner: %v", err) + } + if got != want { + t.Fatalf("CreateDinner returned %+v, want %+v", got, want) + } + if got, err = q.GetDinner(ctx, create.ID); err != nil || got != want { + t.Fatalf("GetDinner = %+v, %v; want %+v", got, err, want) + } + _, err = q.GetDinner(ctx, "missing") + mustNoRows(t, "GetDinner missing", err) + + // Second dinner sorts before the first by normalized name, in a + // different status so the status filter is exercised. + other, err := q.CreateDinner(ctx, db.CreateDinnerParams{ + ID: store.NewID(), + Name: "Apple Pork Chops", + NormalizedName: "apple pork chops", + Status: "quest_log", + Origin: "side_quest", + GroundingStatus: "pending", + CreatedAt: created, + UpdatedAt: created, + }) + if err != nil { + t.Fatalf("CreateDinner other: %v", err) + } + third, err := q.CreateDinner(ctx, db.CreateDinnerParams{ + ID: store.NewID(), + Name: "Beef Stew", + NormalizedName: "beef stew", + Status: "regular", + Origin: "manual", + GroundingStatus: "manual", + CreatedAt: created, + UpdatedAt: created, + }) + if err != nil { + t.Fatalf("CreateDinner third: %v", err) + } + listIDs := func(status string) []string { + t.Helper() + ds, err := q.ListDinnersByStatus(ctx, status) + if err != nil { + t.Fatalf("ListDinnersByStatus(%s): %v", status, err) + } + ids := []string{} + for _, d := range ds { + ids = append(ids, d.ID) + } + return ids + } + assertIDs(t, "regular", listIDs("regular"), third.ID, create.ID) + assertIDs(t, "quest_log", listIDs("quest_log"), other.ID) + assertIDs(t, "tried", listIDs("tried")) + + update := db.UpdateDinnerParams{ + ID: create.ID, + Name: "Fish Tacos", + NormalizedName: "fish tacos", + Status: "tried", + Origin: "side_quest", + Method: str("grill"), + MinutesTotal: num(25), + Difficulty: str("medium"), + Cuisine: sql.NullString{}, + RecipeID: sql.NullString{}, + PhotoID: str(photo.ID), + GroundingStatus: "failed", + UpdatedBy: str("sam@example.com"), + UpdatedAt: updated, + } + got, err = q.UpdateDinner(ctx, update) + if err != nil { + t.Fatalf("UpdateDinner: %v", err) + } + want = db.Dinner{ + ID: create.ID, Name: update.Name, NormalizedName: update.NormalizedName, + Status: update.Status, Origin: update.Origin, Method: update.Method, + MinutesTotal: update.MinutesTotal, Difficulty: update.Difficulty, + Cuisine: update.Cuisine, IsFavorite: 0, RecipeID: update.RecipeID, + PhotoID: update.PhotoID, GroundingStatus: update.GroundingStatus, + UpdatedBy: update.UpdatedBy, CreatedAt: created, UpdatedAt: updated, + } + if got != want { + t.Fatalf("UpdateDinner returned %+v, want %+v", got, want) + } + if got, err = q.GetDinner(ctx, create.ID); err != nil || got != want { + t.Fatalf("GetDinner after update = %+v, %v; want %+v", got, err, want) + } + update.ID = "missing" + _, err = q.UpdateDinner(ctx, update) + mustNoRows(t, "UpdateDinner missing", err) + + got, err = q.SetDinnerFavorite(ctx, db.SetDinnerFavoriteParams{ + ID: create.ID, IsFavorite: 1, UpdatedBy: str("alex@example.com"), UpdatedAt: updated, + }) + if err != nil { + t.Fatalf("SetDinnerFavorite: %v", err) + } + want.IsFavorite, want.UpdatedBy = 1, str("alex@example.com") + if got != want { + t.Fatalf("SetDinnerFavorite returned %+v, want %+v", got, want) + } + got, err = q.SetDinnerFavorite(ctx, db.SetDinnerFavoriteParams{ + ID: create.ID, IsFavorite: 0, UpdatedBy: str("sam@example.com"), UpdatedAt: updated, + }) + if err != nil || got.IsFavorite != 0 || got.UpdatedBy != str("sam@example.com") { + t.Fatalf("SetDinnerFavorite off = %+v, %v", got, err) + } + _, err = q.SetDinnerFavorite(ctx, db.SetDinnerFavoriteParams{ID: "missing", IsFavorite: 1, UpdatedAt: updated}) + mustNoRows(t, "SetDinnerFavorite missing", err) + + const archivedAt = "2026-10-05T12:00:00.000Z" + got, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{ + ID: create.ID, UpdatedBy: str("alex@example.com"), UpdatedAt: archivedAt, + }) + if err != nil { + t.Fatalf("ArchiveDinner: %v", err) + } + want.IsFavorite, want.Status, want.UpdatedBy, want.UpdatedAt = 0, "archived", str("alex@example.com"), archivedAt + if got != want { + t.Fatalf("ArchiveDinner returned %+v, want %+v", got, want) + } + assertIDs(t, "archived", listIDs("archived"), create.ID) + assertIDs(t, "tried after archive", listIDs("tried")) + _, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{ID: "missing", UpdatedAt: archivedAt}) + mustNoRows(t, "ArchiveDinner missing", err) +} + +func assertIDs(t *testing.T, what string, got []string, want ...string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("%s: got ids %v, want %v", what, got, want) + } + for i := range got { + if got[i] != want[i] { + t.Fatalf("%s: got ids %v, want %v", what, got, want) + } + } +} + +func TestRecipeUpsertWithIngredientsAndSteps(t *testing.T) { + ctx := context.Background() + s, q := openQueries(t) + const url = "https://example.com/chili" + + _, err := q.GetRecipeBySourceURL(ctx, url) + mustNoRows(t, "GetRecipeBySourceURL before insert", err) + + // saveRecipe is how a caller stores a fetched recipe: upsert by + // source_url, then replace the children, all in one transaction. + saveRecipe := func(title, at string, ingredients, steps []string) db.Recipe { + t.Helper() + var r db.Recipe + err := s.WithTx(ctx, func(tx *sql.Tx) error { + qtx := q.WithTx(tx) + var err error + r, err = qtx.UpsertRecipe(ctx, db.UpsertRecipeParams{ + ID: store.NewID(), SourceUrl: url, SiteName: str("Example"), Title: title, + TotalMinutes: num(60), RawJsonld: `{"name":"` + title + `"}`, + FetchedAt: at, CreatedAt: at, UpdatedAt: at, + }) + if err != nil { + return err + } + if err := qtx.DeleteRecipeIngredients(ctx, r.ID); err != nil { + return err + } + if err := qtx.DeleteRecipeSteps(ctx, r.ID); err != nil { + return err + } + for i, text := range ingredients { + if err := qtx.CreateRecipeIngredient(ctx, db.CreateRecipeIngredientParams{ + ID: store.NewID(), RecipeID: r.ID, Position: int64(i), RawText: text, + Name: str(text), Qty: str("1"), Unit: str("cup"), Note: sql.NullString{}, + CreatedAt: at, UpdatedAt: at, + }); err != nil { + return err + } + } + for i, text := range steps { + if err := qtx.CreateRecipeStep(ctx, db.CreateRecipeStepParams{ + ID: store.NewID(), RecipeID: r.ID, Position: int64(i), Text: text, + CreatedAt: at, UpdatedAt: at, + }); err != nil { + return err + } + } + return nil + }) + if err != nil { + t.Fatalf("save recipe %q: %v", title, err) + } + return r + } + assertRecipe := func(id, title, createdAt, updatedAt string, ingredients, steps []string) { + t.Helper() + r, err := q.GetRecipe(ctx, id) + if err != nil { + t.Fatalf("GetRecipe: %v", err) + } + if r.Title != title || r.SourceUrl != url || r.CreatedAt != createdAt || r.UpdatedAt != updatedAt || r.FetchedAt != updatedAt { + t.Fatalf("GetRecipe = %+v; want title %q, created %s, updated %s", r, title, createdAt, updatedAt) + } + byURL, err := q.GetRecipeBySourceURL(ctx, url) + if err != nil || byURL != r { + t.Fatalf("GetRecipeBySourceURL = %+v, %v; want %+v", byURL, err, r) + } + ings, err := q.ListRecipeIngredients(ctx, id) + if err != nil { + t.Fatalf("ListRecipeIngredients: %v", err) + } + gotIngs := []string{} + for i, ing := range ings { + if ing.RecipeID != id || ing.Position != int64(i) || ing.Name != str(ing.RawText) { + t.Fatalf("ingredient %d = %+v", i, ing) + } + gotIngs = append(gotIngs, ing.RawText) + } + assertIDs(t, "ingredients", gotIngs, ingredients...) + sts, err := q.ListRecipeSteps(ctx, id) + if err != nil { + t.Fatalf("ListRecipeSteps: %v", err) + } + gotSteps := []string{} + for i, st := range sts { + if st.RecipeID != id || st.Position != int64(i) { + t.Fatalf("step %d = %+v", i, st) + } + gotSteps = append(gotSteps, st.Text) + } + assertIDs(t, "steps", gotSteps, steps...) + } + + first := saveRecipe("Chili", created, []string{"beans", "beef", "tomatoes"}, []string{"Brown the beef.", "Simmer everything."}) + assertRecipe(first.ID, "Chili", created, created, []string{"beans", "beef", "tomatoes"}, []string{"Brown the beef.", "Simmer everything."}) + + // Re-fetching the same URL keeps the id and created_at and replaces the + // fields and children. + second := saveRecipe("White Chili", updated, []string{"chicken", "white beans"}, []string{"Simmer."}) + if second.ID != first.ID { + t.Fatalf("upsert changed id: %s -> %s", first.ID, second.ID) + } + assertRecipe(first.ID, "White Chili", created, updated, []string{"chicken", "white beans"}, []string{"Simmer."}) + + // A different recipe's children are not listed. + upsertRecipe(t, q, store.NewID(), "https://example.com/other", "Other", created) + var n int + if err := s.Reader.QueryRowContext(ctx, "SELECT count(*) FROM recipes").Scan(&n); err != nil || n != 2 { + t.Fatalf("recipes count = %d, %v; want 2", n, err) + } + _, err = q.GetRecipe(ctx, "missing") + mustNoRows(t, "GetRecipe missing", err) +} + +func TestPhotos(t *testing.T) { + ctx := context.Background() + _, q := openQueries(t) + + _, err := q.GetPhotoBySHA256(ctx, "bb22") + mustNoRows(t, "GetPhotoBySHA256 before insert", err) + + first := createPhoto(t, q, "bb22") + createPhoto(t, q, "cc33") + if first.Sha256 != "bb22" || first.Width != 800 || first.Height != 600 || first.CreatedAt != created { + t.Fatalf("CreatePhoto returned %+v", first) + } + got, err := q.GetPhotoBySHA256(ctx, "bb22") + if err != nil || got != first { + t.Fatalf("GetPhotoBySHA256 = %+v, %v; want %+v", got, err, first) + } + + // sha256 is not unique in the schema; the earliest photo is returned. + createPhoto(t, q, "bb22") + if got, err = q.GetPhotoBySHA256(ctx, "bb22"); err != nil || got != first { + t.Fatalf("GetPhotoBySHA256 with duplicate = %+v, %v; want %+v", got, err, first) + } +} + +func TestHouseholdSettings(t *testing.T) { + ctx := context.Background() + _, q := openQueries(t) + _, err := q.GetHouseholdSettings(ctx) + mustNoRows(t, "GetHouseholdSettings before upsert", err) + + p := db.UpsertHouseholdSettingsParams{ + Timezone: "America/New_York", DinnerTime: "17:30", ReminderTime: "16:30", + ReviewTime: "09:00", PlanDraftDow: 5, PlanDraftTime: "09:00", + SuggestionsDow: 0, SuggestionsTime: "08:00", ChatAutosendDow: 6, + ChatAutosendTime: "08:00", CreatedAt: created, UpdatedAt: created, + } + want := db.HouseholdSetting{ + ID: 1, Timezone: p.Timezone, DinnerTime: p.DinnerTime, ReminderTime: p.ReminderTime, + ReviewTime: p.ReviewTime, PlanDraftDow: p.PlanDraftDow, PlanDraftTime: p.PlanDraftTime, + SuggestionsDow: p.SuggestionsDow, SuggestionsTime: p.SuggestionsTime, + ChatAutosendDow: p.ChatAutosendDow, ChatAutosendTime: p.ChatAutosendTime, + CreatedAt: created, UpdatedAt: created, + } + assertSettings(t, "insert", q.UpsertHouseholdSettings, q.GetHouseholdSettings, p, want) + + p = db.UpsertHouseholdSettingsParams{ + Timezone: "America/Chicago", DinnerTime: "18:00", ReminderTime: "17:00", + ReviewTime: "10:00", PlanDraftDow: 4, PlanDraftTime: "20:00", + SuggestionsDow: 1, SuggestionsTime: "07:00", ChatAutosendDow: 0, + ChatAutosendTime: "09:30", OnboardingCompletedAt: str(updated), + UpdatesSeenAt: str(updated), CreatedAt: updated, UpdatedAt: updated, + } + want = db.HouseholdSetting{ + ID: 1, Timezone: p.Timezone, DinnerTime: p.DinnerTime, ReminderTime: p.ReminderTime, + ReviewTime: p.ReviewTime, PlanDraftDow: p.PlanDraftDow, PlanDraftTime: p.PlanDraftTime, + SuggestionsDow: p.SuggestionsDow, SuggestionsTime: p.SuggestionsTime, + ChatAutosendDow: p.ChatAutosendDow, ChatAutosendTime: p.ChatAutosendTime, + OnboardingCompletedAt: p.OnboardingCompletedAt, UpdatesSeenAt: p.UpdatesSeenAt, + CreatedAt: created, UpdatedAt: updated, + } + assertSettings(t, "update", q.UpsertHouseholdSettings, q.GetHouseholdSettings, p, want) +} + +func TestAISettings(t *testing.T) { + ctx := context.Background() + _, q := openQueries(t) + _, err := q.GetAISettings(ctx) + mustNoRows(t, "GetAISettings before upsert", err) + + p := db.UpsertAISettingsParams{ + Provider: "ollama", BaseUrl: "http://ollama:11434", Model: "llama3", + Temperature: 0.7, CreatedAt: created, UpdatedAt: created, + } + want := db.AiSetting{ + ID: 1, Provider: p.Provider, BaseUrl: p.BaseUrl, Model: p.Model, + Temperature: p.Temperature, CreatedAt: created, UpdatedAt: created, + } + assertSettings(t, "insert", q.UpsertAISettings, q.GetAISettings, p, want) + + p = db.UpsertAISettingsParams{ + Provider: "anthropic", BaseUrl: "https://api.anthropic.com", Model: "claude", + Temperature: 0.2, SystemPrompt: str("Be brief."), ApiKeyCt: []byte{1, 2, 3}, + ApiKeyLast4: str("wxyz"), KeyVerifiedAt: str(updated), CreatedAt: updated, UpdatedAt: updated, + } + want = db.AiSetting{ + ID: 1, Provider: p.Provider, BaseUrl: p.BaseUrl, Model: p.Model, + Temperature: p.Temperature, SystemPrompt: p.SystemPrompt, ApiKeyCt: p.ApiKeyCt, + ApiKeyLast4: p.ApiKeyLast4, KeyVerifiedAt: p.KeyVerifiedAt, + CreatedAt: created, UpdatedAt: updated, + } + assertSettings(t, "update", q.UpsertAISettings, q.GetAISettings, p, want) +} + +func TestRecipeSearchSettings(t *testing.T) { + ctx := context.Background() + _, q := openQueries(t) + _, err := q.GetRecipeSearchSettings(ctx) + mustNoRows(t, "GetRecipeSearchSettings before upsert", err) + + p := db.UpsertRecipeSearchSettingsParams{ + Provider: "searxng", BaseUrl: str("http://searxng:8080"), CreatedAt: created, UpdatedAt: created, + } + want := db.RecipeSearchSetting{ + ID: 1, Provider: p.Provider, BaseUrl: p.BaseUrl, CreatedAt: created, UpdatedAt: created, + } + assertSettings(t, "insert", q.UpsertRecipeSearchSettings, q.GetRecipeSearchSettings, p, want) + + p = db.UpsertRecipeSearchSettingsParams{ + Provider: "brave", ApiKeyCt: []byte{9, 8, 7}, CreatedAt: updated, UpdatedAt: updated, + } + want = db.RecipeSearchSetting{ + ID: 1, Provider: p.Provider, ApiKeyCt: p.ApiKeyCt, CreatedAt: created, UpdatedAt: updated, + } + assertSettings(t, "update", q.UpsertRecipeSearchSettings, q.GetRecipeSearchSettings, p, want) +} + +func TestIntegrationsGoogle(t *testing.T) { + ctx := context.Background() + _, q := openQueries(t) + _, err := q.GetIntegrationsGoogle(ctx) + mustNoRows(t, "GetIntegrationsGoogle before upsert", err) + + p := db.UpsertIntegrationsGoogleParams{ + RefreshTokenCt: []byte{1}, ConnectedAt: created, CreatedAt: created, UpdatedAt: created, + } + want := db.IntegrationsGoogle{ + ID: 1, RefreshTokenCt: p.RefreshTokenCt, ConnectedAt: created, CreatedAt: created, UpdatedAt: created, + } + assertSettings(t, "insert", q.UpsertIntegrationsGoogle, q.GetIntegrationsGoogle, p, want) + + p = db.UpsertIntegrationsGoogleParams{ + RefreshTokenCt: []byte{2, 3}, CalendarID: str("family@group.calendar.google.com"), + CalendarName: str("Family"), ConnectedAt: updated, LastSyncError: str("quota exceeded"), + CreatedAt: updated, UpdatedAt: updated, + } + want = db.IntegrationsGoogle{ + ID: 1, RefreshTokenCt: p.RefreshTokenCt, CalendarID: p.CalendarID, + CalendarName: p.CalendarName, ConnectedAt: updated, LastSyncError: p.LastSyncError, + CreatedAt: created, UpdatedAt: updated, + } + assertSettings(t, "update", q.UpsertIntegrationsGoogle, q.GetIntegrationsGoogle, p, want) +} + +func TestChatSettings(t *testing.T) { + ctx := context.Background() + _, q := openQueries(t) + _, err := q.GetChatSettings(ctx) + mustNoRows(t, "GetChatSettings before upsert", err) + + p := db.UpsertChatSettingsParams{ + WebhookUrlCt: []byte{4, 5}, CreatedAt: created, UpdatedAt: created, + } + want := db.ChatSetting{ + ID: 1, WebhookUrlCt: p.WebhookUrlCt, CreatedAt: created, UpdatedAt: created, + } + assertSettings(t, "insert", q.UpsertChatSettings, q.GetChatSettings, p, want) + + p = db.UpsertChatSettingsParams{ + WebhookUrlCt: []byte{6}, SpaceLabel: str("Kitchen"), LastTestAt: str(updated), + LastTestStatus: str("ok"), CreatedAt: updated, UpdatedAt: updated, + } + want = db.ChatSetting{ + ID: 1, WebhookUrlCt: p.WebhookUrlCt, SpaceLabel: p.SpaceLabel, LastTestAt: p.LastTestAt, + LastTestStatus: p.LastTestStatus, CreatedAt: created, UpdatedAt: updated, + } + assertSettings(t, "update", q.UpsertChatSettings, q.GetChatSettings, p, want) +} + +// assertSettings upserts p and checks that both the returned row and a +// fresh Get equal want. On update, want keeps the original created_at. +func assertSettings[P, R any]( + t *testing.T, what string, + upsert func(context.Context, P) (R, error), get func(context.Context) (R, error), + p P, want R, +) { + t.Helper() + ctx := context.Background() + got, err := upsert(ctx, p) + if err != nil { + t.Fatalf("%s upsert: %v", what, err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("%s upsert returned %+v, want %+v", what, got, want) + } + got, err = get(ctx) + if err != nil { + t.Fatalf("%s get: %v", what, err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("%s get = %+v, want %+v", what, got, want) + } +} diff --git a/internal/db/dinners.sql.go b/internal/db/dinners.sql.go new file mode 100644 index 0000000..3b7b213 --- /dev/null +++ b/internal/db/dinners.sql.go @@ -0,0 +1,321 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: dinners.sql + +package db + +import ( + "context" + "database/sql" +) + +const archiveDinner = `-- name: ArchiveDinner :one +UPDATE dinners +SET status = 'archived', + updated_by = ?, + updated_at = ? +WHERE id = ? +RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at +` + +type ArchiveDinnerParams struct { + UpdatedBy sql.NullString + UpdatedAt string + ID string +} + +// An unknown id returns sql.ErrNoRows. +func (q *Queries) ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error) { + row := q.db.QueryRowContext(ctx, archiveDinner, arg.UpdatedBy, arg.UpdatedAt, arg.ID) + var i Dinner + err := row.Scan( + &i.ID, + &i.Name, + &i.NormalizedName, + &i.Status, + &i.Origin, + &i.Method, + &i.MinutesTotal, + &i.Difficulty, + &i.Cuisine, + &i.IsFavorite, + &i.RecipeID, + &i.PhotoID, + &i.GroundingStatus, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createDinner = `-- name: CreateDinner :one +INSERT INTO dinners ( + id, name, normalized_name, status, origin, method, minutes_total, + difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, + updated_by, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, + ?, ?, ? +) +RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at +` + +type CreateDinnerParams struct { + ID string + Name string + NormalizedName string + Status string + Origin string + Method sql.NullString + MinutesTotal sql.NullInt64 + Difficulty sql.NullString + Cuisine sql.NullString + IsFavorite int64 + RecipeID sql.NullString + PhotoID sql.NullString + GroundingStatus string + UpdatedBy sql.NullString + CreatedAt string + UpdatedAt string +} + +func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error) { + row := q.db.QueryRowContext(ctx, createDinner, + arg.ID, + arg.Name, + arg.NormalizedName, + arg.Status, + arg.Origin, + arg.Method, + arg.MinutesTotal, + arg.Difficulty, + arg.Cuisine, + arg.IsFavorite, + arg.RecipeID, + arg.PhotoID, + arg.GroundingStatus, + arg.UpdatedBy, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i Dinner + err := row.Scan( + &i.ID, + &i.Name, + &i.NormalizedName, + &i.Status, + &i.Origin, + &i.Method, + &i.MinutesTotal, + &i.Difficulty, + &i.Cuisine, + &i.IsFavorite, + &i.RecipeID, + &i.PhotoID, + &i.GroundingStatus, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getDinner = `-- name: GetDinner :one +SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at FROM dinners +WHERE id = ? +` + +func (q *Queries) GetDinner(ctx context.Context, id string) (Dinner, error) { + row := q.db.QueryRowContext(ctx, getDinner, id) + var i Dinner + err := row.Scan( + &i.ID, + &i.Name, + &i.NormalizedName, + &i.Status, + &i.Origin, + &i.Method, + &i.MinutesTotal, + &i.Difficulty, + &i.Cuisine, + &i.IsFavorite, + &i.RecipeID, + &i.PhotoID, + &i.GroundingStatus, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listDinnersByStatus = `-- name: ListDinnersByStatus :many +SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at FROM dinners +WHERE status = ? +ORDER BY normalized_name, id +` + +func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error) { + rows, err := q.db.QueryContext(ctx, listDinnersByStatus, status) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Dinner + for rows.Next() { + var i Dinner + if err := rows.Scan( + &i.ID, + &i.Name, + &i.NormalizedName, + &i.Status, + &i.Origin, + &i.Method, + &i.MinutesTotal, + &i.Difficulty, + &i.Cuisine, + &i.IsFavorite, + &i.RecipeID, + &i.PhotoID, + &i.GroundingStatus, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const setDinnerFavorite = `-- name: SetDinnerFavorite :one +UPDATE dinners +SET is_favorite = ?, + updated_by = ?, + updated_at = ? +WHERE id = ? +RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at +` + +type SetDinnerFavoriteParams struct { + IsFavorite int64 + UpdatedBy sql.NullString + UpdatedAt string + ID string +} + +// An unknown id returns sql.ErrNoRows. +func (q *Queries) SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error) { + row := q.db.QueryRowContext(ctx, setDinnerFavorite, + arg.IsFavorite, + arg.UpdatedBy, + arg.UpdatedAt, + arg.ID, + ) + var i Dinner + err := row.Scan( + &i.ID, + &i.Name, + &i.NormalizedName, + &i.Status, + &i.Origin, + &i.Method, + &i.MinutesTotal, + &i.Difficulty, + &i.Cuisine, + &i.IsFavorite, + &i.RecipeID, + &i.PhotoID, + &i.GroundingStatus, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateDinner = `-- name: UpdateDinner :one +UPDATE dinners +SET name = ?, + normalized_name = ?, + status = ?, + origin = ?, + method = ?, + minutes_total = ?, + difficulty = ?, + cuisine = ?, + recipe_id = ?, + photo_id = ?, + grounding_status = ?, + updated_by = ?, + updated_at = ? +WHERE id = ? +RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at +` + +type UpdateDinnerParams struct { + Name string + NormalizedName string + Status string + Origin string + Method sql.NullString + MinutesTotal sql.NullInt64 + Difficulty sql.NullString + Cuisine sql.NullString + RecipeID sql.NullString + PhotoID sql.NullString + GroundingStatus string + UpdatedBy sql.NullString + UpdatedAt string + ID string +} + +// An unknown id returns sql.ErrNoRows. +func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error) { + row := q.db.QueryRowContext(ctx, updateDinner, + arg.Name, + arg.NormalizedName, + arg.Status, + arg.Origin, + arg.Method, + arg.MinutesTotal, + arg.Difficulty, + arg.Cuisine, + arg.RecipeID, + arg.PhotoID, + arg.GroundingStatus, + arg.UpdatedBy, + arg.UpdatedAt, + arg.ID, + ) + var i Dinner + err := row.Scan( + &i.ID, + &i.Name, + &i.NormalizedName, + &i.Status, + &i.Origin, + &i.Method, + &i.MinutesTotal, + &i.Difficulty, + &i.Cuisine, + &i.IsFavorite, + &i.RecipeID, + &i.PhotoID, + &i.GroundingStatus, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/db/models.go b/internal/db/models.go new file mode 100644 index 0000000..78206b0 --- /dev/null +++ b/internal/db/models.go @@ -0,0 +1,309 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +import ( + "database/sql" +) + +type AiRun struct { + ID string + Skill string + SkillVersion string + Provider string + Model string + Temperature float64 + Status string + LatencyMs sql.NullInt64 + TokensIn sql.NullInt64 + TokensOut sql.NullInt64 + Error sql.NullString + RequestRedacted string + ResponseRedacted sql.NullString + CreatedAt string + UpdatedAt string +} + +type AiSetting struct { + ID int64 + Provider string + BaseUrl string + Model string + Temperature float64 + SystemPrompt sql.NullString + ApiKeyCt []byte + ApiKeyLast4 sql.NullString + KeyVerifiedAt sql.NullString + CreatedAt string + UpdatedAt string +} + +type ChatSetting struct { + ID int64 + WebhookUrlCt []byte + SpaceLabel sql.NullString + LastTestAt sql.NullString + LastTestStatus sql.NullString + CreatedAt string + UpdatedAt string +} + +type Dinner struct { + ID string + Name string + NormalizedName string + Status string + Origin string + Method sql.NullString + MinutesTotal sql.NullInt64 + Difficulty sql.NullString + Cuisine sql.NullString + IsFavorite int64 + RecipeID sql.NullString + PhotoID sql.NullString + GroundingStatus string + UpdatedBy sql.NullString + CreatedAt string + UpdatedAt string +} + +type HouseholdSetting struct { + ID int64 + Timezone string + DinnerTime string + ReminderTime string + ReviewTime string + PlanDraftDow int64 + PlanDraftTime string + SuggestionsDow int64 + SuggestionsTime string + ChatAutosendDow int64 + ChatAutosendTime string + OnboardingCompletedAt sql.NullString + UpdatesSeenAt sql.NullString + CreatedAt string + UpdatedAt string +} + +type IntegrationsGoogle struct { + ID int64 + RefreshTokenCt []byte + CalendarID sql.NullString + CalendarName sql.NullString + ConnectedAt string + LastSyncError sql.NullString + CreatedAt string + UpdatedAt string +} + +type Job struct { + ID string + Kind string + Status string + Payload string + Result sql.NullString + Error sql.NullString + StartedAt sql.NullString + FinishedAt sql.NullString + CreatedAt string + UpdatedAt string +} + +type NotificationOutbox struct { + ID string + EventKey string + Channel string + Payload string + Status string + Attempts int64 + NextAttemptAt sql.NullString + LastError sql.NullString + CreatedAt string + UpdatedAt string +} + +type NotificationPref struct { + Event string + Push int64 + Chat int64 + CreatedAt string + UpdatedAt string +} + +type Photo struct { + ID string + SourceUrl string + LocalPath string + Width int64 + Height int64 + Sha256 string + FetchedAt string + CreatedAt string + UpdatedAt string +} + +type PlanDay struct { + ID string + PlanID string + Date string + DinnerID sql.NullString + IsNew int64 + CalendarEventID sql.NullString + CreatedAt string + UpdatedAt string +} + +type PushSubscription struct { + ID string + Endpoint string + P256dh string + Auth string + UserAgent sql.NullString + FailureCount int64 + LastSuccessAt sql.NullString + CreatedAt string + UpdatedAt string +} + +type Recipe struct { + ID string + SourceUrl string + SiteName sql.NullString + Title string + TotalMinutes sql.NullInt64 + PrepMinutes sql.NullInt64 + CookMinutes sql.NullInt64 + Yield sql.NullString + RawJsonld string + FetchedAt string + CreatedAt string + UpdatedAt string +} + +type RecipeIngredient struct { + ID string + RecipeID string + Position int64 + RawText string + Name sql.NullString + Qty sql.NullString + Unit sql.NullString + Note sql.NullString + CreatedAt string + UpdatedAt string +} + +type RecipeSearchSetting struct { + ID int64 + Provider string + BaseUrl sql.NullString + ApiKeyCt []byte + CreatedAt string + UpdatedAt string +} + +type RecipeStep struct { + ID string + RecipeID string + Position int64 + Text string + CreatedAt string + UpdatedAt string +} + +type RotationReview struct { + ID string + DinnerID string + PlanDate string + Status string + Decision sql.NullString + DecidedAt sql.NullString + UpdatedBy sql.NullString + CreatedAt string + UpdatedAt string +} + +type ScheduleRun struct { + ID string + Schedule string + PeriodKey string + CreatedAt string +} + +type Session struct { + IDHash string + Subject string + Email string + Name string + Groups string + CsrfToken string + CreatedAt string + LastSeenAt string + ExpiresAt string +} + +type ShoppingGroup struct { + ID string + ListID string + DinnerID string + Position int64 + CreatedAt string + UpdatedAt string +} + +type ShoppingItem struct { + ID string + GroupID string + Name string + Qty sql.NullString + Needed int64 + Position int64 + CreatedAt string + UpdatedAt string +} + +type ShoppingList struct { + ID string + WeekStart string + PlanID string + Title string + Status string + SentToChatAt sql.NullString + FrozenAt sql.NullString + CreatedAt string + UpdatedAt string +} + +type Suggestion struct { + ID string + DinnerID string + BatchID string + Reason string + CitedRegularIds string + Status string + DecidedAt sql.NullString + CreatedAt string + UpdatedAt string +} + +type SuggestionBatch struct { + ID string + Trigger string + Status string + AiRunID sql.NullString + CreatedAt string + UpdatedAt string +} + +type WeekPlan struct { + ID string + WeekStart string + Status string + AiNote sql.NullString + AiRunID sql.NullString + CalendarSyncedAt sql.NullString + CreatedAt string + UpdatedAt string +} diff --git a/internal/db/photos.sql.go b/internal/db/photos.sql.go new file mode 100644 index 0000000..884ba4d --- /dev/null +++ b/internal/db/photos.sql.go @@ -0,0 +1,86 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: photos.sql + +package db + +import ( + "context" +) + +const createPhoto = `-- name: CreatePhoto :one +INSERT INTO photos ( + id, source_url, local_path, width, height, sha256, fetched_at, + created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, + ?, ? +) +RETURNING id, source_url, local_path, width, height, sha256, fetched_at, created_at, updated_at +` + +type CreatePhotoParams struct { + ID string + SourceUrl string + LocalPath string + Width int64 + Height int64 + Sha256 string + FetchedAt string + CreatedAt string + UpdatedAt string +} + +func (q *Queries) CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error) { + row := q.db.QueryRowContext(ctx, createPhoto, + arg.ID, + arg.SourceUrl, + arg.LocalPath, + arg.Width, + arg.Height, + arg.Sha256, + arg.FetchedAt, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i Photo + err := row.Scan( + &i.ID, + &i.SourceUrl, + &i.LocalPath, + &i.Width, + &i.Height, + &i.Sha256, + &i.FetchedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getPhotoBySHA256 = `-- name: GetPhotoBySHA256 :one +SELECT id, source_url, local_path, width, height, sha256, fetched_at, created_at, updated_at FROM photos +WHERE sha256 = ? +ORDER BY id +LIMIT 1 +` + +// Finds stored image bytes by content hash. The schema does not make sha256 +// unique, so the earliest-created (lowest id) match is returned. +func (q *Queries) GetPhotoBySHA256(ctx context.Context, sha256 string) (Photo, error) { + row := q.db.QueryRowContext(ctx, getPhotoBySHA256, sha256) + var i Photo + err := row.Scan( + &i.ID, + &i.SourceUrl, + &i.LocalPath, + &i.Width, + &i.Height, + &i.Sha256, + &i.FetchedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/db/querier.go b/internal/db/querier.go new file mode 100644 index 0000000..bc63ad5 --- /dev/null +++ b/internal/db/querier.go @@ -0,0 +1,65 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +import ( + "context" +) + +type Querier interface { + // An unknown id returns sql.ErrNoRows. + ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error) + CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error) + CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error) + CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error + CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error + DeleteRecipeIngredients(ctx context.Context, recipeID string) error + DeleteRecipeSteps(ctx context.Context, recipeID string) error + // Returns sql.ErrNoRows until the row exists. + GetAISettings(ctx context.Context) (AiSetting, error) + // Returns sql.ErrNoRows until the row exists. + GetChatSettings(ctx context.Context) (ChatSetting, error) + GetDinner(ctx context.Context, id string) (Dinner, error) + // Returns sql.ErrNoRows until the row exists. + GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error) + // Returns sql.ErrNoRows until the row exists. + GetIntegrationsGoogle(ctx context.Context) (IntegrationsGoogle, error) + // Finds stored image bytes by content hash. The schema does not make sha256 + // unique, so the earliest-created (lowest id) match is returned. + GetPhotoBySHA256(ctx context.Context, sha256 string) (Photo, error) + GetRecipe(ctx context.Context, id string) (Recipe, error) + GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error) + // Returns sql.ErrNoRows until the row exists. + GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error) + ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error) + ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error) + ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error) + // An unknown id returns sql.ErrNoRows. + SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error) + // An unknown id returns sql.ErrNoRows. + UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error) + // Creates the single row (id 1) or replaces every column except id and + // created_at. + UpsertAISettings(ctx context.Context, arg UpsertAISettingsParams) (AiSetting, error) + // Creates the single row (id 1) or replaces every column except id and + // created_at. + UpsertChatSettings(ctx context.Context, arg UpsertChatSettingsParams) (ChatSetting, error) + // Creates the single row (id 1) or replaces every column except id and + // created_at. + UpsertHouseholdSettings(ctx context.Context, arg UpsertHouseholdSettingsParams) (HouseholdSetting, error) + // Creates the single row (id 1) or replaces every column except id and + // created_at. + UpsertIntegrationsGoogle(ctx context.Context, arg UpsertIntegrationsGoogleParams) (IntegrationsGoogle, error) + // A recipe is keyed by source_url: re-fetching the same URL updates the + // existing row and keeps its id and created_at, which the returned row + // carries. Replace its ingredients and steps in the same transaction with + // DeleteRecipeIngredients/DeleteRecipeSteps then the Create queries. + UpsertRecipe(ctx context.Context, arg UpsertRecipeParams) (Recipe, error) + // Creates the single row (id 1) or replaces every column except id and + // created_at. + UpsertRecipeSearchSettings(ctx context.Context, arg UpsertRecipeSearchSettingsParams) (RecipeSearchSetting, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/db/recipes.sql.go b/internal/db/recipes.sql.go new file mode 100644 index 0000000..6255443 --- /dev/null +++ b/internal/db/recipes.sql.go @@ -0,0 +1,298 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: recipes.sql + +package db + +import ( + "context" + "database/sql" +) + +const createRecipeIngredient = `-- name: CreateRecipeIngredient :exec +INSERT INTO recipe_ingredients ( + id, recipe_id, position, raw_text, name, qty, unit, note, + created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, + ?, ? +) +` + +type CreateRecipeIngredientParams struct { + ID string + RecipeID string + Position int64 + RawText string + Name sql.NullString + Qty sql.NullString + Unit sql.NullString + Note sql.NullString + CreatedAt string + UpdatedAt string +} + +func (q *Queries) CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error { + _, err := q.db.ExecContext(ctx, createRecipeIngredient, + arg.ID, + arg.RecipeID, + arg.Position, + arg.RawText, + arg.Name, + arg.Qty, + arg.Unit, + arg.Note, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const createRecipeStep = `-- name: CreateRecipeStep :exec +INSERT INTO recipe_steps ( + id, recipe_id, position, text, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ? +) +` + +type CreateRecipeStepParams struct { + ID string + RecipeID string + Position int64 + Text string + CreatedAt string + UpdatedAt string +} + +func (q *Queries) CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error { + _, err := q.db.ExecContext(ctx, createRecipeStep, + arg.ID, + arg.RecipeID, + arg.Position, + arg.Text, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const deleteRecipeIngredients = `-- name: DeleteRecipeIngredients :exec +DELETE FROM recipe_ingredients +WHERE recipe_id = ? +` + +func (q *Queries) DeleteRecipeIngredients(ctx context.Context, recipeID string) error { + _, err := q.db.ExecContext(ctx, deleteRecipeIngredients, recipeID) + return err +} + +const deleteRecipeSteps = `-- name: DeleteRecipeSteps :exec +DELETE FROM recipe_steps +WHERE recipe_id = ? +` + +func (q *Queries) DeleteRecipeSteps(ctx context.Context, recipeID string) error { + _, err := q.db.ExecContext(ctx, deleteRecipeSteps, recipeID) + return err +} + +const getRecipe = `-- name: GetRecipe :one +SELECT id, source_url, site_name, title, total_minutes, prep_minutes, cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at FROM recipes +WHERE id = ? +` + +func (q *Queries) GetRecipe(ctx context.Context, id string) (Recipe, error) { + row := q.db.QueryRowContext(ctx, getRecipe, id) + var i Recipe + err := row.Scan( + &i.ID, + &i.SourceUrl, + &i.SiteName, + &i.Title, + &i.TotalMinutes, + &i.PrepMinutes, + &i.CookMinutes, + &i.Yield, + &i.RawJsonld, + &i.FetchedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getRecipeBySourceURL = `-- name: GetRecipeBySourceURL :one +SELECT id, source_url, site_name, title, total_minutes, prep_minutes, cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at FROM recipes +WHERE source_url = ? +` + +func (q *Queries) GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error) { + row := q.db.QueryRowContext(ctx, getRecipeBySourceURL, sourceUrl) + var i Recipe + err := row.Scan( + &i.ID, + &i.SourceUrl, + &i.SiteName, + &i.Title, + &i.TotalMinutes, + &i.PrepMinutes, + &i.CookMinutes, + &i.Yield, + &i.RawJsonld, + &i.FetchedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listRecipeIngredients = `-- name: ListRecipeIngredients :many +SELECT id, recipe_id, position, raw_text, name, qty, unit, note, created_at, updated_at FROM recipe_ingredients +WHERE recipe_id = ? +ORDER BY position, id +` + +func (q *Queries) ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error) { + rows, err := q.db.QueryContext(ctx, listRecipeIngredients, recipeID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []RecipeIngredient + for rows.Next() { + var i RecipeIngredient + if err := rows.Scan( + &i.ID, + &i.RecipeID, + &i.Position, + &i.RawText, + &i.Name, + &i.Qty, + &i.Unit, + &i.Note, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listRecipeSteps = `-- name: ListRecipeSteps :many +SELECT id, recipe_id, position, text, created_at, updated_at FROM recipe_steps +WHERE recipe_id = ? +ORDER BY position, id +` + +func (q *Queries) ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error) { + rows, err := q.db.QueryContext(ctx, listRecipeSteps, recipeID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []RecipeStep + for rows.Next() { + var i RecipeStep + if err := rows.Scan( + &i.ID, + &i.RecipeID, + &i.Position, + &i.Text, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertRecipe = `-- name: UpsertRecipe :one +INSERT INTO recipes ( + id, source_url, site_name, title, total_minutes, prep_minutes, + cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ? +) +ON CONFLICT (source_url) DO UPDATE SET + site_name = excluded.site_name, + title = excluded.title, + total_minutes = excluded.total_minutes, + prep_minutes = excluded.prep_minutes, + cook_minutes = excluded.cook_minutes, + yield = excluded.yield, + raw_jsonld = excluded.raw_jsonld, + fetched_at = excluded.fetched_at, + updated_at = excluded.updated_at +RETURNING id, source_url, site_name, title, total_minutes, prep_minutes, cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at +` + +type UpsertRecipeParams struct { + ID string + SourceUrl string + SiteName sql.NullString + Title string + TotalMinutes sql.NullInt64 + PrepMinutes sql.NullInt64 + CookMinutes sql.NullInt64 + Yield sql.NullString + RawJsonld string + FetchedAt string + CreatedAt string + UpdatedAt string +} + +// A recipe is keyed by source_url: re-fetching the same URL updates the +// existing row and keeps its id and created_at, which the returned row +// carries. Replace its ingredients and steps in the same transaction with +// DeleteRecipeIngredients/DeleteRecipeSteps then the Create queries. +func (q *Queries) UpsertRecipe(ctx context.Context, arg UpsertRecipeParams) (Recipe, error) { + row := q.db.QueryRowContext(ctx, upsertRecipe, + arg.ID, + arg.SourceUrl, + arg.SiteName, + arg.Title, + arg.TotalMinutes, + arg.PrepMinutes, + arg.CookMinutes, + arg.Yield, + arg.RawJsonld, + arg.FetchedAt, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i Recipe + err := row.Scan( + &i.ID, + &i.SourceUrl, + &i.SiteName, + &i.Title, + &i.TotalMinutes, + &i.PrepMinutes, + &i.CookMinutes, + &i.Yield, + &i.RawJsonld, + &i.FetchedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/db/settings.sql.go b/internal/db/settings.sql.go new file mode 100644 index 0000000..5bdc156 --- /dev/null +++ b/internal/db/settings.sql.go @@ -0,0 +1,428 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: settings.sql + +package db + +import ( + "context" + "database/sql" +) + +const getAISettings = `-- name: GetAISettings :one +SELECT id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at FROM ai_settings +WHERE id = 1 +` + +// Returns sql.ErrNoRows until the row exists. +func (q *Queries) GetAISettings(ctx context.Context) (AiSetting, error) { + row := q.db.QueryRowContext(ctx, getAISettings) + var i AiSetting + err := row.Scan( + &i.ID, + &i.Provider, + &i.BaseUrl, + &i.Model, + &i.Temperature, + &i.SystemPrompt, + &i.ApiKeyCt, + &i.ApiKeyLast4, + &i.KeyVerifiedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getChatSettings = `-- name: GetChatSettings :one +SELECT id, webhook_url_ct, space_label, last_test_at, last_test_status, created_at, updated_at FROM chat_settings +WHERE id = 1 +` + +// Returns sql.ErrNoRows until the row exists. +func (q *Queries) GetChatSettings(ctx context.Context) (ChatSetting, error) { + row := q.db.QueryRowContext(ctx, getChatSettings) + var i ChatSetting + err := row.Scan( + &i.ID, + &i.WebhookUrlCt, + &i.SpaceLabel, + &i.LastTestAt, + &i.LastTestStatus, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getHouseholdSettings = `-- name: GetHouseholdSettings :one +SELECT id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, chat_autosend_time, onboarding_completed_at, updates_seen_at, created_at, updated_at FROM household_settings +WHERE id = 1 +` + +// Returns sql.ErrNoRows until the row exists. +func (q *Queries) GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error) { + row := q.db.QueryRowContext(ctx, getHouseholdSettings) + var i HouseholdSetting + err := row.Scan( + &i.ID, + &i.Timezone, + &i.DinnerTime, + &i.ReminderTime, + &i.ReviewTime, + &i.PlanDraftDow, + &i.PlanDraftTime, + &i.SuggestionsDow, + &i.SuggestionsTime, + &i.ChatAutosendDow, + &i.ChatAutosendTime, + &i.OnboardingCompletedAt, + &i.UpdatesSeenAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getIntegrationsGoogle = `-- name: GetIntegrationsGoogle :one +SELECT id, refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error, created_at, updated_at FROM integrations_google +WHERE id = 1 +` + +// Returns sql.ErrNoRows until the row exists. +func (q *Queries) GetIntegrationsGoogle(ctx context.Context) (IntegrationsGoogle, error) { + row := q.db.QueryRowContext(ctx, getIntegrationsGoogle) + var i IntegrationsGoogle + err := row.Scan( + &i.ID, + &i.RefreshTokenCt, + &i.CalendarID, + &i.CalendarName, + &i.ConnectedAt, + &i.LastSyncError, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getRecipeSearchSettings = `-- name: GetRecipeSearchSettings :one +SELECT id, provider, base_url, api_key_ct, created_at, updated_at FROM recipe_search_settings +WHERE id = 1 +` + +// Returns sql.ErrNoRows until the row exists. +func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error) { + row := q.db.QueryRowContext(ctx, getRecipeSearchSettings) + var i RecipeSearchSetting + err := row.Scan( + &i.ID, + &i.Provider, + &i.BaseUrl, + &i.ApiKeyCt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const upsertAISettings = `-- name: UpsertAISettings :one +INSERT INTO ai_settings ( + id, provider, base_url, model, temperature, system_prompt, api_key_ct, + api_key_last4, key_verified_at, created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ? +) +ON CONFLICT (id) DO UPDATE SET + provider = excluded.provider, + base_url = excluded.base_url, + model = excluded.model, + temperature = excluded.temperature, + system_prompt = excluded.system_prompt, + api_key_ct = excluded.api_key_ct, + api_key_last4 = excluded.api_key_last4, + key_verified_at = excluded.key_verified_at, + updated_at = excluded.updated_at +RETURNING id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at +` + +type UpsertAISettingsParams struct { + Provider string + BaseUrl string + Model string + Temperature float64 + SystemPrompt sql.NullString + ApiKeyCt []byte + ApiKeyLast4 sql.NullString + KeyVerifiedAt sql.NullString + CreatedAt string + UpdatedAt string +} + +// Creates the single row (id 1) or replaces every column except id and +// created_at. +func (q *Queries) UpsertAISettings(ctx context.Context, arg UpsertAISettingsParams) (AiSetting, error) { + row := q.db.QueryRowContext(ctx, upsertAISettings, + arg.Provider, + arg.BaseUrl, + arg.Model, + arg.Temperature, + arg.SystemPrompt, + arg.ApiKeyCt, + arg.ApiKeyLast4, + arg.KeyVerifiedAt, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i AiSetting + err := row.Scan( + &i.ID, + &i.Provider, + &i.BaseUrl, + &i.Model, + &i.Temperature, + &i.SystemPrompt, + &i.ApiKeyCt, + &i.ApiKeyLast4, + &i.KeyVerifiedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const upsertChatSettings = `-- name: UpsertChatSettings :one +INSERT INTO chat_settings ( + id, webhook_url_ct, space_label, last_test_at, last_test_status, + created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, + ?, ? +) +ON CONFLICT (id) DO UPDATE SET + webhook_url_ct = excluded.webhook_url_ct, + space_label = excluded.space_label, + last_test_at = excluded.last_test_at, + last_test_status = excluded.last_test_status, + updated_at = excluded.updated_at +RETURNING id, webhook_url_ct, space_label, last_test_at, last_test_status, created_at, updated_at +` + +type UpsertChatSettingsParams struct { + WebhookUrlCt []byte + SpaceLabel sql.NullString + LastTestAt sql.NullString + LastTestStatus sql.NullString + CreatedAt string + UpdatedAt string +} + +// Creates the single row (id 1) or replaces every column except id and +// created_at. +func (q *Queries) UpsertChatSettings(ctx context.Context, arg UpsertChatSettingsParams) (ChatSetting, error) { + row := q.db.QueryRowContext(ctx, upsertChatSettings, + arg.WebhookUrlCt, + arg.SpaceLabel, + arg.LastTestAt, + arg.LastTestStatus, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i ChatSetting + err := row.Scan( + &i.ID, + &i.WebhookUrlCt, + &i.SpaceLabel, + &i.LastTestAt, + &i.LastTestStatus, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const upsertHouseholdSettings = `-- name: UpsertHouseholdSettings :one +INSERT INTO household_settings ( + id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, + plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, + chat_autosend_time, onboarding_completed_at, updates_seen_at, + created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, + ?, ? +) +ON CONFLICT (id) DO UPDATE SET + timezone = excluded.timezone, + dinner_time = excluded.dinner_time, + reminder_time = excluded.reminder_time, + review_time = excluded.review_time, + plan_draft_dow = excluded.plan_draft_dow, + plan_draft_time = excluded.plan_draft_time, + suggestions_dow = excluded.suggestions_dow, + suggestions_time = excluded.suggestions_time, + chat_autosend_dow = excluded.chat_autosend_dow, + chat_autosend_time = excluded.chat_autosend_time, + onboarding_completed_at = excluded.onboarding_completed_at, + updates_seen_at = excluded.updates_seen_at, + updated_at = excluded.updated_at +RETURNING id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, chat_autosend_time, onboarding_completed_at, updates_seen_at, created_at, updated_at +` + +type UpsertHouseholdSettingsParams struct { + Timezone string + DinnerTime string + ReminderTime string + ReviewTime string + PlanDraftDow int64 + PlanDraftTime string + SuggestionsDow int64 + SuggestionsTime string + ChatAutosendDow int64 + ChatAutosendTime string + OnboardingCompletedAt sql.NullString + UpdatesSeenAt sql.NullString + CreatedAt string + UpdatedAt string +} + +// Creates the single row (id 1) or replaces every column except id and +// created_at. +func (q *Queries) UpsertHouseholdSettings(ctx context.Context, arg UpsertHouseholdSettingsParams) (HouseholdSetting, error) { + row := q.db.QueryRowContext(ctx, upsertHouseholdSettings, + arg.Timezone, + arg.DinnerTime, + arg.ReminderTime, + arg.ReviewTime, + arg.PlanDraftDow, + arg.PlanDraftTime, + arg.SuggestionsDow, + arg.SuggestionsTime, + arg.ChatAutosendDow, + arg.ChatAutosendTime, + arg.OnboardingCompletedAt, + arg.UpdatesSeenAt, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i HouseholdSetting + err := row.Scan( + &i.ID, + &i.Timezone, + &i.DinnerTime, + &i.ReminderTime, + &i.ReviewTime, + &i.PlanDraftDow, + &i.PlanDraftTime, + &i.SuggestionsDow, + &i.SuggestionsTime, + &i.ChatAutosendDow, + &i.ChatAutosendTime, + &i.OnboardingCompletedAt, + &i.UpdatesSeenAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const upsertIntegrationsGoogle = `-- name: UpsertIntegrationsGoogle :one +INSERT INTO integrations_google ( + id, refresh_token_ct, calendar_id, calendar_name, connected_at, + last_sync_error, created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, + ?, ?, ? +) +ON CONFLICT (id) DO UPDATE SET + refresh_token_ct = excluded.refresh_token_ct, + calendar_id = excluded.calendar_id, + calendar_name = excluded.calendar_name, + connected_at = excluded.connected_at, + last_sync_error = excluded.last_sync_error, + updated_at = excluded.updated_at +RETURNING id, refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error, created_at, updated_at +` + +type UpsertIntegrationsGoogleParams struct { + RefreshTokenCt []byte + CalendarID sql.NullString + CalendarName sql.NullString + ConnectedAt string + LastSyncError sql.NullString + CreatedAt string + UpdatedAt string +} + +// Creates the single row (id 1) or replaces every column except id and +// created_at. +func (q *Queries) UpsertIntegrationsGoogle(ctx context.Context, arg UpsertIntegrationsGoogleParams) (IntegrationsGoogle, error) { + row := q.db.QueryRowContext(ctx, upsertIntegrationsGoogle, + arg.RefreshTokenCt, + arg.CalendarID, + arg.CalendarName, + arg.ConnectedAt, + arg.LastSyncError, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i IntegrationsGoogle + err := row.Scan( + &i.ID, + &i.RefreshTokenCt, + &i.CalendarID, + &i.CalendarName, + &i.ConnectedAt, + &i.LastSyncError, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const upsertRecipeSearchSettings = `-- name: UpsertRecipeSearchSettings :one +INSERT INTO recipe_search_settings ( + id, provider, base_url, api_key_ct, created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, ? +) +ON CONFLICT (id) DO UPDATE SET + provider = excluded.provider, + base_url = excluded.base_url, + api_key_ct = excluded.api_key_ct, + updated_at = excluded.updated_at +RETURNING id, provider, base_url, api_key_ct, created_at, updated_at +` + +type UpsertRecipeSearchSettingsParams struct { + Provider string + BaseUrl sql.NullString + ApiKeyCt []byte + CreatedAt string + UpdatedAt string +} + +// Creates the single row (id 1) or replaces every column except id and +// created_at. +func (q *Queries) UpsertRecipeSearchSettings(ctx context.Context, arg UpsertRecipeSearchSettingsParams) (RecipeSearchSetting, error) { + row := q.db.QueryRowContext(ctx, upsertRecipeSearchSettings, + arg.Provider, + arg.BaseUrl, + arg.ApiKeyCt, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i RecipeSearchSetting + err := row.Scan( + &i.ID, + &i.Provider, + &i.BaseUrl, + &i.ApiKeyCt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/migrations/queries/dinners.sql b/migrations/queries/dinners.sql new file mode 100644 index 0000000..6b4d39b --- /dev/null +++ b/migrations/queries/dinners.sql @@ -0,0 +1,57 @@ +-- name: CreateDinner :one +INSERT INTO dinners ( + id, name, normalized_name, status, origin, method, minutes_total, + difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, + updated_by, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, + ?, ?, ? +) +RETURNING *; + +-- name: GetDinner :one +SELECT * FROM dinners +WHERE id = ?; + +-- name: ListDinnersByStatus :many +SELECT * FROM dinners +WHERE status = ? +ORDER BY normalized_name, id; + +-- name: UpdateDinner :one +-- An unknown id returns sql.ErrNoRows. +UPDATE dinners +SET name = ?, + normalized_name = ?, + status = ?, + origin = ?, + method = ?, + minutes_total = ?, + difficulty = ?, + cuisine = ?, + recipe_id = ?, + photo_id = ?, + grounding_status = ?, + updated_by = ?, + updated_at = ? +WHERE id = ? +RETURNING *; + +-- name: ArchiveDinner :one +-- An unknown id returns sql.ErrNoRows. +UPDATE dinners +SET status = 'archived', + updated_by = ?, + updated_at = ? +WHERE id = ? +RETURNING *; + +-- name: SetDinnerFavorite :one +-- An unknown id returns sql.ErrNoRows. +UPDATE dinners +SET is_favorite = ?, + updated_by = ?, + updated_at = ? +WHERE id = ? +RETURNING *; diff --git a/migrations/queries/photos.sql b/migrations/queries/photos.sql new file mode 100644 index 0000000..983d5ab --- /dev/null +++ b/migrations/queries/photos.sql @@ -0,0 +1,17 @@ +-- name: CreatePhoto :one +INSERT INTO photos ( + id, source_url, local_path, width, height, sha256, fetched_at, + created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, + ?, ? +) +RETURNING *; + +-- name: GetPhotoBySHA256 :one +-- Finds stored image bytes by content hash. The schema does not make sha256 +-- unique, so the earliest-created (lowest id) match is returned. +SELECT * FROM photos +WHERE sha256 = ? +ORDER BY id +LIMIT 1; diff --git a/migrations/queries/recipes.sql b/migrations/queries/recipes.sql new file mode 100644 index 0000000..3ed7a3c --- /dev/null +++ b/migrations/queries/recipes.sql @@ -0,0 +1,65 @@ +-- name: UpsertRecipe :one +-- A recipe is keyed by source_url: re-fetching the same URL updates the +-- existing row and keeps its id and created_at, which the returned row +-- carries. Replace its ingredients and steps in the same transaction with +-- DeleteRecipeIngredients/DeleteRecipeSteps then the Create queries. +INSERT INTO recipes ( + id, source_url, site_name, title, total_minutes, prep_minutes, + cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ? +) +ON CONFLICT (source_url) DO UPDATE SET + site_name = excluded.site_name, + title = excluded.title, + total_minutes = excluded.total_minutes, + prep_minutes = excluded.prep_minutes, + cook_minutes = excluded.cook_minutes, + yield = excluded.yield, + raw_jsonld = excluded.raw_jsonld, + fetched_at = excluded.fetched_at, + updated_at = excluded.updated_at +RETURNING *; + +-- name: GetRecipe :one +SELECT * FROM recipes +WHERE id = ?; + +-- name: GetRecipeBySourceURL :one +SELECT * FROM recipes +WHERE source_url = ?; + +-- name: CreateRecipeIngredient :exec +INSERT INTO recipe_ingredients ( + id, recipe_id, position, raw_text, name, qty, unit, note, + created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, + ?, ? +); + +-- name: ListRecipeIngredients :many +SELECT * FROM recipe_ingredients +WHERE recipe_id = ? +ORDER BY position, id; + +-- name: DeleteRecipeIngredients :exec +DELETE FROM recipe_ingredients +WHERE recipe_id = ?; + +-- name: CreateRecipeStep :exec +INSERT INTO recipe_steps ( + id, recipe_id, position, text, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ? +); + +-- name: ListRecipeSteps :many +SELECT * FROM recipe_steps +WHERE recipe_id = ? +ORDER BY position, id; + +-- name: DeleteRecipeSteps :exec +DELETE FROM recipe_steps +WHERE recipe_id = ?; diff --git a/migrations/queries/settings.sql b/migrations/queries/settings.sql new file mode 100644 index 0000000..2b39e69 --- /dev/null +++ b/migrations/queries/settings.sql @@ -0,0 +1,128 @@ +-- name: GetHouseholdSettings :one +-- Returns sql.ErrNoRows until the row exists. +SELECT * FROM household_settings +WHERE id = 1; + +-- name: UpsertHouseholdSettings :one +-- Creates the single row (id 1) or replaces every column except id and +-- created_at. +INSERT INTO household_settings ( + id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, + plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, + chat_autosend_time, onboarding_completed_at, updates_seen_at, + created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, + ?, ? +) +ON CONFLICT (id) DO UPDATE SET + timezone = excluded.timezone, + dinner_time = excluded.dinner_time, + reminder_time = excluded.reminder_time, + review_time = excluded.review_time, + plan_draft_dow = excluded.plan_draft_dow, + plan_draft_time = excluded.plan_draft_time, + suggestions_dow = excluded.suggestions_dow, + suggestions_time = excluded.suggestions_time, + chat_autosend_dow = excluded.chat_autosend_dow, + chat_autosend_time = excluded.chat_autosend_time, + onboarding_completed_at = excluded.onboarding_completed_at, + updates_seen_at = excluded.updates_seen_at, + updated_at = excluded.updated_at +RETURNING *; + +-- name: GetAISettings :one +-- Returns sql.ErrNoRows until the row exists. +SELECT * FROM ai_settings +WHERE id = 1; + +-- name: UpsertAISettings :one +-- Creates the single row (id 1) or replaces every column except id and +-- created_at. +INSERT INTO ai_settings ( + id, provider, base_url, model, temperature, system_prompt, api_key_ct, + api_key_last4, key_verified_at, created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ? +) +ON CONFLICT (id) DO UPDATE SET + provider = excluded.provider, + base_url = excluded.base_url, + model = excluded.model, + temperature = excluded.temperature, + system_prompt = excluded.system_prompt, + api_key_ct = excluded.api_key_ct, + api_key_last4 = excluded.api_key_last4, + key_verified_at = excluded.key_verified_at, + updated_at = excluded.updated_at +RETURNING *; + +-- name: GetRecipeSearchSettings :one +-- Returns sql.ErrNoRows until the row exists. +SELECT * FROM recipe_search_settings +WHERE id = 1; + +-- name: UpsertRecipeSearchSettings :one +-- Creates the single row (id 1) or replaces every column except id and +-- created_at. +INSERT INTO recipe_search_settings ( + id, provider, base_url, api_key_ct, created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, ? +) +ON CONFLICT (id) DO UPDATE SET + provider = excluded.provider, + base_url = excluded.base_url, + api_key_ct = excluded.api_key_ct, + updated_at = excluded.updated_at +RETURNING *; + +-- name: GetIntegrationsGoogle :one +-- Returns sql.ErrNoRows until the row exists. +SELECT * FROM integrations_google +WHERE id = 1; + +-- name: UpsertIntegrationsGoogle :one +-- Creates the single row (id 1) or replaces every column except id and +-- created_at. +INSERT INTO integrations_google ( + id, refresh_token_ct, calendar_id, calendar_name, connected_at, + last_sync_error, created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, + ?, ?, ? +) +ON CONFLICT (id) DO UPDATE SET + refresh_token_ct = excluded.refresh_token_ct, + calendar_id = excluded.calendar_id, + calendar_name = excluded.calendar_name, + connected_at = excluded.connected_at, + last_sync_error = excluded.last_sync_error, + updated_at = excluded.updated_at +RETURNING *; + +-- name: GetChatSettings :one +-- Returns sql.ErrNoRows until the row exists. +SELECT * FROM chat_settings +WHERE id = 1; + +-- name: UpsertChatSettings :one +-- Creates the single row (id 1) or replaces every column except id and +-- created_at. +INSERT INTO chat_settings ( + id, webhook_url_ct, space_label, last_test_at, last_test_status, + created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, + ?, ? +) +ON CONFLICT (id) DO UPDATE SET + webhook_url_ct = excluded.webhook_url_ct, + space_label = excluded.space_label, + last_test_at = excluded.last_test_at, + last_test_status = excluded.last_test_status, + updated_at = excluded.updated_at +RETURNING *; diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..bbdc793 --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,15 @@ +# sqlc code generation. Regenerate with `make sqlc` (pinned sqlc version in the +# Makefile); `make sqlc-check` fails when internal/db is stale. sqlc reads only +# the top-level *.sql files of the schema directory, so migrations/queries/ is +# not treated as schema. +version: "2" +sql: + - engine: sqlite + schema: migrations + queries: migrations/queries + gen: + go: + package: db + out: internal/db + sql_package: database/sql + emit_interface: true From c1a868c48be0d0b4fcbd230dca33ce1245d271c3 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 09:27:22 -0400 Subject: [PATCH 5/5] Feature 02 integration fix: repository coverage and boot-path tests (#15) Co-authored-by: dev_agent Co-committed-by: dev_agent --- cmd/server/boot_test.go | 96 ++++++++++++++++ cmd/server/main.go | 23 +++- internal/store/migrate_test.go | 60 ++++++++++ internal/store/repository_test.go | 177 ++++++++++++++++++++++++++++++ 4 files changed, 352 insertions(+), 4 deletions(-) create mode 100644 cmd/server/boot_test.go create mode 100644 internal/store/repository_test.go diff --git a/cmd/server/boot_test.go b/cmd/server/boot_test.go new file mode 100644 index 0000000..df8106d --- /dev/null +++ b/cmd/server/boot_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "bytes" + "context" + "io/fs" + "log/slog" + "net/url" + "strings" + "testing" + "testing/fstest" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +// useBootMigrations swaps bootMigrations for the test and restores it after. +// Tests that call it must not run in parallel. +func useBootMigrations(t *testing.T, m fstest.MapFS) { + t.Helper() + saved := bootMigrations + bootMigrations = m + t.Cleanup(func() { bootMigrations = saved }) +} + +var brokenMigrations = fstest.MapFS{ + "0000_good.sql": {Data: []byte("CREATE TABLE good (id INTEGER PRIMARY KEY);")}, + "0001_broken.sql": {Data: []byte("CREATE TABLE broken (id INTEGER PRIMARY KEY;")}, +} + +func TestOpenStoreMigrationFailureStopsBoot(t *testing.T) { + useBootMigrations(t, brokenMigrations) + + db, err := openStore(context.Background(), t.TempDir()) + if err == nil { + db.Close() + t.Fatal("openStore succeeded with a broken migration") + } + if db != nil { + t.Error("openStore returned a store alongside the error") + } + if !strings.Contains(err.Error(), "0001_broken.sql") { + t.Errorf("error does not name the broken migration: %v", err) + } +} + +func TestOpenStoreAppliesEmbeddedMigrations(t *testing.T) { + ctx := context.Background() + db, err := openStore(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }) + var n int + if err := db.Reader.QueryRowContext(ctx, "SELECT count(*) FROM schema_migrations").Scan(&n); err != nil { + t.Fatal(err) + } + files, err := fs.Glob(migrations.FS, "*.sql") + if err != nil { + t.Fatal(err) + } + if n != len(files) { + t.Errorf("schema_migrations has %d rows, want %d", n, len(files)) + } +} + +func TestRunMigrationFailureReturnsBeforeListening(t *testing.T) { + useBootMigrations(t, brokenMigrations) + + var logs bytes.Buffer + cfg := config.Config{ + HTTPAddr: "127.0.0.1:0", + PublicURL: &url.URL{Scheme: "http", Host: "localhost"}, + DataDir: t.TempDir(), + ShutdownTimeout: time.Second, + } + errc := make(chan error, 1) + go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }() + + select { + case err := <-errc: + if err == nil || !strings.Contains(err.Error(), "0001_broken.sql") { + t.Fatalf("run error = %v, want the broken migration's error", err) + } + case <-time.After(10 * time.Second): + t.Fatal("run kept going after a migration failure") + } + if strings.Contains(logs.String(), `"listening"`) { + t.Errorf("run started listening despite the migration failure: %s", logs.String()) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index ec9f141..f98335e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "io/fs" "log/slog" "net/http" "os" @@ -40,7 +41,7 @@ func run(cfg config.Config, logger *slog.Logger) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - db, err := store.Open(ctx, cfg.DataDir) + db, err := openStore(ctx, cfg.DataDir) if err != nil { return err } @@ -49,9 +50,6 @@ func run(cfg config.Config, logger *slog.Logger) error { logger.Error("close database", "error", err) } }() - if err := store.Migrate(ctx, db.Writer, migrations.FS); err != nil { - return err - } router, err := server.NewRouter(logger, version, frontend.Assets()) if err != nil { @@ -88,6 +86,23 @@ func run(cfg config.Config, logger *slog.Logger) error { return nil } +// bootMigrations is the migration set applied at boot. Tests replace it to +// prove a failing migration stops the boot. +var bootMigrations fs.FS = migrations.FS + +// openStore opens the database in dataDir and applies bootMigrations. On a +// migration failure it closes the database and returns the error. +func openStore(ctx context.Context, dataDir string) (*store.Store, error) { + db, err := store.Open(ctx, dataDir) + if err != nil { + return nil, err + } + if err := store.Migrate(ctx, db.Writer, bootMigrations); err != nil { + return nil, errors.Join(err, db.Close()) + } + return db, nil +} + // logListening logs the startup line. Only the public URL's origin is logged // so no credential-bearing URL component can reach the log. func logListening(logger *slog.Logger, cfg config.Config) { diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index 99d381c..bff84c5 100644 --- a/internal/store/migrate_test.go +++ b/internal/store/migrate_test.go @@ -3,6 +3,7 @@ package store import ( "context" "io/fs" + "slices" "strings" "testing" "testing/fstest" @@ -201,3 +202,62 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) { t.Error("table from 0001_init was not created") } } + +// schemaSQL returns every schema object's definition, so a test can prove a +// later Migrate created or altered nothing. +func schemaSQL(t *testing.T, s *Store) []string { + t.Helper() + rows, err := s.Reader.Query("SELECT type || ' ' || name || ': ' || coalesce(sql, '') FROM sqlite_schema ORDER BY type, name") + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var out []string + for rows.Next() { + var def string + if err := rows.Scan(&def); err != nil { + t.Fatal(err) + } + out = append(out, def) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return out +} + +func TestMigrateReopenSameDataDirIsNoOp(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + + first, err := Open(ctx, dir) + if err != nil { + t.Fatal(err) + } + if err := Migrate(ctx, first.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + before, beforeSchema := migrationRows(t, first), schemaSQL(t, first) + if err := first.Close(); err != nil { + t.Fatal(err) + } + + second, err := Open(ctx, dir) + if err != nil { + t.Fatalf("reopen: %v", err) + } + t.Cleanup(func() { + if err := second.Close(); err != nil { + t.Error(err) + } + }) + if err := Migrate(ctx, second.Writer, migrations.FS); err != nil { + t.Fatalf("migrate after reopen: %v", err) + } + if after := migrationRows(t, second); !slices.Equal(after, before) { + t.Errorf("schema_migrations changed on reopen:\n got %v\nwant %v", after, before) + } + if afterSchema := schemaSQL(t, second); !slices.Equal(afterSchema, beforeSchema) { + t.Errorf("schema changed on reopen:\n got %v\nwant %v", afterSchema, beforeSchema) + } +} diff --git a/internal/store/repository_test.go b/internal/store/repository_test.go new file mode 100644 index 0000000..8ae01b2 --- /dev/null +++ b/internal/store/repository_test.go @@ -0,0 +1,177 @@ +package store + +import ( + "context" + "fmt" + "maps" + "reflect" + "slices" + "strings" + "testing" +) + +// The tables below have no public sqlc queries yet (they arrive with the +// planning, suggestion and Supply run features), so these tests drive them +// with raw SQL to prove the schema stores, returns and updates their rows. + +const repoTime = "2026-10-03T12:00:00Z" + +type repoRow struct { + table string + cols map[string]any +} + +// repoParents are the rows the tables under test reference, in insert order. +var repoParents = map[string]repoRow{ + "dinners": {"dinners", map[string]any{ + "id": "dinner-1", "name": "Chili", "normalized_name": "chili", + "status": "regular", "origin": "regular", "is_favorite": int64(0), + "grounding_status": "manual", "created_at": repoTime, "updated_at": repoTime, + }}, + "suggestion_batches": {"suggestion_batches", map[string]any{ + "id": "batch-1", "trigger": "manual", "status": "done", + "created_at": repoTime, "updated_at": repoTime, + }}, + "week_plans": {"week_plans", map[string]any{ + "id": "plan-1", "week_start": "2026-10-05", "status": "draft", + "created_at": repoTime, "updated_at": repoTime, + }}, + "shopping_lists": {"shopping_lists", map[string]any{ + "id": "list-1", "week_start": "2026-10-05", "plan_id": "plan-1", + "title": "Supply run", "status": "current", + "created_at": repoTime, "updated_at": repoTime, + }}, + "shopping_groups": {"shopping_groups", map[string]any{ + "id": "group-1", "list_id": "list-1", "dinner_id": "dinner-1", + "position": int64(0), "created_at": repoTime, "updated_at": repoTime, + }}, +} + +// readRepoRow reads cols of the row with id through the reader. +func readRepoRow(t *testing.T, s *Store, table, id string, cols []string) map[string]any { + t.Helper() + quoted := make([]string, len(cols)) + vals := make([]any, len(cols)) + ptrs := make([]any, len(cols)) + for i, c := range cols { + quoted[i] = `"` + c + `"` + ptrs[i] = &vals[i] + } + q := fmt.Sprintf(`SELECT %s FROM "%s" WHERE id = ?`, strings.Join(quoted, ", "), table) + if err := s.Reader.QueryRowContext(context.Background(), q, id).Scan(ptrs...); err != nil { + t.Fatalf("read %s %s: %v", table, id, err) + } + got := make(map[string]any, len(cols)) + for i, c := range cols { + got[c] = vals[i] + } + return got +} + +func TestRepositoryInsertReadUpdate(t *testing.T) { + cases := []struct { + parents []string + row repoRow + update map[string]any + }{ + { + row: repoRow{"week_plans", map[string]any{ + "id": "plan-2", "week_start": "2026-10-12", "status": "draft", + "ai_note": "Two Side quests this week.", "ai_run_id": nil, "calendar_synced_at": nil, + "created_at": repoTime, "updated_at": repoTime, + }}, + update: map[string]any{"status": "synced", "calendar_synced_at": "2026-10-04T09:00:00Z"}, + }, + { + parents: []string{"dinners", "week_plans"}, + row: repoRow{"plan_days", map[string]any{ + "id": "day-1", "plan_id": "plan-1", "date": "2026-10-06", "dinner_id": "dinner-1", + "is_new": int64(0), "calendar_event_id": nil, + "created_at": repoTime, "updated_at": repoTime, + }}, + update: map[string]any{"dinner_id": nil, "calendar_event_id": "evt-123"}, + }, + { + parents: []string{"dinners", "suggestion_batches"}, + row: repoRow{"suggestions", map[string]any{ + "id": "sugg-1", "dinner_id": "dinner-1", "batch_id": "batch-1", + "reason": "You make chili often.", "cited_regular_ids": `["dinner-1"]`, + "status": "pending", "decided_at": nil, + "created_at": repoTime, "updated_at": repoTime, + }}, + update: map[string]any{"status": "accepted", "decided_at": "2026-10-04T10:00:00Z"}, + }, + { + parents: []string{"week_plans"}, + row: repoRow{"shopping_lists", map[string]any{ + "id": "list-2", "week_start": "2026-10-05", "plan_id": "plan-1", + "title": "Supply run", "status": "current", "sent_to_chat_at": nil, "frozen_at": nil, + "created_at": repoTime, "updated_at": repoTime, + }}, + update: map[string]any{"status": "archived", "frozen_at": "2026-10-12T00:00:00Z"}, + }, + { + parents: []string{"dinners", "week_plans", "shopping_lists"}, + row: repoRow{"shopping_groups", map[string]any{ + "id": "group-2", "list_id": "list-1", "dinner_id": "dinner-1", "position": int64(0), + "created_at": repoTime, "updated_at": repoTime, + }}, + update: map[string]any{"position": int64(3)}, + }, + { + parents: []string{"dinners", "week_plans", "shopping_lists", "shopping_groups"}, + row: repoRow{"shopping_items", map[string]any{ + "id": "item-1", "group_id": "group-1", "name": "Onion", "qty": "2", + "needed": int64(1), "position": int64(0), + "created_at": repoTime, "updated_at": repoTime, + }}, + update: map[string]any{"needed": int64(0), "qty": "3"}, + }, + } + + for _, tc := range cases { + t.Run(tc.row.table, func(t *testing.T) { + ctx := context.Background() + s := migratedStore(t) + for _, p := range tc.parents { + parent := repoParents[p] + if err := insertSchemaRow(s.Writer, parent.table, parent.cols); err != nil { + t.Fatalf("seed %s: %v", p, err) + } + } + + table, id := tc.row.table, tc.row.cols["id"].(string) + cols := slices.Sorted(maps.Keys(tc.row.cols)) + + if err := insertSchemaRow(s.Writer, table, tc.row.cols); err != nil { + t.Fatalf("insert: %v", err) + } + if got := readRepoRow(t, s, table, id, cols); !reflect.DeepEqual(got, tc.row.cols) { + t.Fatalf("read after insert:\n got %v\nwant %v", got, tc.row.cols) + } + + want := maps.Clone(tc.row.cols) + maps.Copy(want, tc.update) + want["updated_at"] = "2026-10-04T12:00:00Z" + setCols := slices.Sorted(maps.Keys(tc.update)) + sets := make([]string, 0, len(setCols)+1) + args := make([]any, 0, len(setCols)+2) + for _, c := range append(setCols, "updated_at") { + sets = append(sets, `"`+c+`" = ?`) + args = append(args, want[c]) + } + args = append(args, id) + q := fmt.Sprintf(`UPDATE "%s" SET %s WHERE id = ?`, table, strings.Join(sets, ", ")) + res, err := s.Writer.ExecContext(ctx, q, args...) + if err != nil { + t.Fatalf("update: %v", err) + } + if n, err := res.RowsAffected(); err != nil || n != 1 { + t.Fatalf("update affected %d rows (err %v), want 1", n, err) + } + if got := readRepoRow(t, s, table, id, cols); !reflect.DeepEqual(got, want) { + t.Fatalf("read after update:\n got %v\nwant %v", got, want) + } + }) + } +}