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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user