fix(pds-server): forward poster handle to AppView on ingest push

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:<snip>…'
synthetic identifiers — which is fine for Bluesky (PLC directory
resolves the rest), but local-PDS users have 'did🔑' 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.
This commit is contained in:
tomdebone
2026-07-07 21:50:41 +02:00
parent abea4d2a8a
commit 184e0dfe03
4 changed files with 65 additions and 5 deletions
+21 -1
View File
@@ -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<bool> {
self.push(
did,
handle,
collection,
"create",
rkey,
@@ -93,19 +108,21 @@ impl AppViewPushClient {
collection: &str,
rkey: &str,
) -> Result<bool> {
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<bool> {
self.push(
did,
handle,
"app.bsky.graph.follow",
"create",
rkey,
@@ -124,6 +141,7 @@ impl AppViewPushClient {
) -> Result<bool> {
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,
+3 -1
View File
@@ -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<String> = 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,
+21
View File
@@ -454,3 +454,24 @@ async fn persist_user_blocks_in_tx(
}
Ok(())
}
/// Look up the current handle for `did` from the `users` table.
///
/// Returned as `Option<String>` (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<String> {
sqlx::query_scalar::<_, String>("SELECT handle FROM users WHERE did = $1")
.bind(did)
.fetch_optional(&state.db)
.await
.ok()
.flatten()
}
+19 -2
View File
@@ -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<String> = 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");