feat(appview): Notifications, Follower-/Following-Listen, Thread-Route

Bisher erfuhr ein Nutzer nie, dass jemand anderes mit ihm interagiert hat:
Like, Repost, Follow und Reply hinterließen keine Spur, an der der Client
hätte pollen können. Der Tray-/Notification-Pfad im Desktop-Client (Phase 7)
hing damit in der Luft.

Migration 0008:
* notifications(recipient, author, kind, subject_uri, created_at,
  indexed_at, read_at) mit Keyset-Index (recipient, indexed_at DESC, id DESC)
  und Partial-Index auf ungelesene Zeilen für den Badge-Poll.
* Dedupe-Unique-Index über COALESCE(subject_uri, '') — plain NULLs
  kollidieren nicht, sonst gäbe es pro Follow beliebig viele Zeilen.
  Folge: Unlike-Relike erzeugt keine zweite Notification, Toggle-Spam ist
  damit ausgeschlossen.
* Bewusst kein CHECK (recipient <> author): ein Ausrutscher dort würde die
  umgebende Like-Transaktion abbrechen, also das Like wegen eines
  Notification-Bugs verlieren. Gefiltert wird in Rust und im INSERT.

Indexer: record_notification() hängt an upsert_like/-repost (in derselben
Transaktion wie die Counter) sowie upsert_follow/-post. Selbst-Interaktionen
sind still. Empfänger muss uns bekannt sein (profiles- oder posts-Zeile),
sonst würden wir für den gesamten öffentlichen Firehose Zeilen anlegen —
als ein INSERT ... SELECT ... WHERE EXISTS, also ohne TOCTOU-Fenster.
Reply-Notifications tragen die URI der *Antwort* als subject_uri, weil die
Liste den Text zeigt, den der Empfänger noch nicht kennt.

Endpoints: GET /api/notifications, /api/notifications/count,
POST /api/notifications/seen (seenAt als Wasserzeichen),
GET /api/followers, /api/following, GET /api/thread (beide Schreibweisen).
Cursor-Codec, Limit-Clamping und Fehlerform sind die der bestehenden
Endpoints.

/api/post/*uri bleibt wire-kompatibel und teilt sich jetzt
load_thread_context() mit /api/thread — mit max_parents = 1, weil es nur
den direkten Parent serialisiert; die volle Ahnenkette wären bis zu 20
sequenzielle Queries für Zeilen, die danach verworfen werden.

Nebenbei ein Darstellungsfehler: der synthetische Platzhalter-Handle für
Actors ohne bekannten Handle trug ein führendes '@', während jeder Consumer
selbst '@{handle}' rendert — im Feed kam '@@did:plc:abcd…' heraus. Der
Platzhalter ist jetzt durchgängig sigil-frei.

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-09 21:36:32 +02:00
co-authored by Claude Opus 5
parent 9d009bfcba
commit c4ca218d97
6 changed files with 2519 additions and 123 deletions
+47 -16
View File
@@ -134,7 +134,11 @@ async fn timeline_returns_seeded_posts() {
"cid": "bafyreicid",
"record": {
"text": format!("seeded post #{i}"),
"createdAt": "2026-07-01T12:00:00Z",
// Strictly increasing so the ordering assertion
// below has something to actually check: the rows
// are inserted in this order, so `indexed_at` and
// `created_at` agree for *our* posts.
"createdAt": format!("2026-07-01T12:00:{:02}Z", i),
}
}),
)
@@ -147,9 +151,15 @@ async fn timeline_returns_seeded_posts() {
// three rows in the first page.
tokio::time::sleep(Duration::from_millis(50)).await;
// `limit=100`, not 10: this DID follows nobody, so the endpoint
// serves the cold-start *global* recent feed. On any database with
// more than a handful of recent posts (i.e. every developer
// machine that has run this suite twice) the three rows we just
// seeded fall outside a 10-row window and the assertions below
// fail for reasons that have nothing to do with the timeline.
let resp = c
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[("did", did.as_str()), ("limit", "10")])
.query(&[("did", did.as_str()), ("limit", "100")])
.send()
.await
.unwrap();
@@ -174,15 +184,28 @@ async fn timeline_returns_seeded_posts() {
.collect();
assert!(our_uris.len() >= 3, "missing our seeded posts in {posts:?}");
// Posts must be sorted with `indexed_at DESC`. We can't see
// indexed_at directly in the response, but the URI order in
// `app.twi.post/<rkey>` is rkey-random here, so we only assert
// `created_at` is non-increasing.
// Posts come back `indexed_at DESC`, and we can't see `indexed_at`
// in the response but for the three rows WE just inserted,
// insertion order == `indexed_at` order == `created_at` order, so
// their `created_at` values must be non-increasing.
//
// Two fixes over the original assertion:
// - the wire field is `created_at`, not `createdAt` (the
// `PostRow` wire type in `routes/types.rs` carries no
// `rename_all = "camelCase"`), so `p["createdAt"]` was JSON
// `null` and `.as_str().unwrap()` panicked on the first row;
// - it ran over ALL posts, including other tests' fixtures from
// the global cold-start feed, whose `created_at` values have
// no relation to their `indexed_at` order. Restricting it to
// our own DID is the only version of this claim that holds.
let mut prev: Option<String> = None;
for p in posts {
let ca = p["createdAt"].as_str().unwrap().to_string();
if let Some(p) = prev.take() {
assert!(ca <= p, "createdAt must be non-increasing: {ca} <= {p}");
for p in posts.iter().filter(|p| p["did"] == json!(did)) {
let ca = p["created_at"].as_str().unwrap().to_string();
if let Some(prev) = prev.take() {
assert!(
ca <= prev,
"created_at must be non-increasing: {ca} <= {prev}"
);
}
prev = Some(ca);
}
@@ -354,16 +377,24 @@ async fn profile_returns_posts_for_handle() {
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
// 404 for an unknown handle.
// An unknown handle is NOT a 404. `resolve_profile` deliberately
// synthesises an empty profile (empty `did`, zero counts, no
// posts) so the UI renders an empty profile page instead of an
// error toast — see the comment on the `let Some(target_did)`
// else-branch in `routes.rs`. This assertion used to expect 404
// and contradicted the endpoint it was testing.
let unknown = format!("nobody_{}", uuid::Uuid::new_v4().simple());
let resp = c
.get(format!(
"{APPVIEW_URL}/api/profile/nobody_{}",
uuid::Uuid::new_v4().simple()
))
.get(format!("{APPVIEW_URL}/api/profile/{unknown}"))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 404);
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["did"], json!(""));
assert_eq!(body["handle"], json!(unknown));
assert!(body["posts"].as_array().unwrap().is_empty());
assert_eq!(body["post_count"], json!(0));
// /api/profile?did=... must work too.
let resp = c