Merge pull request 'Feature 02: SQLite Data Layer and Schema' (#14) from feature/b9c21d67-bf72-411a-901d-77e57352d398-sqlite-data-layer-and-schema into dev
CI / ci (push) Successful in 1m23s
CI / ci (push) Successful in 1m23s
This commit was merged in pull request #14.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// useBootMigrations swaps bootMigrations for the test and restores it after.
|
||||
// Tests that call it must not run in parallel.
|
||||
func useBootMigrations(t *testing.T, m fstest.MapFS) {
|
||||
t.Helper()
|
||||
saved := bootMigrations
|
||||
bootMigrations = m
|
||||
t.Cleanup(func() { bootMigrations = saved })
|
||||
}
|
||||
|
||||
var brokenMigrations = fstest.MapFS{
|
||||
"0000_good.sql": {Data: []byte("CREATE TABLE good (id INTEGER PRIMARY KEY);")},
|
||||
"0001_broken.sql": {Data: []byte("CREATE TABLE broken (id INTEGER PRIMARY KEY;")},
|
||||
}
|
||||
|
||||
func TestOpenStoreMigrationFailureStopsBoot(t *testing.T) {
|
||||
useBootMigrations(t, brokenMigrations)
|
||||
|
||||
db, err := openStore(context.Background(), t.TempDir())
|
||||
if err == nil {
|
||||
db.Close()
|
||||
t.Fatal("openStore succeeded with a broken migration")
|
||||
}
|
||||
if db != nil {
|
||||
t.Error("openStore returned a store alongside the error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "0001_broken.sql") {
|
||||
t.Errorf("error does not name the broken migration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenStoreAppliesEmbeddedMigrations(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := openStore(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
var n int
|
||||
if err := db.Reader.QueryRowContext(ctx, "SELECT count(*) FROM schema_migrations").Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files, err := fs.Glob(migrations.FS, "*.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != len(files) {
|
||||
t.Errorf("schema_migrations has %d rows, want %d", n, len(files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMigrationFailureReturnsBeforeListening(t *testing.T) {
|
||||
useBootMigrations(t, brokenMigrations)
|
||||
|
||||
var logs bytes.Buffer
|
||||
cfg := config.Config{
|
||||
HTTPAddr: "127.0.0.1:0",
|
||||
PublicURL: &url.URL{Scheme: "http", Host: "localhost"},
|
||||
DataDir: t.TempDir(),
|
||||
ShutdownTimeout: time.Second,
|
||||
}
|
||||
errc := make(chan error, 1)
|
||||
go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }()
|
||||
|
||||
select {
|
||||
case err := <-errc:
|
||||
if err == nil || !strings.Contains(err.Error(), "0001_broken.sql") {
|
||||
t.Fatalf("run error = %v, want the broken migration's error", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("run kept going after a migration failure")
|
||||
}
|
||||
if strings.Contains(logs.String(), `"listening"`) {
|
||||
t.Errorf("run started listening despite the migration failure: %s", logs.String())
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -15,6 +16,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 +41,16 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
db, err := openStore(ctx, cfg.DataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := db.Close(); err != nil {
|
||||
logger.Error("close database", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
router, err := server.NewRouter(logger, version, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -73,6 +86,23 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// bootMigrations is the migration set applied at boot. Tests replace it to
|
||||
// prove a failing migration stops the boot.
|
||||
var bootMigrations fs.FS = migrations.FS
|
||||
|
||||
// openStore opens the database in dataDir and applies bootMigrations. On a
|
||||
// migration failure it closes the database and returns the error.
|
||||
func openStore(ctx context.Context, dataDir string) (*store.Store, error) {
|
||||
db, err := store.Open(ctx, dataDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := store.Migrate(ctx, db.Writer, bootMigrations); err != nil {
|
||||
return nil, errors.Join(err, db.Close())
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// logListening logs the startup line. Only the public URL's origin is logged
|
||||
// so no credential-bearing URL component can reach the log.
|
||||
func logListening(logger *slog.Logger, cfg config.Config) {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"slices"
|
||||
"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")
|
||||
}
|
||||
}
|
||||
|
||||
// schemaSQL returns every schema object's definition, so a test can prove a
|
||||
// later Migrate created or altered nothing.
|
||||
func schemaSQL(t *testing.T, s *Store) []string {
|
||||
t.Helper()
|
||||
rows, err := s.Reader.Query("SELECT type || ' ' || name || ': ' || coalesce(sql, '') FROM sqlite_schema ORDER BY type, name")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var def string
|
||||
if err := rows.Scan(&def); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out = append(out, def)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestMigrateReopenSameDataDirIsNoOp(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
|
||||
first, err := Open(ctx, dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Migrate(ctx, first.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, beforeSchema := migrationRows(t, first), schemaSQL(t, first)
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
second, err := Open(ctx, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := second.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := Migrate(ctx, second.Writer, migrations.FS); err != nil {
|
||||
t.Fatalf("migrate after reopen: %v", err)
|
||||
}
|
||||
if after := migrationRows(t, second); !slices.Equal(after, before) {
|
||||
t.Errorf("schema_migrations changed on reopen:\n got %v\nwant %v", after, before)
|
||||
}
|
||||
if afterSchema := schemaSQL(t, second); !slices.Equal(afterSchema, beforeSchema) {
|
||||
t.Errorf("schema changed on reopen:\n got %v\nwant %v", afterSchema, beforeSchema)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The tables below have no public sqlc queries yet (they arrive with the
|
||||
// planning, suggestion and Supply run features), so these tests drive them
|
||||
// with raw SQL to prove the schema stores, returns and updates their rows.
|
||||
|
||||
const repoTime = "2026-10-03T12:00:00Z"
|
||||
|
||||
type repoRow struct {
|
||||
table string
|
||||
cols map[string]any
|
||||
}
|
||||
|
||||
// repoParents are the rows the tables under test reference, in insert order.
|
||||
var repoParents = map[string]repoRow{
|
||||
"dinners": {"dinners", map[string]any{
|
||||
"id": "dinner-1", "name": "Chili", "normalized_name": "chili",
|
||||
"status": "regular", "origin": "regular", "is_favorite": int64(0),
|
||||
"grounding_status": "manual", "created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
"suggestion_batches": {"suggestion_batches", map[string]any{
|
||||
"id": "batch-1", "trigger": "manual", "status": "done",
|
||||
"created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
"week_plans": {"week_plans", map[string]any{
|
||||
"id": "plan-1", "week_start": "2026-10-05", "status": "draft",
|
||||
"created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
"shopping_lists": {"shopping_lists", map[string]any{
|
||||
"id": "list-1", "week_start": "2026-10-05", "plan_id": "plan-1",
|
||||
"title": "Supply run", "status": "current",
|
||||
"created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
"shopping_groups": {"shopping_groups", map[string]any{
|
||||
"id": "group-1", "list_id": "list-1", "dinner_id": "dinner-1",
|
||||
"position": int64(0), "created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
}
|
||||
|
||||
// readRepoRow reads cols of the row with id through the reader.
|
||||
func readRepoRow(t *testing.T, s *Store, table, id string, cols []string) map[string]any {
|
||||
t.Helper()
|
||||
quoted := make([]string, len(cols))
|
||||
vals := make([]any, len(cols))
|
||||
ptrs := make([]any, len(cols))
|
||||
for i, c := range cols {
|
||||
quoted[i] = `"` + c + `"`
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
q := fmt.Sprintf(`SELECT %s FROM "%s" WHERE id = ?`, strings.Join(quoted, ", "), table)
|
||||
if err := s.Reader.QueryRowContext(context.Background(), q, id).Scan(ptrs...); err != nil {
|
||||
t.Fatalf("read %s %s: %v", table, id, err)
|
||||
}
|
||||
got := make(map[string]any, len(cols))
|
||||
for i, c := range cols {
|
||||
got[c] = vals[i]
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestRepositoryInsertReadUpdate(t *testing.T) {
|
||||
cases := []struct {
|
||||
parents []string
|
||||
row repoRow
|
||||
update map[string]any
|
||||
}{
|
||||
{
|
||||
row: repoRow{"week_plans", map[string]any{
|
||||
"id": "plan-2", "week_start": "2026-10-12", "status": "draft",
|
||||
"ai_note": "Two Side quests this week.", "ai_run_id": nil, "calendar_synced_at": nil,
|
||||
"created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
update: map[string]any{"status": "synced", "calendar_synced_at": "2026-10-04T09:00:00Z"},
|
||||
},
|
||||
{
|
||||
parents: []string{"dinners", "week_plans"},
|
||||
row: repoRow{"plan_days", map[string]any{
|
||||
"id": "day-1", "plan_id": "plan-1", "date": "2026-10-06", "dinner_id": "dinner-1",
|
||||
"is_new": int64(0), "calendar_event_id": nil,
|
||||
"created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
update: map[string]any{"dinner_id": nil, "calendar_event_id": "evt-123"},
|
||||
},
|
||||
{
|
||||
parents: []string{"dinners", "suggestion_batches"},
|
||||
row: repoRow{"suggestions", map[string]any{
|
||||
"id": "sugg-1", "dinner_id": "dinner-1", "batch_id": "batch-1",
|
||||
"reason": "You make chili often.", "cited_regular_ids": `["dinner-1"]`,
|
||||
"status": "pending", "decided_at": nil,
|
||||
"created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
update: map[string]any{"status": "accepted", "decided_at": "2026-10-04T10:00:00Z"},
|
||||
},
|
||||
{
|
||||
parents: []string{"week_plans"},
|
||||
row: repoRow{"shopping_lists", map[string]any{
|
||||
"id": "list-2", "week_start": "2026-10-05", "plan_id": "plan-1",
|
||||
"title": "Supply run", "status": "current", "sent_to_chat_at": nil, "frozen_at": nil,
|
||||
"created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
update: map[string]any{"status": "archived", "frozen_at": "2026-10-12T00:00:00Z"},
|
||||
},
|
||||
{
|
||||
parents: []string{"dinners", "week_plans", "shopping_lists"},
|
||||
row: repoRow{"shopping_groups", map[string]any{
|
||||
"id": "group-2", "list_id": "list-1", "dinner_id": "dinner-1", "position": int64(0),
|
||||
"created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
update: map[string]any{"position": int64(3)},
|
||||
},
|
||||
{
|
||||
parents: []string{"dinners", "week_plans", "shopping_lists", "shopping_groups"},
|
||||
row: repoRow{"shopping_items", map[string]any{
|
||||
"id": "item-1", "group_id": "group-1", "name": "Onion", "qty": "2",
|
||||
"needed": int64(1), "position": int64(0),
|
||||
"created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
update: map[string]any{"needed": int64(0), "qty": "3"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.row.table, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := migratedStore(t)
|
||||
for _, p := range tc.parents {
|
||||
parent := repoParents[p]
|
||||
if err := insertSchemaRow(s.Writer, parent.table, parent.cols); err != nil {
|
||||
t.Fatalf("seed %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
table, id := tc.row.table, tc.row.cols["id"].(string)
|
||||
cols := slices.Sorted(maps.Keys(tc.row.cols))
|
||||
|
||||
if err := insertSchemaRow(s.Writer, table, tc.row.cols); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
if got := readRepoRow(t, s, table, id, cols); !reflect.DeepEqual(got, tc.row.cols) {
|
||||
t.Fatalf("read after insert:\n got %v\nwant %v", got, tc.row.cols)
|
||||
}
|
||||
|
||||
want := maps.Clone(tc.row.cols)
|
||||
maps.Copy(want, tc.update)
|
||||
want["updated_at"] = "2026-10-04T12:00:00Z"
|
||||
setCols := slices.Sorted(maps.Keys(tc.update))
|
||||
sets := make([]string, 0, len(setCols)+1)
|
||||
args := make([]any, 0, len(setCols)+2)
|
||||
for _, c := range append(setCols, "updated_at") {
|
||||
sets = append(sets, `"`+c+`" = ?`)
|
||||
args = append(args, want[c])
|
||||
}
|
||||
args = append(args, id)
|
||||
q := fmt.Sprintf(`UPDATE "%s" SET %s WHERE id = ?`, table, strings.Join(sets, ", "))
|
||||
res, err := s.Writer.ExecContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil || n != 1 {
|
||||
t.Fatalf("update affected %d rows (err %v), want 1", n, err)
|
||||
}
|
||||
if got := readRepoRow(t, s, table, id, cols); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("read after update:\n got %v\nwant %v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
@@ -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 *;
|
||||
@@ -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;
|
||||
@@ -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 = ?;
|
||||
@@ -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 *;
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user