Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 85ea9c5da4 Add sqlc configuration and core queries
CI / ci (push) Successful in 1m25s
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
CI / ci (pull_request) Successful in 1m25s
sqlc.yaml (sqlite, schema migrations/, queries migrations/queries/)
generates internal/db with emit_interface. Queries cover dinners (create,
get, list by status, update, archive, set favorite), recipes with
ingredients and steps (upsert by source_url, get, replace children),
photos (insert, get by sha256) and get/upsert for the five settings
singletons. Repository tests run every query against a migrated temp
database.

make sqlc regenerates with the pinned sqlc v1.31.1 (dev tool only, not in
go.mod); make sqlc-check regenerates and fails on any drift in
internal/db, and CI runs it after make test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:11:18 -04:00
dev_agent fa7c6b7218 Feature 02 task 3: Initial v1 schema migration (#12)
CI / ci (push) Successful in 50s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 09:06:21 -04:00
dev_agent b06d1680a3 Feature 02 task 2: Store helpers: transactions, ULIDs and time conventions (#11)
CI / ci (push) Successful in 48s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 08:54:55 -04:00
dev_agent e9ee4e768a Feature 02 task 1: SQLite connection and embedded migration runner (#10)
CI / ci (push) Successful in 49s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 00:54:02 -04:00
approver_agent 65b27a7689 Merge pull request 'Feature 01: Repository Scaffold and CI' (#8) from feature/19ad7feb-aba0-4b76-ab70-c47d71ed5e76-repository-scaffold-and-ci into dev
CI / ci (push) Successful in 27s
2026-10-03 00:40:39 -04:00
dev_agent 645b3c00b2 Feature 01 integration fix: CI build parity with make build (#9)
CI / ci (push) Successful in 26s
CI / ci (pull_request) Successful in 25s
Independent review Approved — 0 high findings (feature #8)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 00:36:48 -04:00
32 changed files with 4331 additions and 4 deletions
+8 -1
View File
@@ -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
+29 -1
View File
@@ -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
+15
View File
@@ -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=<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
+18 -2
View File
@@ -1,5 +1,21 @@
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
github.com/oklog/ulid/v2 v2.1.2
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
)
+53
View File
@@ -1,2 +1,55 @@
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/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=
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=
+31
View File
@@ -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,
}
}
+564
View File
@@ -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)
}
}
+321
View File
@@ -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
}
+309
View File
@@ -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
}
+86
View File
@@ -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
}
+65
View File
@@ -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)
+298
View File
@@ -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
}
+428
View File
@@ -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
}
+27
View File
@@ -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()
}
+25
View File
@@ -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
}
}
+105
View File
@@ -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()
}
+203
View File
@@ -0,0 +1,203 @@
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,0001_init" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init", got)
}
}
func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
ctx := context.Background()
s := openTestStore(t)
bootstrap, err := fs.ReadFile(migrations.FS, "0000_bootstrap.sql")
if err != nil {
t.Fatal(err)
}
// 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, 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, "dinners") {
t.Error("table from 0001_init was not created")
}
}
+556
View File
@@ -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)
}
})
})
}
}
+79
View File
@@ -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 <dataDir>/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
}
+116
View File
@@ -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")
}
}
+119
View File
@@ -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
}
+126
View File
@@ -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")
}
}
+37
View File
@@ -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
}
+84
View File
@@ -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)
}
}
+4
View File
@@ -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).
+333
View File
@@ -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
);
+10
View File
@@ -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
+57
View File
@@ -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 *;
+17
View File
@@ -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;
+65
View File
@@ -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 = ?;
+128
View File
@@ -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 *;
+15
View File
@@ -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