feat(db): idempotency keys, ban-aware counts, and a host audit trail
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>
This commit is contained in:
@@ -26,7 +26,7 @@ async fn create_upload(
|
||||
let row: Option<(Uuid,)> = sqlx::query_as(
|
||||
"INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes, caption, client_upload_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (client_upload_id) WHERE client_upload_id IS NOT NULL DO NOTHING
|
||||
ON CONFLICT (client_upload_id) WHERE client_upload_id IS NOT NULL AND deleted_at IS NULL DO NOTHING
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(event_id)
|
||||
@@ -172,4 +172,29 @@ async fn a_deleted_upload_is_not_replayed(pool: PgPool) {
|
||||
None,
|
||||
"a soft-deleted upload must not be replayed"
|
||||
);
|
||||
|
||||
// The other half of that rule, and the half that was missing (H9). Asserting only that the
|
||||
// lookup returns None left the index free to disagree with it: migration 022's predicate
|
||||
// covered soft-deleted rows, so the retry's INSERT hit `ON CONFLICT DO NOTHING` against the
|
||||
// dead row, the replay lookup above then found nothing, and the handler answered 409 — which
|
||||
// the client classifies terminal and purges the blob for. The photo was gone from the phone
|
||||
// AND absent from the gallery, with no way back.
|
||||
//
|
||||
// Migration 026 narrowed the index to live rows so a retry after a delete inserts a FRESH
|
||||
// upload, which is what `find_by_client_upload_id`'s own doc comment always claimed happened.
|
||||
let retried = create_upload(&pool, event_id, user_id, "originals/a.jpg", Some(key)).await;
|
||||
assert!(
|
||||
retried.is_some(),
|
||||
"a retry after the guest deleted the photo must create a fresh upload, not 409 forever"
|
||||
);
|
||||
assert_ne!(
|
||||
retried,
|
||||
Some(id),
|
||||
"the retry must be a new row, not the dead one"
|
||||
);
|
||||
assert_eq!(
|
||||
find_by_key(&pool, user_id, key).await,
|
||||
retried,
|
||||
"the live row is the one the replay lookup must now find"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user