Feature 02 task 3: Initial v1 schema migration #12
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
);
|
||||
Reference in New Issue
Block a user