From 7b77794319c0793034cc78bab901f52dedb36982 Mon Sep 17 00:00:00 2001 From: Aaron Nelson Date: Mon, 4 May 2026 16:41:55 +0000 Subject: [PATCH] api.py: enable PRAGMA foreign_keys=ON in _connect helper; clean up 2 message orphans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The messages table declares FOREIGN KEY (conversation_id) REFERENCES conversations(id), but PRAGMA foreign_keys was never enabled — SQLite defaults it to OFF per connection, and _connect() did not set it. Two orphan rows existed in messages (conversation_id='test123' pointing at a never-existing conversation; both rows from one ~11-second test event on 2026-04-26). Audit before changing the PRAGMA: - All FOREIGN KEY declarations across both DBs (conversations.db, sessions.db) accounted for via PRAGMA foreign_key_list on each table. Only one FK exists: messages.conversation_id -> conversations.id, ON DELETE NO ACTION. - All tables enumerated via sqlite_master. Two tables in conversations.db (conversations, messages); one in sessions.db (sessions). No surprises. - PRAGMA foreign_key_check confirmed exactly the 2 known orphans and zero violations elsewhere. Both delete paths in api.py (delete_conversation at :471, and clear_all_conversations at :986) already delete from messages BEFORE conversations, so cascade behavior was correct in code. The orphan state was caused by a direct INSERT against a non-existent conversation_id at chat-test time, which an unenforced FK silently accepted. Turning the PRAGMA on prevents this class of bug at insert time, not delete time — no delete-path code changes were needed. Order of operations followed the constraint that orphan cleanup must precede PRAGMA-on (SQLite would not retroactively delete orphans, but foreign_key_check would surface them confusingly on any future operation that touched the messages table): 1. DELETE FROM messages WHERE conversation_id NOT IN (SELECT id FROM conversations) — removed the 2 known orphans. 2. Added PRAGMA foreign_keys=ON to _connect() so every connection from _connect_conversations() and _connect_sessions() gets FK enforcement (SQLite requires per-connection setting). 3. Restarted aaronai.service. Verification: - Smoke: GET /api/conversations and /api/conversations/{id}/messages both return 200 with expected payloads against the live api. - E2E single-delete: synthetic conversation + 2 messages inserted via the api's _connect helper (FK on); DELETE /api/conversations/{id} via the live endpoint removed both rows from both tables. - Clear-all e2e: skipped on live DB (destructive) — code shape is structurally identical to single-delete, no FK-relevant logic difference. - Load-bearing negative test: INSERT into messages with a non-existent conversation_id via _connect_conversations() raised sqlite3.IntegrityError("FOREIGN KEY constraint failed"). This is what proves the PRAGMA actually took effect, not just that we set it. Final counts: 7 conversations, 290 messages (down from 292 by the 2 orphans cleaned up). Note: an explicit BEGIN/COMMIT around the two-execute delete paths was considered and skipped. SQLite's implicit-transactional default already gives the atomicity needed; explicit transactions would be clarity-only and belong in a separate commit. --- scripts/api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/api.py b/scripts/api.py index 925484d..7d700db 100644 --- a/scripts/api.py +++ b/scripts/api.py @@ -42,6 +42,7 @@ CONVERSATIONS_DB = str(Path.home() / "aaronai" / "conversations.db") def _connect(path): conn = sqlite3.connect(path, timeout=5.0) conn.execute("PRAGMA synchronous=NORMAL") + conn.execute("PRAGMA foreign_keys=ON") return conn def _connect_conversations():