From 7b760e87b1d9905460ccae10969d68fd0d7c8e4b Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 09:03:49 -0400 Subject: [PATCH] Tighten v1 schema keys, weekday checks and ai_runs references - ai_run_id in suggestion_batches and week_plans now uses the default refusing action instead of ON DELETE SET NULL; the ai_runs purge must clear references explicitly. Tested as a refused parent delete. - Every TEXT primary key (including notification_prefs.event and sessions.id_hash) is NOT NULL; tested that NULL keys are rejected. - Weekday columns use CHECK (col IN (0, 1, 2, 3, 4, 5, 6)) so fractional values are refused; tested with 5.5, 0.5 and 3.5. - Composite UNIQUE tests add positive partial-overlap cases (same date under different plans, same plan_date for different dinners, same event_key on different channels, same period_key for different schedules). Co-Authored-By: Claude Opus 5.5 --- internal/store/schema_test.go | 101 ++++++++++++++++++++++++++++------ migrations/0001_init.sql | 62 +++++++++++---------- 2 files changed, 115 insertions(+), 48 deletions(-) diff --git a/internal/store/schema_test.go b/internal/store/schema_test.go index d316b68..ed61047 100644 --- a/internal/store/schema_test.go +++ b/internal/store/schema_test.go @@ -302,6 +302,9 @@ func TestSchemaEnumChecksRejectInvalidValues(t *testing.T) { {"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"}, @@ -332,7 +335,7 @@ func TestSchemaEnumChecksRejectInvalidValues(t *testing.T) { } s := seededStore(t) for _, c := range cases { - t.Run(c.table+"."+c.column, func(t *testing.T) { + 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) @@ -405,6 +408,82 @@ func TestSchemaUniqueConstraintsRejectDuplicates(t *testing.T) { } } +// 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 { @@ -432,6 +511,9 @@ func TestSchemaDeleteParentWithChildrenRefused(t *testing.T) { {"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 { @@ -472,20 +554,3 @@ func TestSchemaDeleteParentWithChildrenRefused(t *testing.T) { }) } } - -func TestSchemaAIRunPurgeClearsReferences(t *testing.T) { - s := seededStore(t) - ctx := context.Background() - if _, err := s.Writer.ExecContext(ctx, `DELETE FROM ai_runs WHERE id = ?`, rowID("ai_runs", 0)); err != nil { - t.Fatalf("purge ai_runs: %v", err) - } - for _, table := range []string{"suggestion_batches", "week_plans"} { - var ref sql.NullString - if err := s.Writer.QueryRowContext(ctx, fmt.Sprintf(`SELECT ai_run_id FROM "%s" WHERE id = ?`, table), rowID(table, 0)).Scan(&ref); err != nil { - t.Fatal(err) - } - if ref.Valid { - t.Errorf("%s.ai_run_id = %q after purge, want NULL", table, ref.String) - } - } -} diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index d2829cd..80bfe04 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -1,16 +1,18 @@ -- 0001_init: the initial v1 schema (CTXD CONTEXT.MD data model). -- -- Conventions: --- * Row ids are ULIDs stored as TEXT. Single-row settings tables use --- id INTEGER PRIMARY KEY CHECK (id = 1) so a second row is refused. +-- * 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 0-6, Sunday = 0 (Go time.Weekday). +-- 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. The one exception is ai_run_id: ai_runs rows --- are purged after 30 days, so references to them are ON DELETE SET NULL. +-- 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). -- @@ -32,11 +34,11 @@ CREATE TABLE household_settings ( 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 BETWEEN 0 AND 6), + 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 BETWEEN 0 AND 6), + 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 BETWEEN 0 AND 6), + 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, @@ -45,7 +47,7 @@ CREATE TABLE household_settings ( ); CREATE TABLE ai_runs ( - id TEXT PRIMARY KEY, + 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')), @@ -63,7 +65,7 @@ CREATE TABLE ai_runs ( ); CREATE TABLE recipes ( - id TEXT PRIMARY KEY, + id TEXT NOT NULL PRIMARY KEY, source_url TEXT NOT NULL UNIQUE, site_name TEXT, title TEXT NOT NULL, @@ -78,7 +80,7 @@ CREATE TABLE recipes ( ); CREATE TABLE recipe_ingredients ( - id TEXT PRIMARY KEY, + id TEXT NOT NULL PRIMARY KEY, recipe_id TEXT NOT NULL REFERENCES recipes (id), position INTEGER NOT NULL, raw_text TEXT NOT NULL, @@ -91,7 +93,7 @@ CREATE TABLE recipe_ingredients ( ); CREATE TABLE recipe_steps ( - id TEXT PRIMARY KEY, + id TEXT NOT NULL PRIMARY KEY, recipe_id TEXT NOT NULL REFERENCES recipes (id), position INTEGER NOT NULL, text TEXT NOT NULL, @@ -100,7 +102,7 @@ CREATE TABLE recipe_steps ( ); CREATE TABLE photos ( - id TEXT PRIMARY KEY, + id TEXT NOT NULL PRIMARY KEY, source_url TEXT NOT NULL, local_path TEXT NOT NULL, width INTEGER NOT NULL, @@ -112,7 +114,7 @@ CREATE TABLE photos ( ); CREATE TABLE dinners ( - id TEXT PRIMARY KEY, + 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')), @@ -131,16 +133,16 @@ CREATE TABLE dinners ( ); CREATE TABLE suggestion_batches ( - id TEXT PRIMARY KEY, + 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) ON DELETE SET NULL, + ai_run_id TEXT REFERENCES ai_runs (id), created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE suggestions ( - id TEXT PRIMARY KEY, + 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, @@ -152,18 +154,18 @@ CREATE TABLE suggestions ( ); CREATE TABLE week_plans ( - id TEXT PRIMARY KEY, + 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) ON DELETE SET NULL, + 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 PRIMARY KEY, + 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), @@ -175,7 +177,7 @@ CREATE TABLE plan_days ( ); CREATE TABLE rotation_reviews ( - id TEXT PRIMARY KEY, + 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')), @@ -188,7 +190,7 @@ CREATE TABLE rotation_reviews ( ); CREATE TABLE shopping_lists ( - id TEXT PRIMARY KEY, + 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, @@ -200,7 +202,7 @@ CREATE TABLE shopping_lists ( ); CREATE TABLE shopping_groups ( - id TEXT PRIMARY KEY, + 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, @@ -209,7 +211,7 @@ CREATE TABLE shopping_groups ( ); CREATE TABLE shopping_items ( - id TEXT PRIMARY KEY, + id TEXT NOT NULL PRIMARY KEY, group_id TEXT NOT NULL REFERENCES shopping_groups (id), name TEXT NOT NULL, qty TEXT, @@ -264,7 +266,7 @@ CREATE TABLE chat_settings ( ); CREATE TABLE notification_prefs ( - event TEXT PRIMARY KEY CHECK (event IN ('plan_ready', 'list_ready', 'day_of_reminder', 'new_suggestions', 'rotation_review')), + 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, @@ -272,7 +274,7 @@ CREATE TABLE notification_prefs ( ); CREATE TABLE push_subscriptions ( - id TEXT PRIMARY KEY, + id TEXT NOT NULL PRIMARY KEY, endpoint TEXT NOT NULL UNIQUE, p256dh TEXT NOT NULL, auth TEXT NOT NULL, @@ -284,7 +286,7 @@ CREATE TABLE push_subscriptions ( ); CREATE TABLE notification_outbox ( - id TEXT PRIMARY KEY, + 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 @@ -298,7 +300,7 @@ CREATE TABLE notification_outbox ( ); CREATE TABLE jobs ( - id TEXT PRIMARY KEY, + 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 @@ -311,7 +313,7 @@ CREATE TABLE jobs ( ); CREATE TABLE schedule_runs ( - id TEXT PRIMARY KEY, + 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, @@ -319,7 +321,7 @@ CREATE TABLE schedule_runs ( ); CREATE TABLE sessions ( - id_hash TEXT PRIMARY KEY, + id_hash TEXT NOT NULL PRIMARY KEY, subject TEXT NOT NULL, email TEXT NOT NULL, name TEXT NOT NULL,