fix(appview): Unfollows über den Firehose anwendbar machen

Ein Delete-Event trägt nur did + rkey, keinen Record-Body. `follows` hatte
aber nur (follower_did, subject_did) und speicherte den rkey nicht — es gab
also keinen Weg vom rkey zum subject_did, und der Indexer hat solche Ops
geloggt und übersprungen. Unfollows hingen damit allein am Best-Effort-Push,
genau der Abhängigkeit, die der Firehose beseitigen soll.

Migration 0011 ergänzt die rkey-Spalte plus einen partiellen Index für den
Lookup. Der Primärschlüssel bleibt (follower_did, subject_did), damit die
Upserts über Push, Firehose und Replay hinweg idempotent bleiben; ein rkey im
Schlüssel würde aus einem Re-Follow eine zweite Zeile machen und die
Follower-Zahl verdoppeln. Der Index ist bewusst nicht unique: sonst würde
ausgerechnet der Fall, für den das hier existiert — verlorener Delete, dann
ein neuer Create — zu einem abgebrochenen Write.

delete_follow_by_rkey löst und löscht in einem Statement (RETURNING), also
ohne Rennen zwischen Auflösen und Löschen. Findet es nichts — alte Zeile ohne
rkey, schon gelöscht, veralteter rkey — ist das kein Fehler. Der Push-Pfad
über subject_did bleibt unverändert.

Likes und Reposts haben die Lücke nicht: dort ist der rkey Teil der
Zeilenidentität.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
This commit is contained in:
tomdebone
2026-09-10 07:08:46 +02:00
co-authored by Claude Opus 5
parent 124a90dc07
commit 2b695d6892
3 changed files with 498 additions and 27 deletions
+5
View File
@@ -220,10 +220,15 @@ async fn apply(
.map(str::to_string)
})
.ok_or_else(|| bad_request("follow create requires subject_did or record.subject"))?;
// Forward the rkey too. The push path doesn't need it to
// apply *this* write — it has the subject — but storing it
// is what lets a later firehose delete (which carries only
// did + rkey) find this row. See migration 0011.
indexer::upsert_follow(
&state.db,
&req.did,
&subject,
Some(req.rkey.as_str()),
req.record.as_ref(),
)
.await