Four migrations, all additive against a database that already has 001-025 applied. 026 narrows the client-upload idempotency index with `AND deleted_at IS NULL`. The old index made a soft-deleted row keep its key forever, so a guest who deleted a photo and re-sent the same one had the retry silently swallowed. The new indexed set is a strict subset of the old, so it cannot fail on existing rows. 027 adds `client_join_id`, which lets a join retry after a lost response resume the same account instead of 409ing on a name the caller itself owns. Every existing row gets NULL and the partial index excludes NULLs, so it indexes nothing at creation. 028 brings the feed view's like/comment counts in line with what the feed actually renders: a banned guest's rows were still counted, so a card showed "3 comments" above two. `comment.rs` gets the matching `NOT u.is_banned` on the live read path — the export and hashtag queries already filtered it, so the two views of one moderation action disagreed. 029 records host moderation actions, which were previously invisible after the fact. Verified by applying 001-029 to a real Postgres against seeded data, including a soft-deleted row holding a key and a banned user's like and comment. 026's down-migration legitimately fails where a deleted and a live row share a key — that is inherent to the direction, documented in the file, and sqlx never runs downs at boot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
36 lines
2.2 KiB
SQL
36 lines
2.2 KiB
SQL
-- Narrow the client-upload idempotency index so it stops covering soft-deleted rows.
|
|
--
|
|
-- The bug (H9). Migration 022 created the index partial on `client_upload_id IS NOT NULL`
|
|
-- only, so a soft-deleted row kept occupying its key. But `find_by_client_upload_id` filters
|
|
-- `deleted_at IS NULL` — deliberately, and its doc comment says so: "if the guest deleted the
|
|
-- photo and their queue later retries, they should get a fresh upload rather than a
|
|
-- resurrection of a deleted one." The index and the lookup therefore disagreed, and the
|
|
-- disagreement is reachable by an ordinary guest:
|
|
--
|
|
-- 1. guest uploads a photo, then deletes it (soft delete — the row stays, `deleted_at` set)
|
|
-- 2. their queue retries the same item (reconnect requeue, or they tap "Erneut")
|
|
-- 3. the whole body is re-streamed and re-validated, then `ON CONFLICT DO NOTHING` matches
|
|
-- the DEAD row and inserts nothing
|
|
-- 4. the replay lookup filters that row out and finds nothing, so the handler returns 409
|
|
-- 5. the client classifies 409 as terminal and DELETES the blob from IndexedDB
|
|
--
|
|
-- The photo is now gone from the device with no row in the gallery, and there is no path back.
|
|
-- Re-selecting the same file from the camera roll mints a new `client_upload_id`, so that does
|
|
-- work — but the guest has no way to know that is what is required.
|
|
--
|
|
-- Adding `deleted_at IS NULL` makes the index agree with the lookup: a key is claimed only
|
|
-- while a LIVE row holds it, so step 3 inserts a fresh row and the retry succeeds.
|
|
--
|
|
-- Uniqueness among live rows is what the feature actually needs. The property migration 022
|
|
-- was protecting — "the same photo must not land in the gallery twice" — is about rows the
|
|
-- guest can see, and a soft-deleted row is not one of those.
|
|
|
|
DROP INDEX IF EXISTS upload_client_upload_id_key;
|
|
|
|
-- CONCURRENTLY is deliberately NOT used: sqlx runs each migration inside a transaction, and
|
|
-- CREATE INDEX CONCURRENTLY cannot run in one. The table is small (one event's uploads) and
|
|
-- this runs at boot before the server accepts requests, so the brief lock costs nothing.
|
|
CREATE UNIQUE INDEX upload_client_upload_id_key
|
|
ON upload (client_upload_id)
|
|
WHERE client_upload_id IS NOT NULL AND deleted_at IS NULL;
|