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:
@@ -32,6 +32,11 @@ use std::time::Duration;
|
|||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct IngestCommitBody<'a> {
|
struct IngestCommitBody<'a> {
|
||||||
did: &'a str,
|
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,
|
collection: &'a str,
|
||||||
action: &'a str,
|
action: &'a str,
|
||||||
rkey: &'a str,
|
rkey: &'a str,
|
||||||
@@ -63,6 +68,14 @@ impl AppViewPushClient {
|
|||||||
/// AT-Protocol record value as JSON — the AppView's indexer reads
|
/// 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.
|
/// `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)`
|
/// Returns `Ok(true)` if the AppView applied the commit, `Ok(false)`
|
||||||
/// if it returned a non-2xx status (logged as warn), and `Err(_)` if
|
/// 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
|
/// the request itself failed. The caller should treat any non-Ok as
|
||||||
@@ -70,6 +83,7 @@ impl AppViewPushClient {
|
|||||||
pub async fn push_create(
|
pub async fn push_create(
|
||||||
&self,
|
&self,
|
||||||
did: &str,
|
did: &str,
|
||||||
|
handle: Option<&str>,
|
||||||
collection: &str,
|
collection: &str,
|
||||||
rkey: &str,
|
rkey: &str,
|
||||||
cid: &str,
|
cid: &str,
|
||||||
@@ -77,6 +91,7 @@ impl AppViewPushClient {
|
|||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
self.push(
|
self.push(
|
||||||
did,
|
did,
|
||||||
|
handle,
|
||||||
collection,
|
collection,
|
||||||
"create",
|
"create",
|
||||||
rkey,
|
rkey,
|
||||||
@@ -93,19 +108,21 @@ impl AppViewPushClient {
|
|||||||
collection: &str,
|
collection: &str,
|
||||||
rkey: &str,
|
rkey: &str,
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
self.push(did, collection, "delete", rkey, None, None, None)
|
self.push(did, None, collection, "delete", rkey, None, None, None)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn push_follow_create(
|
pub async fn push_follow_create(
|
||||||
&self,
|
&self,
|
||||||
did: &str,
|
did: &str,
|
||||||
|
handle: Option<&str>,
|
||||||
rkey: &str,
|
rkey: &str,
|
||||||
subject_did: &str,
|
subject_did: &str,
|
||||||
record: &Value,
|
record: &Value,
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
self.push(
|
self.push(
|
||||||
did,
|
did,
|
||||||
|
handle,
|
||||||
"app.bsky.graph.follow",
|
"app.bsky.graph.follow",
|
||||||
"create",
|
"create",
|
||||||
rkey,
|
rkey,
|
||||||
@@ -124,6 +141,7 @@ impl AppViewPushClient {
|
|||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
self.push(
|
self.push(
|
||||||
did,
|
did,
|
||||||
|
None,
|
||||||
"app.bsky.graph.follow",
|
"app.bsky.graph.follow",
|
||||||
"delete",
|
"delete",
|
||||||
rkey,
|
rkey,
|
||||||
@@ -137,6 +155,7 @@ impl AppViewPushClient {
|
|||||||
async fn push(
|
async fn push(
|
||||||
&self,
|
&self,
|
||||||
did: &str,
|
did: &str,
|
||||||
|
handle: Option<&str>,
|
||||||
collection: &str,
|
collection: &str,
|
||||||
action: &str,
|
action: &str,
|
||||||
rkey: &str,
|
rkey: &str,
|
||||||
@@ -147,6 +166,7 @@ impl AppViewPushClient {
|
|||||||
let url = format!("{}/internal/ingest-commit", self.base_url);
|
let url = format!("{}/internal/ingest-commit", self.base_url);
|
||||||
let body = IngestCommitBody {
|
let body = IngestCommitBody {
|
||||||
did,
|
did,
|
||||||
|
handle,
|
||||||
collection,
|
collection,
|
||||||
action,
|
action,
|
||||||
rkey,
|
rkey,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
//! removed from the MST, a new commit is signed, the AppView is
|
//! 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.
|
//! 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 at_repo::blockstore::Blockstore;
|
||||||
use crate::routes::types::ErrorBody;
|
use crate::routes::types::ErrorBody;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
@@ -273,6 +273,7 @@ pub async fn create_like(
|
|||||||
let value_cid_str = value_cid.to_string();
|
let value_cid_str = value_cid.to_string();
|
||||||
let push_record = record.clone();
|
let push_record = record.clone();
|
||||||
let push_rkey = rkey.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 commit = apply_and_commit(&state, &did, move |repo| {
|
||||||
let value_cid = value_cid;
|
let value_cid = value_cid;
|
||||||
@@ -317,6 +318,7 @@ pub async fn create_like(
|
|||||||
if let Err(e) = push_handle
|
if let Err(e) = push_handle
|
||||||
.push_create(
|
.push_create(
|
||||||
&push_did,
|
&push_did,
|
||||||
|
push_handle_str.as_deref(),
|
||||||
LIKE_COLLECTION,
|
LIKE_COLLECTION,
|
||||||
&push_rkey_owned,
|
&push_rkey_owned,
|
||||||
&push_cid_owned,
|
&push_cid_owned,
|
||||||
|
|||||||
@@ -453,4 +453,25 @@ async fn persist_user_blocks_in_tx(
|
|||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
Ok(())
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::routes::helpers::{
|
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::routes::types::{CreateRecordReq, CreateRecordResp};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
@@ -92,6 +92,16 @@ pub async fn create_record(
|
|||||||
let push_rkey = rkey.clone();
|
let push_rkey = rkey.clone();
|
||||||
let push_cid = value_cid.to_string();
|
let push_cid = value_cid.to_string();
|
||||||
let push_record = req.record.clone();
|
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 collection = req.collection.clone();
|
||||||
|
|
||||||
let outcome = apply_repo_write(&state, &did, move |repo| {
|
let outcome = apply_repo_write(&state, &did, move |repo| {
|
||||||
@@ -136,7 +146,14 @@ pub async fn create_record(
|
|||||||
// the AppView.
|
// the AppView.
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = push_handle
|
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
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!(error = %e, did = %push_did, "appview push_create failed; jetstream will replay");
|
tracing::warn!(error = %e, did = %push_did, "appview push_create failed; jetstream will replay");
|
||||||
|
|||||||
Reference in New Issue
Block a user