From 184e0dfe0389578be6bfa4994e53d921038e568c Mon Sep 17 00:00:00 2001 From: tomdebone Date: Tue, 7 Jul 2026 21:50:41 +0200 Subject: [PATCH] fix(pds-server): forward poster handle to AppView on ingest push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PDS's best-effort push to /internal/ingest-commit didn't include the poster's handle. The AppView's indexer then stored '\'' (empty) and the timeline UI fell back to '@did:plc:…' synthetic identifiers — which is fine for Bluesky (PLC directory resolves the rest), but local-PDS users have 'did:key:' DIDs that no resolver can look up, so the synthetic handle stuck forever and the profile endpoint could never resolve 'handle → did'. Plumb the handle through: - appview_push.rs: IngestCommitBody gains an optional 'handle' field; push_create / push_follow_create take Option<&str> - routes/helpers.rs: new 'lookup_handle(state, did)' helper that hits the 'users' table (in practice always finds the row for an authenticated route; logs a warning otherwise) - routes/repo.rs (createRecord) and routes/feed.rs (feed.like.create): resolve 'did → handle' from the users table before the spawned ingest push, pass it through The AppView-side companion commit stores the handle on the new row and adds a Jetstream identity-event backfill, so by the time this PR is merged timelines render real '@handle' again. --- crates/pds-server/src/appview_push.rs | 22 +++++++++++++++++++++- crates/pds-server/src/routes/feed.rs | 4 +++- crates/pds-server/src/routes/helpers.rs | 23 ++++++++++++++++++++++- crates/pds-server/src/routes/repo.rs | 21 +++++++++++++++++++-- 4 files changed, 65 insertions(+), 5 deletions(-) diff --git a/crates/pds-server/src/appview_push.rs b/crates/pds-server/src/appview_push.rs index 8713412..a0089d0 100644 --- a/crates/pds-server/src/appview_push.rs +++ b/crates/pds-server/src/appview_push.rs @@ -32,6 +32,11 @@ use std::time::Duration; #[derive(Debug, Serialize)] struct IngestCommitBody<'a> { did: &'a str, + /// The poster's current handle. Optional in the wire payload — + /// the AppView's indexer treats an empty/missing handle as the + /// existing empty-string placeholder, which the Jetstream + /// `identity` event path will eventually backfill. + handle: Option<&'a str>, collection: &'a str, action: &'a str, rkey: &'a str, @@ -63,6 +68,14 @@ impl AppViewPushClient { /// AT-Protocol record value as JSON — the AppView's indexer reads /// `embed` / `reply` off it, which is why we can't just send the CID. /// + /// `handle` is the poster's current handle. Pass `Some(handle)` for + /// local-PDS users so the AppView's `posts.handle` column is + /// populated immediately (otherwise the timeline renders handles as + /// `@did:plc:…` snippets and the profile endpoint can't resolve + /// `handle → did`). For the `app.bsky.feed.like` / `app.bsky.feed.repost` + /// collections the AppView also needs it so the liker's handle + /// lands on the `likes.liker_handle` column. + /// /// Returns `Ok(true)` if the AppView applied the commit, `Ok(false)` /// if it returned a non-2xx status (logged as warn), and `Err(_)` if /// the request itself failed. The caller should treat any non-Ok as @@ -70,6 +83,7 @@ impl AppViewPushClient { pub async fn push_create( &self, did: &str, + handle: Option<&str>, collection: &str, rkey: &str, cid: &str, @@ -77,6 +91,7 @@ impl AppViewPushClient { ) -> Result { self.push( did, + handle, collection, "create", rkey, @@ -93,19 +108,21 @@ impl AppViewPushClient { collection: &str, rkey: &str, ) -> Result { - self.push(did, collection, "delete", rkey, None, None, None) + self.push(did, None, collection, "delete", rkey, None, None, None) .await } pub async fn push_follow_create( &self, did: &str, + handle: Option<&str>, rkey: &str, subject_did: &str, record: &Value, ) -> Result { self.push( did, + handle, "app.bsky.graph.follow", "create", rkey, @@ -124,6 +141,7 @@ impl AppViewPushClient { ) -> Result { self.push( did, + None, "app.bsky.graph.follow", "delete", rkey, @@ -137,6 +155,7 @@ impl AppViewPushClient { async fn push( &self, did: &str, + handle: Option<&str>, collection: &str, action: &str, rkey: &str, @@ -147,6 +166,7 @@ impl AppViewPushClient { let url = format!("{}/internal/ingest-commit", self.base_url); let body = IngestCommitBody { did, + handle, collection, action, rkey, diff --git a/crates/pds-server/src/routes/feed.rs b/crates/pds-server/src/routes/feed.rs index 6bd8d68..f5b961b 100644 --- a/crates/pds-server/src/routes/feed.rs +++ b/crates/pds-server/src/routes/feed.rs @@ -17,7 +17,7 @@ //! removed from the MST, a new commit is signed, the AppView is //! told to drop the row, and we return the new commit CID + rev. -use crate::routes::helpers::{apply_repo_write, err, to_sqlx_error, RepoWriteOutcome}; +use crate::routes::helpers::{apply_repo_write, err, lookup_handle, to_sqlx_error, RepoWriteOutcome}; use at_repo::blockstore::Blockstore; use crate::routes::types::ErrorBody; use crate::state::AppState; @@ -273,6 +273,7 @@ pub async fn create_like( let value_cid_str = value_cid.to_string(); let push_record = record.clone(); let push_rkey = rkey.clone(); + let push_handle_str: Option = lookup_handle(&state, &did).await; let commit = apply_and_commit(&state, &did, move |repo| { let value_cid = value_cid; @@ -317,6 +318,7 @@ pub async fn create_like( if let Err(e) = push_handle .push_create( &push_did, + push_handle_str.as_deref(), LIKE_COLLECTION, &push_rkey_owned, &push_cid_owned, diff --git a/crates/pds-server/src/routes/helpers.rs b/crates/pds-server/src/routes/helpers.rs index 01715c7..6641776 100644 --- a/crates/pds-server/src/routes/helpers.rs +++ b/crates/pds-server/src/routes/helpers.rs @@ -453,4 +453,25 @@ async fn persist_user_blocks_in_tx( })?; } Ok(()) -} \ No newline at end of file +} +/// Look up the current handle for `did` from the `users` table. +/// +/// Returned as `Option` (rather than an empty default) so the +/// caller can decide what to do when the row hasn't been found yet — +/// in practice the row is always present for an authenticated route, +/// but we'd rather log a warning than silently emit a bogus empty +/// handle into the AppView's `posts.handle` column. +/// +/// Called once per `createRecord` / `feed.like.create` write, just +/// before the AppView push, so the liker's/poster's display handle +/// lands on the AppView row at write time — otherwise the AppView has +/// no source for `did:key:` handles and the timeline renders them as +/// `@did:key:z16D…` snippets. +pub async fn lookup_handle(state: &AppState, did: &str) -> Option { + sqlx::query_scalar::<_, String>("SELECT handle FROM users WHERE did = $1") + .bind(did) + .fetch_optional(&state.db) + .await + .ok() + .flatten() +} diff --git a/crates/pds-server/src/routes/repo.rs b/crates/pds-server/src/routes/repo.rs index 167e895..8579177 100644 --- a/crates/pds-server/src/routes/repo.rs +++ b/crates/pds-server/src/routes/repo.rs @@ -1,5 +1,5 @@ use crate::routes::helpers::{ - apply_repo_write, err, to_sqlx_error, RepoWriteOutcome, + apply_repo_write, err, lookup_handle, to_sqlx_error, RepoWriteOutcome, }; use crate::routes::types::{CreateRecordReq, CreateRecordResp}; use crate::state::AppState; @@ -92,6 +92,16 @@ pub async fn create_record( let push_rkey = rkey.clone(); let push_cid = value_cid.to_string(); let push_record = req.record.clone(); + // Resolve the poster's current handle from the local users + // table *before* the spawn — the closure can't easily borrow + // `&state` after we hand ownership to the spawned task. + let push_handle_str: Option = match lookup_handle(&state, &did).await { + h @ Some(_) => h, + None => { + tracing::warn!(did = %did, "appview push: no handle in users table; timeline will show @did-prefix"); + None + } + }; let collection = req.collection.clone(); let outcome = apply_repo_write(&state, &did, move |repo| { @@ -136,7 +146,14 @@ pub async fn create_record( // the AppView. tokio::spawn(async move { if let Err(e) = push_handle - .push_create(&push_did, &push_coll, &push_rkey, &push_cid, &push_record) + .push_create( + &push_did, + push_handle_str.as_deref(), + &push_coll, + &push_rkey, + &push_cid, + &push_record, + ) .await { tracing::warn!(error = %e, did = %push_did, "appview push_create failed; jetstream will replay");