fix: keep chat attachments while a linking chat exists

Fixes https://linear.app/codercom/issue/CODAGT-616/keep-chat-attachments-while-chats-remain-unarchived

Chat attachments could disappear even though the chat was still available. This happened when a message was saved without recording which attachments it used, or when cleanup deleted attachments before an archived chat itself was removed.

Creating a chat, sending or queuing a message, and editing a message now record both the message and which attachments it uses as one operation. If the chat is already at the 50-attachment limit, the chat change fails without being partially saved.

Concurrent attachment writes serialize the 50-file cap per chat. Cleanup locks candidates and checks again for new links before deleting. If a file becomes unavailable after input validation, create, send, and edit return a clear client error and roll back the chat change.

An attachment stays available while any chat that uses it still exists. After an archived chat reaches the end of its retention period and is deleted, an old attachment that no remaining chat uses can be cleaned up. The retention guide and unavailable-attachment UI text document this lifecycle. This change cannot restore attachments that were already deleted.

The database migration adds two indexes so attachment cleanup stays fast as attachments accumulate.

> This PR was authored by Mux (AI) on Mike's behalf.
This commit is contained in:
Michael Suchacz
2026-08-11 13:53:15 +02:00
committed by GitHub
parent 72ad835330
commit 57f38b5c24
33 changed files with 1130 additions and 368 deletions
+10 -14
View File
@@ -64,10 +64,6 @@ FROM chats_expanded
ORDER BY (chats_expanded.id = @id::uuid) DESC, chats_expanded.created_at ASC, chats_expanded.id ASC;
-- name: UnarchiveChatByID :many
-- Unarchives a chat (and its children). Stale file references are
-- handled automatically by FK cascades on chat_file_links: when
-- dbpurge deletes a chat_files row, the corresponding
-- chat_file_links rows are cascade-deleted by PostgreSQL.
WITH updated_chats AS (
UPDATE chats SET
archived = false,
@@ -1663,22 +1659,16 @@ SELECT * FROM chat_context_resources
WHERE chat_id = @chat_id::uuid
ORDER BY source ASC;
-- name: LinkChatFiles :one
-- LinkChatFiles inserts file associations into the chat_file_links
-- join table with deduplication (ON CONFLICT DO NOTHING). The INSERT
-- is conditional: it only proceeds when the total number of links
-- (existing + genuinely new) does not exceed max_file_links. Returns
-- the number of genuinely new file IDs that were NOT inserted due to
-- the cap. A return value of 0 means all files were linked (or were
-- already linked). A positive value means the cap blocked that many
-- new links.
-- name: LinkChatFilesAfterLock :one
-- LinkChatFilesAfterLock requires the chat row lock.
-- The lock serializes cap checks. The result counts rejected new links.
WITH current AS (
SELECT COUNT(*) AS cnt
FROM chat_file_links
WHERE chat_id = @chat_id::uuid
),
new_links AS (
SELECT @chat_id::uuid AS chat_id, unnest(@file_ids::uuid[]) AS file_id
SELECT DISTINCT @chat_id::uuid AS chat_id, unnest(@file_ids::uuid[]) AS file_id
),
genuinely_new AS (
SELECT nl.chat_id, nl.file_id
@@ -1989,6 +1979,12 @@ ORDER BY
LIMIT
1;
-- name: LockChatByID :one
SELECT id
FROM chats
WHERE id = @id::uuid
FOR UPDATE;
-- name: GetChatByIDForUpdate :one
WITH locked_chat AS (
SELECT *