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:
@@ -128,7 +128,7 @@ async fn apply(
|
||||
.clone()
|
||||
.unwrap_or_else(|| serde_json::json!({"text": "", "createdAt": chrono::Utc::now().to_rfc3339()}));
|
||||
let cid = req.cid.clone().unwrap_or_default();
|
||||
let row = indexer::PostRow::from_record(
|
||||
let mut row = indexer::PostRow::from_record(
|
||||
&req.did,
|
||||
&req.rkey,
|
||||
&req.collection,
|
||||
@@ -136,7 +136,7 @@ async fn apply(
|
||||
&record,
|
||||
req.handle.as_deref(),
|
||||
);
|
||||
indexer::upsert_post(&state.db, &row).await.map_err(db_err)?;
|
||||
indexer::upsert_post(&state.db, &mut row).await.map_err(db_err)?;
|
||||
Ok(true)
|
||||
}
|
||||
("app.twi.post", "delete") | ("app.bsky.feed.post", "delete") => {
|
||||
@@ -219,6 +219,39 @@ async fn apply(
|
||||
.map_err(db_err)?;
|
||||
Ok(true)
|
||||
}
|
||||
("app.bsky.actor.profile", "create") if req.rkey == "self" => {
|
||||
// Profile record push from the PDS — populate the
|
||||
// `profiles` cache so the ProfileView-Page and PostCard
|
||||
// avatar get the new display name / bio / avatar / banner
|
||||
// without waiting for the next handle-sync pass.
|
||||
let record = match &req.record {
|
||||
Some(r) if !r.is_null() => r.clone(),
|
||||
_ => return Ok(false),
|
||||
};
|
||||
// Use the handle the PDS provided when present. We
|
||||
// deliberately do NOT fall back to a DB lookup here:
|
||||
// the AppView has no `users` table — the PDS owns that
|
||||
// state. If the PDS omits the handle, we write an empty
|
||||
// string and the `handle_sync` worker (or a subsequent
|
||||
// Jetstream `identity` event) will fill it in.
|
||||
let handle = req
|
||||
.handle
|
||||
.clone()
|
||||
.filter(|h| !h.is_empty())
|
||||
.unwrap_or_default();
|
||||
indexer::upsert_profile(&state.db, &req.did, &handle, &record)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(true)
|
||||
}
|
||||
("app.bsky.actor.profile", "delete") if req.rkey == "self" => {
|
||||
sqlx::query("DELETE FROM profiles WHERE did = $1")
|
||||
.bind(&req.did)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(true)
|
||||
}
|
||||
(coll, action) => {
|
||||
// Unrecognised collection/action — return ok=false so the PDS
|
||||
// doesn't retry. Future collections should be added above.
|
||||
|
||||
Reference in New Issue
Block a user