From fa7c6b721822b5b784a410c13b159d6e5e417377 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 09:06:21 -0400 Subject: [PATCH] Feature 02 task 3: Initial v1 schema migration (#12) Co-authored-by: dev_agent Co-committed-by: dev_agent --- internal/store/migrate_test.go | 21 +- internal/store/schema_test.go | 556 +++++++++++++++++++++++++++++++++ migrations/0001_init.sql | 333 ++++++++++++++++++++ 3 files changed, 898 insertions(+), 12 deletions(-) create mode 100644 internal/store/schema_test.go create mode 100644 migrations/0001_init.sql diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index 35a0824..99d381c 100644 --- a/internal/store/migrate_test.go +++ b/internal/store/migrate_test.go @@ -174,33 +174,30 @@ func TestMigrateEmbeddedMigrations(t *testing.T) { if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil { t.Fatal(err) } - if got := versions(migrationRows(t, s)); got != "0000_bootstrap" { - t.Errorf("versions = %q, want 0000_bootstrap", got) + if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init" { + t.Errorf("versions = %q, want 0000_bootstrap,0001_init", got) } } -func TestMigrateEmbeddedThenNewMigrationApplies(t *testing.T) { +func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) { ctx := context.Background() s := openTestStore(t) - if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { - t.Fatal(err) - } bootstrap, err := fs.ReadFile(migrations.FS, "0000_bootstrap.sql") if err != nil { t.Fatal(err) } - // A later build adds 0001_init.sql alongside the unchanged bootstrap. - upgraded := fstest.MapFS{ - "0000_bootstrap.sql": {Data: bootstrap}, - "0001_init.sql": {Data: []byte("CREATE TABLE later (id INTEGER PRIMARY KEY);")}, + // A database migrated by the bootstrap-only build upgrades to the + // embedded set, which adds 0001_init.sql alongside the unchanged bootstrap. + if err := Migrate(ctx, s.Writer, fstest.MapFS{"0000_bootstrap.sql": {Data: bootstrap}}); err != nil { + t.Fatal(err) } - if err := Migrate(ctx, s.Writer, upgraded); err != nil { + if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { t.Fatal(err) } if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init" { t.Errorf("versions = %q, want 0000_bootstrap,0001_init", got) } - if !tableExists(t, s, "later") { + if !tableExists(t, s, "dinners") { t.Error("table from 0001_init was not created") } } diff --git a/internal/store/schema_test.go b/internal/store/schema_test.go new file mode 100644 index 0000000..ed61047 --- /dev/null +++ b/internal/store/schema_test.go @@ -0,0 +1,556 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "slices" + "strings" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +// schemaTables is every table in the v1 data model, in insert order: parents +// before the children that reference them. +var schemaTables = []string{ + "ai_runs", + "recipes", + "recipe_ingredients", + "recipe_steps", + "photos", + "dinners", + "suggestion_batches", + "suggestions", + "week_plans", + "plan_days", + "rotation_reviews", + "shopping_lists", + "shopping_groups", + "shopping_items", + "notification_prefs", + "push_subscriptions", + "notification_outbox", + "jobs", + "schedule_runs", + "sessions", + // Single-row settings tables; row is always id 1. + "household_settings", + "ai_settings", + "recipe_search_settings", + "integrations_google", + "chat_settings", +} + +var singleRowTables = []string{ + "household_settings", + "ai_settings", + "recipe_search_settings", + "integrations_google", + "chat_settings", +} + +const testTime = "2026-10-03T12:00:00Z" + +// rowID is the id of the i-th test row of table. The schema stores ids as +// opaque TEXT, so deterministic ids keep the fixtures readable. +func rowID(table string, i int) string { return fmt.Sprintf("%s-%d", table, i) } + +// testRow returns a valid row for table. Rows with different i differ in +// every unique column; references point at row 0 of the parent table. +func testRow(table string, i int) map[string]any { + ts := map[string]any{"created_at": testTime, "updated_at": testTime} + r := map[string]any{"id": rowID(table, i)} + switch table { + case "household_settings": + r = map[string]any{"id": 1, "timezone": "America/New_York"} + case "ai_runs": + r["skill"] = "suggest" + r["skill_version"] = "1" + r["provider"] = "anthropic" + r["model"] = "claude" + r["temperature"] = 0.7 + r["status"] = "ok" + r["request_redacted"] = "{}" + case "recipes": + r["source_url"] = fmt.Sprintf("https://example.com/recipe/%d", i) + r["title"] = "Chili" + r["raw_jsonld"] = "{}" + r["fetched_at"] = testTime + case "recipe_ingredients": + r["recipe_id"] = rowID("recipes", 0) + r["position"] = i + r["raw_text"] = "1 onion" + case "recipe_steps": + r["recipe_id"] = rowID("recipes", 0) + r["position"] = i + r["text"] = "Chop the onion." + case "photos": + r["source_url"] = "https://example.com/chili.jpg" + r["local_path"] = "photos/chili.jpg" + r["width"] = 800 + r["height"] = 600 + r["sha256"] = "abc" + r["fetched_at"] = testTime + case "dinners": + r["name"] = "Chili" + r["normalized_name"] = "chili" + r["status"] = "regular" + r["origin"] = "regular" + r["method"] = "crockpot" + r["difficulty"] = "easy" + r["is_favorite"] = 0 + r["recipe_id"] = rowID("recipes", 0) + r["photo_id"] = rowID("photos", 0) + r["grounding_status"] = "grounded" + case "suggestion_batches": + r["trigger"] = "scheduled" + r["status"] = "done" + r["ai_run_id"] = rowID("ai_runs", 0) + case "suggestions": + r["dinner_id"] = rowID("dinners", 0) + r["batch_id"] = rowID("suggestion_batches", 0) + r["reason"] = "Like your chili." + r["cited_regular_ids"] = "[]" + r["status"] = "pending" + case "week_plans": + r["week_start"] = fmt.Sprintf("2026-%02d-05", i+1) + r["status"] = "draft" + r["ai_run_id"] = rowID("ai_runs", 0) + case "plan_days": + r["plan_id"] = rowID("week_plans", 0) + r["date"] = fmt.Sprintf("2026-01-%02d", i+5) + r["dinner_id"] = rowID("dinners", 0) + r["is_new"] = 0 + case "rotation_reviews": + r["dinner_id"] = rowID("dinners", 0) + r["plan_date"] = fmt.Sprintf("2026-01-%02d", i+5) + r["status"] = "decided" + r["decision"] = "keep" + case "shopping_lists": + r["week_start"] = fmt.Sprintf("2026-%02d-05", i+1) + r["plan_id"] = rowID("week_plans", 0) + r["title"] = "Supply run" + r["status"] = "current" + case "shopping_groups": + r["list_id"] = rowID("shopping_lists", 0) + r["dinner_id"] = rowID("dinners", 0) + r["position"] = i + case "shopping_items": + r["group_id"] = rowID("shopping_groups", 0) + r["name"] = "Onion" + r["needed"] = 1 + r["position"] = i + case "ai_settings": + r = map[string]any{"id": 1, "provider": "ollama", "base_url": "http://ollama:11434", "model": "llama", "temperature": 0.7} + case "recipe_search_settings": + r = map[string]any{"id": 1, "provider": "searxng", "base_url": "http://searxng:8080"} + case "integrations_google": + r = map[string]any{"id": 1, "refresh_token_ct": []byte{1}, "connected_at": testTime} + case "chat_settings": + r = map[string]any{"id": 1, "webhook_url_ct": []byte{1}, "last_test_status": "ok"} + case "notification_prefs": + events := []string{"plan_ready", "list_ready", "day_of_reminder", "new_suggestions", "rotation_review"} + r = map[string]any{"event": events[i], "push": 1, "chat": 0} + case "push_subscriptions": + r["endpoint"] = fmt.Sprintf("https://push.example.com/%d", i) + r["p256dh"] = "key" + r["auth"] = "auth" + r["failure_count"] = 0 + case "notification_outbox": + r["event_key"] = fmt.Sprintf("plan_ready:2026-W%02d", i+1) + r["channel"] = "push" + r["payload"] = "{}" + r["status"] = "pending" + r["attempts"] = 0 + case "jobs": + r["kind"] = "suggest" + r["status"] = "pending" + r["payload"] = "{}" + case "schedule_runs": + r["schedule"] = "plan_draft" + r["period_key"] = fmt.Sprintf("2026-W%02d", i+1) + r["created_at"] = testTime + return r + case "sessions": + return map[string]any{ + "id_hash": fmt.Sprintf("hash-%d", i), + "subject": "sub", + "email": "kitchen@example.com", + "name": "Our kitchen", + "groups": `["mealquest-admin"]`, + "csrf_token": "csrf", + "created_at": testTime, + "last_seen_at": testTime, + "expires_at": testTime, + } + default: + panic("testRow: unknown table " + table) + } + for k, v := range ts { + r[k] = v + } + return r +} + +type execer interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + +func insertSchemaRow(db execer, table string, row map[string]any) error { + cols := make([]string, 0, len(row)) + for c := range row { + cols = append(cols, c) + } + slices.Sort(cols) + quoted := make([]string, len(cols)) + args := make([]any, len(cols)) + for i, c := range cols { + quoted[i] = `"` + c + `"` + args[i] = row[c] + } + q := fmt.Sprintf(`INSERT INTO "%s" (%s) VALUES (%s)`, + table, strings.Join(quoted, ", "), strings.TrimSuffix(strings.Repeat("?, ", len(cols)), ", ")) + _, err := db.ExecContext(context.Background(), q, args...) + return err +} + +// migratedStore opens a temp store migrated with the embedded migrations. +func migratedStore(t *testing.T) *Store { + t.Helper() + s := openTestStore(t) + if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + return s +} + +// seededStore is a migrated store holding row 0 of every multi-row table. +// Single-row tables are left empty so tests can insert their only row. +func seededStore(t *testing.T) *Store { + t.Helper() + s := migratedStore(t) + for _, table := range schemaTables { + if slices.Contains(singleRowTables, table) { + continue + } + if err := insertSchemaRow(s.Writer, table, testRow(table, 0)); err != nil { + t.Fatalf("seed %s: %v", table, err) + } + } + return s +} + +// inRolledBackTx runs fn in a writer transaction that is always rolled back, +// so each case starts from the seeded state. +func inRolledBackTx(t *testing.T, s *Store, fn func(tx *sql.Tx)) { + t.Helper() + tx, err := s.Writer.BeginTx(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := tx.Rollback(); err != nil { + t.Fatal(err) + } + }() + fn(tx) +} + +func wantConstraintError(t *testing.T, err error, kind, what string) { + t.Helper() + if err == nil { + t.Fatalf("%s: accepted, want %s constraint failure", what, kind) + } + if !strings.Contains(err.Error(), kind+" constraint failed") { + t.Fatalf("%s: error %q, want %s constraint failure", what, err, kind) + } +} + +func TestSchemaTablesExist(t *testing.T) { + s := migratedStore(t) + if len(schemaTables) != 25 { + t.Fatalf("schemaTables lists %d tables, want 25", len(schemaTables)) + } + for _, table := range schemaTables { + if !tableExists(t, s, table) { + t.Errorf("table %s missing", table) + } + } +} + +func TestSchemaForeignKeysAreValid(t *testing.T) { + s := seededStore(t) + rows, err := s.Writer.Query("PRAGMA foreign_key_check") + if err != nil { + t.Fatal(err) + } + defer rows.Close() + if rows.Next() { + t.Error("foreign_key_check reported violations in the seeded data") + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } +} + +func TestSchemaEnumChecksRejectInvalidValues(t *testing.T) { + cases := []struct { + table, column string + bad any + }{ + {"household_settings", "plan_draft_dow", 7}, + {"household_settings", "suggestions_dow", -1}, + {"household_settings", "chat_autosend_dow", 7}, + {"household_settings", "plan_draft_dow", 5.5}, + {"household_settings", "suggestions_dow", 0.5}, + {"household_settings", "chat_autosend_dow", 3.5}, + {"ai_runs", "provider", "openai"}, + {"ai_runs", "status", "done"}, + {"dinners", "status", "deck"}, + {"dinners", "origin", "ai"}, + {"dinners", "method", "microwave"}, + {"dinners", "difficulty", "extreme"}, + {"dinners", "is_favorite", 2}, + {"dinners", "grounding_status", "unknown"}, + {"suggestion_batches", "trigger", "cron"}, + {"suggestion_batches", "status", "ok"}, + {"suggestions", "status", "maybe"}, + {"week_plans", "status", "final"}, + {"plan_days", "is_new", 2}, + {"rotation_reviews", "status", "open"}, + {"rotation_reviews", "decision", "promote"}, + {"shopping_lists", "status", "frozen"}, + {"shopping_items", "needed", 2}, + {"ai_settings", "provider", "openai"}, + {"recipe_search_settings", "provider", "google"}, + {"chat_settings", "last_test_status", "pending"}, + {"notification_prefs", "event", "weekly_digest"}, + {"notification_prefs", "push", 2}, + {"notification_prefs", "chat", 2}, + {"notification_outbox", "channel", "email"}, + {"notification_outbox", "status", "queued"}, + {"jobs", "status", "queued"}, + {"schedule_runs", "schedule", "hourly"}, + } + s := seededStore(t) + for _, c := range cases { + t.Run(fmt.Sprintf("%s.%s=%v", c.table, c.column, c.bad), func(t *testing.T) { + row := testRow(c.table, 1) + if slices.Contains(singleRowTables, c.table) { + row = testRow(c.table, 0) + } + inRolledBackTx(t, s, func(tx *sql.Tx) { + if err := insertSchemaRow(tx, c.table, row); err != nil { + t.Fatalf("valid row rejected: %v", err) + } + }) + row[c.column] = c.bad + inRolledBackTx(t, s, func(tx *sql.Tx) { + wantConstraintError(t, insertSchemaRow(tx, c.table, row), "CHECK", fmt.Sprintf("%s.%s = %v", c.table, c.column, c.bad)) + }) + }) + } +} + +func TestSchemaRotationReviewEventAllowed(t *testing.T) { + s := migratedStore(t) + if err := insertSchemaRow(s.Writer, "notification_prefs", testRow("notification_prefs", 4)); err != nil { + t.Fatalf("rotation_review event rejected: %v", err) + } +} + +func TestSchemaDinnerStatuses(t *testing.T) { + s := seededStore(t) + for _, status := range []string{"suggested", "quest_log", "tried", "regular", "archived"} { + inRolledBackTx(t, s, func(tx *sql.Tx) { + row := testRow("dinners", 1) + row["status"] = status + if err := insertSchemaRow(tx, "dinners", row); err != nil { + t.Errorf("status %s rejected: %v", status, err) + } + }) + } +} + +func TestSchemaUniqueConstraintsRejectDuplicates(t *testing.T) { + cases := []struct { + table string + columns []string + }{ + {"recipes", []string{"source_url"}}, + {"week_plans", []string{"week_start"}}, + {"plan_days", []string{"plan_id", "date"}}, + {"rotation_reviews", []string{"dinner_id", "plan_date"}}, + {"shopping_lists", []string{"week_start"}}, + {"push_subscriptions", []string{"endpoint"}}, + {"notification_outbox", []string{"event_key", "channel"}}, + {"schedule_runs", []string{"schedule", "period_key"}}, + {"notification_prefs", []string{"event"}}, + } + s := seededStore(t) + for _, c := range cases { + t.Run(c.table+"("+strings.Join(c.columns, ",")+")", func(t *testing.T) { + row := testRow(c.table, 1) + inRolledBackTx(t, s, func(tx *sql.Tx) { + if err := insertSchemaRow(tx, c.table, row); err != nil { + t.Fatalf("distinct row rejected: %v", err) + } + }) + seeded := testRow(c.table, 0) + for _, col := range c.columns { + row[col] = seeded[col] + } + inRolledBackTx(t, s, func(tx *sql.Tx) { + wantConstraintError(t, insertSchemaRow(tx, c.table, row), "UNIQUE", "duplicate "+c.table) + }) + }) + } +} + +// TestSchemaCompositeUniqueAllowsPartialOverlap shows each composite UNIQUE +// spans all its columns: a row sharing only some of them with the seeded row +// is accepted. (Sharing only the other columns is covered by the distinct row +// in TestSchemaUniqueConstraintsRejectDuplicates.) +func TestSchemaCompositeUniqueAllowsPartialOverlap(t *testing.T) { + cases := []struct { + name string + table string + shared string // column copied from the seeded row + parent string // row 1 of this table is inserted first, if set + set map[string]any // columns that differ from the seeded row + }{ + {"same date, different plan", "plan_days", "date", "week_plans", map[string]any{"plan_id": rowID("week_plans", 1)}}, + {"same plan_date, different dinner", "rotation_reviews", "plan_date", "dinners", map[string]any{"dinner_id": rowID("dinners", 1)}}, + {"same event_key, different channel", "notification_outbox", "event_key", "", map[string]any{"channel": "chat"}}, + {"same period_key, different schedule", "schedule_runs", "period_key", "", map[string]any{"schedule": "week_rollover"}}, + } + s := seededStore(t) + for _, c := range cases { + t.Run(c.table+": "+c.name, func(t *testing.T) { + row := testRow(c.table, 1) + row[c.shared] = testRow(c.table, 0)[c.shared] + for k, v := range c.set { + row[k] = v + } + inRolledBackTx(t, s, func(tx *sql.Tx) { + if c.parent != "" { + if err := insertSchemaRow(tx, c.parent, testRow(c.parent, 1)); err != nil { + t.Fatalf("insert parent: %v", err) + } + } + if err := insertSchemaRow(tx, c.table, row); err != nil { + t.Fatalf("row rejected: %v", err) + } + }) + }) + } +} + +// TestSchemaTextPrimaryKeysRejectNull guards against SQLite's legacy +// acceptance of NULL in non-INTEGER primary keys. +func TestSchemaTextPrimaryKeysRejectNull(t *testing.T) { + s := seededStore(t) + ctx := context.Background() + for _, table := range schemaTables { + if slices.Contains(singleRowTables, table) { + continue + } + key := "id" + switch table { + case "notification_prefs": + key = "event" + case "sessions": + key = "id_hash" + } + t.Run(table+"."+key, func(t *testing.T) { + inRolledBackTx(t, s, func(tx *sql.Tx) { + // Two NULL-key rows: neither may be accepted, so NULL can + // never stand in for a key, once or repeatedly. + for i := 1; i <= 2; i++ { + row := testRow(table, i) + row[key] = nil + wantConstraintError(t, insertSchemaRow(tx, table, row), "NOT NULL", fmt.Sprintf("%s.%s = NULL (row %d)", table, key, i)) + } + var n int + if err := tx.QueryRowContext(ctx, fmt.Sprintf(`SELECT count(*) FROM "%s" WHERE "%s" IS NULL`, table, key)).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 0 { + t.Errorf("%d rows with NULL %s", n, key) + } + }) + }) + } +} + +func TestSchemaSingleRowTablesRejectSecondRow(t *testing.T) { + s := migratedStore(t) + for _, table := range singleRowTables { + if err := insertSchemaRow(s.Writer, table, testRow(table, 0)); err != nil { + t.Fatalf("%s: %v", table, err) + } + row := testRow(table, 0) + row["id"] = 2 + wantConstraintError(t, insertSchemaRow(s.Writer, table, row), "CHECK", table+" second row") + } +} + +func TestSchemaDeleteParentWithChildrenRefused(t *testing.T) { + cases := []struct{ parent, child, column string }{ + {"recipes", "recipe_ingredients", "recipe_id"}, + {"recipes", "recipe_steps", "recipe_id"}, + {"recipes", "dinners", "recipe_id"}, + {"photos", "dinners", "photo_id"}, + {"dinners", "suggestions", "dinner_id"}, + {"suggestion_batches", "suggestions", "batch_id"}, + {"week_plans", "plan_days", "plan_id"}, + {"dinners", "plan_days", "dinner_id"}, + {"dinners", "rotation_reviews", "dinner_id"}, + {"week_plans", "shopping_lists", "plan_id"}, + {"shopping_lists", "shopping_groups", "list_id"}, + {"dinners", "shopping_groups", "dinner_id"}, + {"shopping_groups", "shopping_items", "group_id"}, + // ai_runs too: the purge must clear references before deleting. + {"ai_runs", "suggestion_batches", "ai_run_id"}, + {"ai_runs", "week_plans", "ai_run_id"}, + } + s := seededStore(t) + for _, c := range cases { + t.Run(c.parent+"<-"+c.child+"."+c.column, func(t *testing.T) { + // A fresh parent with exactly one child, through this column only. + parentID := rowID(c.parent, 1) + child := testRow(c.child, 1) + child[c.column] = parentID + del := fmt.Sprintf(`DELETE FROM "%s" WHERE id = ?`, c.parent) + ctx := context.Background() + + inRolledBackTx(t, s, func(tx *sql.Tx) { + if err := insertSchemaRow(tx, c.parent, testRow(c.parent, 1)); err != nil { + t.Fatalf("insert parent: %v", err) + } + if err := insertSchemaRow(tx, c.child, child); err != nil { + t.Fatalf("insert child: %v", err) + } + _, err := tx.ExecContext(ctx, del, parentID) + wantConstraintError(t, err, "FOREIGN KEY", "delete "+c.parent) + var n int + if err := tx.QueryRowContext(ctx, fmt.Sprintf(`SELECT count(*) FROM "%s" WHERE id = ?`, c.parent), parentID).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Errorf("parent row gone after refused delete") + } + }) + // Control: without the child the same delete succeeds. + inRolledBackTx(t, s, func(tx *sql.Tx) { + if err := insertSchemaRow(tx, c.parent, testRow(c.parent, 1)); err != nil { + t.Fatalf("insert parent: %v", err) + } + if _, err := tx.ExecContext(ctx, del, parentID); err != nil { + t.Fatalf("delete childless parent: %v", err) + } + }) + }) + } +} diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql new file mode 100644 index 0000000..80bfe04 --- /dev/null +++ b/migrations/0001_init.sql @@ -0,0 +1,333 @@ +-- 0001_init: the initial v1 schema (CTXD CONTEXT.MD data model). +-- +-- Conventions: +-- * Row ids are ULIDs stored as TEXT NOT NULL PRIMARY KEY (SQLite would +-- otherwise accept NULL in a TEXT primary key). Single-row settings tables +-- use id INTEGER PRIMARY KEY CHECK (id = 1) so a second row is refused. +-- * Timestamps are UTC RFC 3339 TEXT. Household-local dates (week_start, +-- plan day) are YYYY-MM-DD TEXT; household-local times are HH:MM TEXT. +-- * Columns ending in _ct only ever hold secretbox ciphertext. +-- * Every enum column has a CHECK. Booleans are INTEGER CHECK (x IN (0, 1)). +-- Days of the week are INTEGER IN (0, 1, 2, 3, 4, 5, 6), Sunday = 0 (Go +-- time.Weekday); IN rather than BETWEEN so fractional values are refused. +-- * Foreign keys use the default NO ACTION, so deleting a parent that still +-- has children is refused. This includes ai_run_id: the 30-day ai_runs +-- purge must clear those references explicitly before deleting. +-- * Defaults exist only where CTXD names one (household_settings times and +-- shopping_items.needed). +-- +-- Enum value sets not spelled out in CONTEXT.MD, chosen here: +-- ai_settings.provider, ai_runs.provider: openai_compatible, anthropic, ollama +-- ai_runs.status: running, ok, error +-- chat_settings.last_test_status: ok, failed +-- jobs.status, suggestion_batches.status: pending, running, done, failed +-- notification_outbox.status: pending, sent, failed +-- schedule_runs.schedule: suggestions_weekly, plan_draft, +-- shopping_chat_autosend, day_of_reminder, rotation_check, week_rollover, +-- ai_runs_purge +-- jobs.kind and ai_runs.skill are open identifiers that grow with features, +-- not enums, so they carry no CHECK. + +CREATE TABLE household_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + timezone TEXT NOT NULL, + dinner_time TEXT NOT NULL DEFAULT '17:30', + reminder_time TEXT NOT NULL DEFAULT '16:30', + review_time TEXT NOT NULL DEFAULT '09:00', + plan_draft_dow INTEGER NOT NULL DEFAULT 5 CHECK (plan_draft_dow IN (0, 1, 2, 3, 4, 5, 6)), + plan_draft_time TEXT NOT NULL DEFAULT '09:00', + suggestions_dow INTEGER NOT NULL DEFAULT 0 CHECK (suggestions_dow IN (0, 1, 2, 3, 4, 5, 6)), + suggestions_time TEXT NOT NULL DEFAULT '08:00', + chat_autosend_dow INTEGER NOT NULL DEFAULT 6 CHECK (chat_autosend_dow IN (0, 1, 2, 3, 4, 5, 6)), + chat_autosend_time TEXT NOT NULL DEFAULT '08:00', + onboarding_completed_at TEXT, + updates_seen_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE ai_runs ( + id TEXT NOT NULL PRIMARY KEY, + skill TEXT NOT NULL, + skill_version TEXT NOT NULL, + provider TEXT NOT NULL CHECK (provider IN ('openai_compatible', 'anthropic', 'ollama')), + model TEXT NOT NULL, + temperature REAL NOT NULL, + status TEXT NOT NULL CHECK (status IN ('running', 'ok', 'error')), + latency_ms INTEGER, + tokens_in INTEGER, + tokens_out INTEGER, + error TEXT, + request_redacted TEXT NOT NULL, + response_redacted TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE recipes ( + id TEXT NOT NULL PRIMARY KEY, + source_url TEXT NOT NULL UNIQUE, + site_name TEXT, + title TEXT NOT NULL, + total_minutes INTEGER, + prep_minutes INTEGER, + cook_minutes INTEGER, + yield TEXT, + raw_jsonld TEXT NOT NULL, + fetched_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE recipe_ingredients ( + id TEXT NOT NULL PRIMARY KEY, + recipe_id TEXT NOT NULL REFERENCES recipes (id), + position INTEGER NOT NULL, + raw_text TEXT NOT NULL, + name TEXT, + qty TEXT, + unit TEXT, + note TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE recipe_steps ( + id TEXT NOT NULL PRIMARY KEY, + recipe_id TEXT NOT NULL REFERENCES recipes (id), + position INTEGER NOT NULL, + text TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE photos ( + id TEXT NOT NULL PRIMARY KEY, + source_url TEXT NOT NULL, + local_path TEXT NOT NULL, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + sha256 TEXT NOT NULL, + fetched_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE dinners ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + normalized_name TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('suggested', 'quest_log', 'tried', 'regular', 'archived')), + origin TEXT NOT NULL CHECK (origin IN ('regular', 'side_quest', 'ingredients', 'quick', 'manual')), + method TEXT CHECK (method IN ('stovetop', 'oven', 'crockpot', 'grill', 'other')), + minutes_total INTEGER, + difficulty TEXT CHECK (difficulty IN ('easy', 'medium', 'hard')), + cuisine TEXT, + is_favorite INTEGER NOT NULL CHECK (is_favorite IN (0, 1)), + recipe_id TEXT REFERENCES recipes (id), + photo_id TEXT REFERENCES photos (id), + grounding_status TEXT NOT NULL CHECK (grounding_status IN ('pending', 'grounded', 'failed', 'manual')), + updated_by TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE suggestion_batches ( + id TEXT NOT NULL PRIMARY KEY, + trigger TEXT NOT NULL CHECK (trigger IN ('scheduled', 'manual', 'replenish')), + status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'done', 'failed')), + ai_run_id TEXT REFERENCES ai_runs (id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE suggestions ( + id TEXT NOT NULL PRIMARY KEY, + dinner_id TEXT NOT NULL REFERENCES dinners (id), + batch_id TEXT NOT NULL REFERENCES suggestion_batches (id), + reason TEXT NOT NULL, + cited_regular_ids TEXT NOT NULL, -- JSON array of dinner ids + status TEXT NOT NULL CHECK (status IN ('pending', 'accepted', 'declined')), + decided_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE week_plans ( + id TEXT NOT NULL PRIMARY KEY, + week_start TEXT NOT NULL UNIQUE, + status TEXT NOT NULL CHECK (status IN ('draft', 'synced', 'changed')), + ai_note TEXT, + ai_run_id TEXT REFERENCES ai_runs (id), + calendar_synced_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE plan_days ( + id TEXT NOT NULL PRIMARY KEY, + plan_id TEXT NOT NULL REFERENCES week_plans (id), + date TEXT NOT NULL, + dinner_id TEXT REFERENCES dinners (id), + is_new INTEGER NOT NULL CHECK (is_new IN (0, 1)), + calendar_event_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (plan_id, date) +); + +CREATE TABLE rotation_reviews ( + id TEXT NOT NULL PRIMARY KEY, + dinner_id TEXT NOT NULL REFERENCES dinners (id), + plan_date TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'decided')), + decision TEXT CHECK (decision IN ('keep', 'drop', 'not_made')), + decided_at TEXT, + updated_by TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (dinner_id, plan_date) +); + +CREATE TABLE shopping_lists ( + id TEXT NOT NULL PRIMARY KEY, + week_start TEXT NOT NULL UNIQUE, + plan_id TEXT NOT NULL REFERENCES week_plans (id), + title TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('current', 'archived')), + sent_to_chat_at TEXT, + frozen_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE shopping_groups ( + id TEXT NOT NULL PRIMARY KEY, + list_id TEXT NOT NULL REFERENCES shopping_lists (id), + dinner_id TEXT NOT NULL REFERENCES dinners (id), + position INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE shopping_items ( + id TEXT NOT NULL PRIMARY KEY, + group_id TEXT NOT NULL REFERENCES shopping_groups (id), + name TEXT NOT NULL, + qty TEXT, + needed INTEGER NOT NULL DEFAULT 1 CHECK (needed IN (0, 1)), + position INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE ai_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + provider TEXT NOT NULL CHECK (provider IN ('openai_compatible', 'anthropic', 'ollama')), + base_url TEXT NOT NULL, + model TEXT NOT NULL, + temperature REAL NOT NULL, + system_prompt TEXT, + api_key_ct BLOB, + api_key_last4 TEXT, + key_verified_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE recipe_search_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + provider TEXT NOT NULL CHECK (provider IN ('searxng', 'brave')), + base_url TEXT, + api_key_ct BLOB, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE integrations_google ( + id INTEGER PRIMARY KEY CHECK (id = 1), + refresh_token_ct BLOB NOT NULL, + calendar_id TEXT, + calendar_name TEXT, + connected_at TEXT NOT NULL, + last_sync_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE chat_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + webhook_url_ct BLOB NOT NULL, + space_label TEXT, + last_test_at TEXT, + last_test_status TEXT CHECK (last_test_status IN ('ok', 'failed')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE notification_prefs ( + event TEXT NOT NULL PRIMARY KEY CHECK (event IN ('plan_ready', 'list_ready', 'day_of_reminder', 'new_suggestions', 'rotation_review')), + push INTEGER NOT NULL CHECK (push IN (0, 1)), + chat INTEGER NOT NULL CHECK (chat IN (0, 1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE push_subscriptions ( + id TEXT NOT NULL PRIMARY KEY, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + user_agent TEXT, + failure_count INTEGER NOT NULL, + last_success_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE notification_outbox ( + id TEXT NOT NULL PRIMARY KEY, + event_key TEXT NOT NULL, + channel TEXT NOT NULL CHECK (channel IN ('push', 'chat')), + payload TEXT NOT NULL, -- JSON + status TEXT NOT NULL CHECK (status IN ('pending', 'sent', 'failed')), + attempts INTEGER NOT NULL, + next_attempt_at TEXT, + last_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (event_key, channel) +); + +CREATE TABLE jobs ( + id TEXT NOT NULL PRIMARY KEY, + kind TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'done', 'failed')), + payload TEXT NOT NULL, -- JSON + result TEXT, -- JSON, written progressively + error TEXT, + started_at TEXT, + finished_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE schedule_runs ( + id TEXT NOT NULL PRIMARY KEY, + schedule TEXT NOT NULL CHECK (schedule IN ('suggestions_weekly', 'plan_draft', 'shopping_chat_autosend', 'day_of_reminder', 'rotation_check', 'week_rollover', 'ai_runs_purge')), + period_key TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE (schedule, period_key) +); + +CREATE TABLE sessions ( + id_hash TEXT NOT NULL PRIMARY KEY, + subject TEXT NOT NULL, + email TEXT NOT NULL, + name TEXT NOT NULL, + groups TEXT NOT NULL, -- JSON array + csrf_token TEXT NOT NULL, + created_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + expires_at TEXT NOT NULL +);