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:
@@ -54,6 +54,12 @@ pub struct PostRow {
|
||||
pub like_count: i64,
|
||||
#[serde(default)]
|
||||
pub repost_count: i64,
|
||||
/// Resolved author-avatar CID from the `profiles` cache. NULL
|
||||
/// until the user has pushed a profile through the PDS path. The
|
||||
/// PostCard uses this to render an <Avatar cid={post.avatar_cid}/>
|
||||
/// inline without a per-row PDS round trip.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub avatar_cid: Option<String>,
|
||||
}
|
||||
|
||||
/// Raw `FromRow` impl — we read `embed` as the helper newtype then
|
||||
@@ -76,6 +82,7 @@ impl<'r> FromRow<'r, sqlx::postgres::PgRow> for PostRow {
|
||||
created_at: row.try_get("created_at")?,
|
||||
like_count: row.try_get::<i64, _>("like_count").unwrap_or(0),
|
||||
repost_count: row.try_get::<i64, _>("repost_count").unwrap_or(0),
|
||||
avatar_cid: row.try_get::<Option<String>, _>("avatar_cid").ok().flatten(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -100,6 +107,8 @@ pub struct PostRowWithIndexed {
|
||||
pub indexed_at: DateTime<Utc>,
|
||||
pub like_count: i64,
|
||||
pub repost_count: i64,
|
||||
/// Resolved author-avatar CID from the `profiles` cache.
|
||||
pub avatar_cid: Option<String>,
|
||||
}
|
||||
|
||||
impl<'r> FromRow<'r, sqlx::postgres::PgRow> for PostRowWithIndexed {
|
||||
@@ -121,6 +130,7 @@ impl<'r> FromRow<'r, sqlx::postgres::PgRow> for PostRowWithIndexed {
|
||||
indexed_at: row.try_get("indexed_at")?,
|
||||
like_count: row.try_get::<i64, _>("like_count").unwrap_or(0),
|
||||
repost_count: row.try_get::<i64, _>("repost_count").unwrap_or(0),
|
||||
avatar_cid: row.try_get::<Option<String>, _>("avatar_cid").ok().flatten(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -142,6 +152,7 @@ impl From<PostRowWithIndexed> for PostRow {
|
||||
created_at: r.created_at,
|
||||
like_count: r.like_count,
|
||||
repost_count: r.repost_count,
|
||||
avatar_cid: r.avatar_cid,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,6 +173,22 @@ pub struct ProfileResponse {
|
||||
pub posts: Vec<PostRow>,
|
||||
pub followers: i64,
|
||||
pub following: i64,
|
||||
/// Denormalised profile metadata from the `profiles` cache.
|
||||
/// Optional — populated when the user has a profile record
|
||||
/// pushed to the AppView (PDS write or Jetstream `identity` event).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub avatar_cid: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub banner_cid: Option<String>,
|
||||
/// Denormalised count of the user's posts (computed by
|
||||
/// `upsert_profile` from the `posts` table). Lets the
|
||||
/// ProfileView-Page render without an extra `COUNT(*)`.
|
||||
#[serde(default)]
|
||||
pub post_count: i64,
|
||||
}
|
||||
|
||||
/// `GET /api/search` response. `q` echoes the search string so the
|
||||
|
||||
Reference in New Issue
Block a user