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:
co-authored by
Claude Opus 5
parent
9d009bfcba
commit
c4ca218d97
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user