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:
tomdebone
2026-07-18 17:56:52 +02:00
parent 3064d3d8b7
commit 59a3cb02dd
6 changed files with 585 additions and 18 deletions
+46
View File
@@ -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);