Tighten v1 schema keys, weekday checks and ai_runs references
CI / ci (push) Successful in 50s
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
CI / ci (pull_request) Successful in 50s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 09:03:49 -04:00
co-authored by Claude Opus 5.5
parent 50b670e0a8
commit 7b760e87b1
2 changed files with 115 additions and 48 deletions
+83 -18
View File
@@ -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)
}
}
}
+32 -30
View File
@@ -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,