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.
56 lines
2.3 KiB
SQL
56 lines
2.3 KiB
SQL
-- AppView database schema 0005: profile metadata + per-post avatar refs.
|
|
--
|
|
-- Why
|
|
-- The Profile-View-Page and PostCard both render a user avatar.
|
|
-- Fetching the live `app.bsky.actor.profile/self` record from every
|
|
-- user's PDS on every render doesn't scale, and isn't always reachable
|
|
-- (e.g. a did:web: user whose PDS is offline). We cache the
|
|
-- denormalised profile fields the UI shows keyed by did, indexed by
|
|
-- handle so the `/api/profile/<handle>` lookup is index-driven.
|
|
--
|
|
-- Source of truth: the user's own PDS. The PDS pushes profile records
|
|
-- via the existing `/internal/ingest-commit` path; this migration
|
|
-- adds the `(collection, action, rkey) == ('app.bsky.actor.profile',
|
|
-- 'create', 'self')` arm to the AppView's indexer to populate this
|
|
-- table.
|
|
--
|
|
-- All fields nullable: a profile record can omit displayName,
|
|
-- description, avatar, banner independently.
|
|
--
|
|
-- post_count / follower_count / following_count are denormalised
|
|
-- counts populated only when the row is created/replaced; the
|
|
-- Profile-View-Page reads them here so it doesn't have to issue a
|
|
-- separate COUNT(*) over posts/follows.
|
|
--
|
|
-- The avatar_cid on posts is the resolved profile-avatar blob ref
|
|
-- (or NULL) for the post's author. The AppView fills it in at
|
|
-- upsert_post-time from the profiles table; the PostCard reads it
|
|
-- to inline an <Avatar cid={post.avatar_cid}/> without a per-row
|
|
-- PDS round-trip.
|
|
|
|
CREATE TABLE profiles (
|
|
did TEXT PRIMARY KEY,
|
|
handle TEXT NOT NULL,
|
|
display_name TEXT,
|
|
description TEXT,
|
|
avatar_cid TEXT,
|
|
banner_cid TEXT,
|
|
post_count BIGINT NOT NULL DEFAULT 0,
|
|
follower_count BIGINT NOT NULL DEFAULT 0,
|
|
following_count BIGINT NOT NULL DEFAULT 0,
|
|
indexed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
CREATE INDEX profiles_indexed_at_idx ON profiles (indexed_at DESC);
|
|
|
|
-- Backfill: seed a profile row for every handle we've already
|
|
-- resolved through the posts.did → posts.handle mapping. The display
|
|
-- fields stay NULL — they need the live PDS profile record.
|
|
INSERT INTO profiles (did, handle)
|
|
SELECT DISTINCT ON (did) did, handle
|
|
FROM posts
|
|
WHERE handle <> ''
|
|
ORDER BY did, indexed_at DESC
|
|
ON CONFLICT (did) DO NOTHING;
|
|
|
|
ALTER TABLE posts ADD COLUMN IF NOT EXISTS avatar_cid TEXT;
|