--- phase: 01-data-integrity plan: "01" subsystem: backend/storage tags: [sqlite, bug-fix, data-integrity, upsert, foreign-keys] dependency_graph: requires: [] provides: [DATA-01, DATA-02] affects: [pkg/diunwebhook/diunwebhook.go] tech_stack: added: [] patterns: [SQLite UPSERT (ON CONFLICT DO UPDATE), PRAGMA foreign_keys = ON] key_files: created: [] modified: - pkg/diunwebhook/diunwebhook.go - pkg/diunwebhook/diunwebhook_test.go decisions: - "Use named-column INSERT with ON CONFLICT(image) DO UPDATE SET (UPSERT) instead of INSERT OR REPLACE to preserve tag_assignments child rows" - "Enable PRAGMA foreign_keys = ON immediately after SetMaxOpenConns(1) so all connections (single-connection pool) enforce FK constraints" metrics: duration: "2 minutes" completed_date: "2026-03-23" tasks_completed: 2 files_modified: 2 --- # Phase 01 Plan 01: Fix SQLite Data-Destruction Bugs (UPSERT + FK Enforcement) Summary **One-liner:** SQLite UPSERT replaces INSERT OR REPLACE to preserve tag_assignments on re-insert, and PRAGMA foreign_keys = ON enables ON DELETE CASCADE for tag deletion. ## What Was Built Fixed two silent data-destruction bugs in the SQLite persistence layer: **Bug DATA-01 (INSERT OR REPLACE destroying tags):** SQLite's `INSERT OR REPLACE` is implemented as DELETE + INSERT. The DELETE fired `ON DELETE CASCADE` on `tag_assignments.image`, silently removing the tag assignment every time a new DIUN event arrived for an already-tagged image. Fixed by replacing the statement with a proper UPSERT (`INSERT INTO ... ON CONFLICT(image) DO UPDATE SET`) that only updates the non-key columns, leaving `tag_assignments` untouched. **Bug DATA-02 (FK enforcement disabled):** SQLite disables foreign key enforcement by default. `PRAGMA foreign_keys = ON` was never executed, so `ON DELETE CASCADE` on `tag_assignments.tag_id` did not fire when a tag was deleted. Fixed by executing the pragma immediately after `db.SetMaxOpenConns(1)` in `InitDB()`, before any DDL statements. ## Tasks Completed | Task | Name | Commit | Files | |------|------|--------|-------| | 1 | Replace INSERT OR REPLACE with UPSERT + add PRAGMA FK enforcement | 7edbaad | pkg/diunwebhook/diunwebhook.go | | 2 | Add regression test TestUpdateEvent_PreservesTagOnUpsert | e2d388c | pkg/diunwebhook/diunwebhook_test.go | ## Decisions Made 1. **Named-column UPSERT over positional INSERT OR REPLACE:** The UPSERT explicitly lists 15 columns and their `excluded.*` counterparts in the DO UPDATE SET clause, making column mapping unambiguous and safe for future schema additions. 2. **acknowledged_at hardcoded NULL in UPSERT:** The UPSERT sets `acknowledged_at = NULL` unconditionally in both the INSERT and the ON CONFLICT update clause. This ensures a new event always resets the acknowledged state, matching the pre-existing behavior and test expectations. 3. **PRAGMA placement before DDL:** The FK pragma is placed before all CREATE TABLE statements to ensure the enforcement is active when foreign key relationships are first defined, not just at query time. ## Deviations from Plan None — plan executed exactly as written. ## Verification Results - `grep -c "INSERT OR REPLACE INTO updates" pkg/diunwebhook/diunwebhook.go` → `0` (confirmed) - `grep -c "PRAGMA foreign_keys = ON" pkg/diunwebhook/diunwebhook.go` → `1` (confirmed) - `grep -c "ON CONFLICT(image) DO UPDATE SET" pkg/diunwebhook/diunwebhook.go` → `1` (confirmed) - Full test suite: 29 tests pass, 0 failures, coverage 63.6% - `TestDismissHandler_ReappearsAfterNewWebhook` — PASS - `TestDeleteTagHandler_CascadesAssignment` — PASS - `TestUpdateEvent_PreservesTagOnUpsert` — PASS (new regression test) ## Known Stubs None. ## Self-Check: PASSED Files exist: - FOUND: pkg/diunwebhook/diunwebhook.go (modified) - FOUND: pkg/diunwebhook/diunwebhook_test.go (modified, contains TestUpdateEvent_PreservesTagOnUpsert) Commits exist: - FOUND: 7edbaad — fix(01-01): replace INSERT OR REPLACE with UPSERT and enable FK enforcement - FOUND: e2d388c — test(01-01): add TestUpdateEvent_PreservesTagOnUpsert regression test