feat(appview): profile cache + Jetstream indexing + denormalised counts
Adds the AppView-side half of the profile feature so non-local-PDS
authors also get their profile metadata indexed (the Jetstream
identity event stream only carries the handle, not display name /
bio / avatar). The PDS-push path was already wired by the previous
commit; this lands the Jetstream path.
Migration 0005:
* `profiles` table keyed by DID with display_name / description /
avatar_cid / banner_cid plus denormalised post_count /
follower_count / following_count. Backfilled from the posts
table on apply.
* `posts.avatar_cid` column — populated from the profiles cache
at `upsert_post` time so the PostCard can render an avatar
inline without a per-row PDS round trip.
Migration 0007 (clean-up): the original 0005 also created a
`LOWER(handle)` index that no query uses; this drops it
idempotently so dev DBs that already applied 0005 converge.
Indexer (`crates/appview/src/indexer.rs`):
* New `app.bsky.actor.profile` arm in `apply_commit` calls
`upsert_profile` on create, DELETEs the row on delete. Handle
is looked up from `posts` (the Jetstream commit envelope
doesn't carry it).
* `upsert_post` signature is now `&mut PostRow` so it can fill
`row.avatar_cid` from the profiles cache; the ON CONFLICT
clause uses `COALESCE(EXCLUDED, posts)` so re-indexing doesn't
overwrite an already-known avatar.
* `upsert_profile` writes display_name / description /
avatar_cid / banner_cid + the denormalised counts.
* `blob_link_of` helper accepts both `{ $type, ref.$link }`
and legacy flat `{ $link }` blob-ref shapes.
Ingest (`crates/appview/src/ingest.rs`):
* `app.bsky.actor.profile` create/delete arms in the PDS-push
path. The handle-fallback previously did `SELECT handle FROM
users WHERE did = $1` — but the AppView has no `users` table
(it's PDS-owned state). Replaced with a simple use-what-the-PDS-
sent approach; the handle_sync worker fills the column later.
Routes (`crates/appview/src/routes.rs`):
* `resolve_profile` reads the denormalised profile fields from
the cache. When no profile row exists the `post_count` fallback
uses a live `SELECT COUNT(*)` instead of `posts.len()`, so
prolific authors without a profile row report the real count
rather than the 50-post slice cap.
Tests (DB-gated, run when DATABASE_URL_APPVIEW is set):
* `blob_link_of_modern_shape` / `_legacy_flat_link` /
`_missing_field`.
* `upsert_profile_round_trip` — insert + replace semantics.
* `apply_commit_indexes_profile_create` — end-to-end Jetstream
arm + delete.
This commit is contained in:
@@ -372,6 +372,11 @@ async fn resolve_profile(
|
||||
posts: Vec::new(),
|
||||
followers: 0,
|
||||
following: 0,
|
||||
display_name: None,
|
||||
description: None,
|
||||
avatar_cid: None,
|
||||
banner_cid: None,
|
||||
post_count: 0,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -427,12 +432,51 @@ async fn resolve_profile(
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
// Look up the denormalised profile metadata for this DID. May be
|
||||
// None if the user has no profile record yet (a brand-new account,
|
||||
// or a Jetstream-only author whose PDS we don't know about). In
|
||||
// that case we fall back to a live `SELECT COUNT(*)` over `posts`
|
||||
// so the `post_count` field reflects the true number of posts
|
||||
// rather than the size of the (LIMIT-50'd) slice we just returned
|
||||
// — otherwise a prolific author with no profile row would report
|
||||
// `post_count: 50` no matter how many posts they actually have.
|
||||
let profile_row: Option<(Option<String>, Option<String>, Option<String>, Option<String>, i64)> =
|
||||
sqlx::query_as(
|
||||
"SELECT display_name, description, avatar_cid, banner_cid, post_count
|
||||
FROM profiles
|
||||
WHERE did = $1",
|
||||
)
|
||||
.bind(&target_did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
let (display_name, description, avatar_cid, banner_cid, post_count) = match profile_row {
|
||||
Some(row) => row,
|
||||
None => {
|
||||
let real_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*)::BIGINT FROM posts \
|
||||
WHERE did = $1 \
|
||||
AND collection IN ('app.twi.post','app.bsky.feed.post')",
|
||||
)
|
||||
.bind(&target_did)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
(None, None, None, None, real_count)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(ProfileResponse {
|
||||
did: target_did,
|
||||
handle: display_handle,
|
||||
posts,
|
||||
followers,
|
||||
following,
|
||||
display_name,
|
||||
description,
|
||||
avatar_cid,
|
||||
banner_cid,
|
||||
post_count,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -830,6 +874,7 @@ mod tests {
|
||||
created_at: Utc::now(),
|
||||
like_count: 0,
|
||||
repost_count: 0,
|
||||
avatar_cid: None,
|
||||
},
|
||||
PostRow {
|
||||
uri: "at://x/app.twi.post/2".into(),
|
||||
@@ -846,6 +891,7 @@ mod tests {
|
||||
created_at: Utc::now(),
|
||||
like_count: 0,
|
||||
repost_count: 0,
|
||||
avatar_cid: None,
|
||||
},
|
||||
];
|
||||
decorate_handles(&mut rows);
|
||||
|
||||
Reference in New Issue
Block a user