Files
maarcadetweet/crates/appview/src/indexer.rs
T
tomdeboneandClaude Opus 5 c4ca218d97 feat(appview): Notifications, Follower-/Following-Listen, Thread-Route
Bisher erfuhr ein Nutzer nie, dass jemand anderes mit ihm interagiert hat:
Like, Repost, Follow und Reply hinterließen keine Spur, an der der Client
hätte pollen können. Der Tray-/Notification-Pfad im Desktop-Client (Phase 7)
hing damit in der Luft.

Migration 0008:
* notifications(recipient, author, kind, subject_uri, created_at,
  indexed_at, read_at) mit Keyset-Index (recipient, indexed_at DESC, id DESC)
  und Partial-Index auf ungelesene Zeilen für den Badge-Poll.
* Dedupe-Unique-Index über COALESCE(subject_uri, '') — plain NULLs
  kollidieren nicht, sonst gäbe es pro Follow beliebig viele Zeilen.
  Folge: Unlike-Relike erzeugt keine zweite Notification, Toggle-Spam ist
  damit ausgeschlossen.
* Bewusst kein CHECK (recipient <> author): ein Ausrutscher dort würde die
  umgebende Like-Transaktion abbrechen, also das Like wegen eines
  Notification-Bugs verlieren. Gefiltert wird in Rust und im INSERT.

Indexer: record_notification() hängt an upsert_like/-repost (in derselben
Transaktion wie die Counter) sowie upsert_follow/-post. Selbst-Interaktionen
sind still. Empfänger muss uns bekannt sein (profiles- oder posts-Zeile),
sonst würden wir für den gesamten öffentlichen Firehose Zeilen anlegen —
als ein INSERT ... SELECT ... WHERE EXISTS, also ohne TOCTOU-Fenster.
Reply-Notifications tragen die URI der *Antwort* als subject_uri, weil die
Liste den Text zeigt, den der Empfänger noch nicht kennt.

Endpoints: GET /api/notifications, /api/notifications/count,
POST /api/notifications/seen (seenAt als Wasserzeichen),
GET /api/followers, /api/following, GET /api/thread (beide Schreibweisen).
Cursor-Codec, Limit-Clamping und Fehlerform sind die der bestehenden
Endpoints.

/api/post/*uri bleibt wire-kompatibel und teilt sich jetzt
load_thread_context() mit /api/thread — mit max_parents = 1, weil es nur
den direkten Parent serialisiert; die volle Ahnenkette wären bis zu 20
sequenzielle Queries für Zeilen, die danach verworfen werden.

Nebenbei ein Darstellungsfehler: der synthetische Platzhalter-Handle für
Actors ohne bekannten Handle trug ein führendes '@', während jeder Consumer
selbst '@{handle}' rendert — im Feed kam '@@did:plc:abcd…' heraus. Der
Platzhalter ist jetzt durchgängig sigil-frei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 21:36:32 +02:00

2093 lines
72 KiB
Rust

//! DB upsert functions for the AppView indexer.
//!
//! These are pure: given a [`PgPool`] and a parsed Jetstream (or PDS-pushed)
//! `CommitOp`, each function performs one idempotent write against the
//! `posts` / `likes` / `reposts` / `follows` tables.
//!
//! Every write is idempotent: UPSERT for create/update and DELETE for
//! removals, so re-applying the same event multiple times (e.g. on a Jetstream
//! reconnect that replays recent messages) is safe.
use anyhow::{anyhow, Result};
use at_firehose::{CommitOp, JetstreamEvent};
use serde_json::Value;
use sqlx::{
encode::IsNull,
error::BoxDynError,
PgPool, Postgres, Type,
};
#[cfg(test)]
use std::time::Duration;
#[cfg(test)]
use tokio::time::timeout;
/// Collections whose `create`/`delete` events we persist to the `posts` table.
#[allow(dead_code)]
pub const POST_COLLECTIONS: &[&str] = &["app.twi.post", "app.bsky.feed.post"];
/// Build a `at://did/collection/rkey` URI from the event's `did` + the
/// commit op's `rkey` (falling back to the trailing component of `path`).
#[allow(dead_code)]
pub fn build_uri(did: &str, collection: &str, op: &CommitOp) -> Option<String> {
let rkey = op
.rkey
.clone()
.or_else(|| {
op.path
.as_deref()
.and_then(|p| p.rsplit('/').next().map(str::to_string))
})?;
Some(format!("at://{did}/{collection}/{rkey}"))
}
/// Best-effort extraction of the collection NSID from a Jetstream commit
/// payload. Jetstream single-op events have it at `commit.collection`;
/// firehose-style batched events have it per-op under `collection`.
pub fn extract_collection(commit: &Value) -> Option<String> {
if let Some(c) = commit.get("collection").and_then(|v| v.as_str()) {
return Some(c.to_string());
}
if let Some(ops) = commit.get("ops").and_then(|v| v.as_array()) {
if let Some(c) = ops
.first()
.and_then(|o| o.get("collection"))
.and_then(|v| v.as_str())
{
return Some(c.to_string());
}
}
None
}
/// Convert the wire-format `commit` value into a flat list of
/// [`CommitOp`]s. Accepts both the Jetstream single-op shape (where the
/// commit object itself carries `operation`/`rkey`/`record`/...) and the
/// firehose batched shape (where `commit.ops` is an array of ops).
///
/// The wire field name is `operation`, which we map to `CommitOp::action`
/// because that's the name the at-firehose crate settled on.
pub fn commit_op_from_jetstream_value(commit: &Value) -> Vec<CommitOp> {
if let Some(ops) = commit.get("ops").and_then(|v| v.as_array()) {
return ops.iter().filter_map(parse_single_op).collect();
}
parse_single_op(commit).into_iter().collect()
}
fn parse_single_op(op: &Value) -> Option<CommitOp> {
let action = op
.get("operation")
.or_else(|| op.get("action"))
.and_then(|v| v.as_str())?
.to_string();
let rkey = op.get("rkey").and_then(|v| v.as_str()).map(String::from);
let path = op.get("path").and_then(|v| v.as_str()).map(String::from);
let cid = op.get("cid").and_then(|v| v.as_str()).map(String::from);
let record = op.get("record").cloned();
Some(CommitOp {
action,
rkey,
path,
cid,
record,
})
}
/// Parse an ISO-8601 timestamp string from a record's `createdAt`. We treat
/// any parse failure as "now" rather than dropping the record — the event
/// itself is still useful; we just lose the client's timestamp.
pub fn parse_created_at(s: Option<&str>) -> chrono::DateTime<chrono::Utc> {
match s.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) {
Some(dt) => dt.with_timezone(&chrono::Utc),
None => chrono::Utc::now(),
}
}
// -- cursor ----------------------------------------------------------------
const CURSOR_ROW_ID: i32 = 1;
/// Read the persisted jetstream cursor (microseconds since epoch). Returns 0
/// if the row is missing for any reason (e.g. fresh DB before the migration
/// inserted it).
#[allow(dead_code)]
pub async fn cursor_get(db: &PgPool) -> Result<i64> {
let row: Option<(i64,)> = sqlx::query_as(
"SELECT cursor FROM jetstream_cursor WHERE id = $1",
)
.bind(CURSOR_ROW_ID)
.fetch_optional(db)
.await?;
Ok(row.map(|(c,)| c).unwrap_or(0))
}
/// Advance the persisted cursor, but never to a lower value.
pub async fn cursor_advance(db: &PgPool, new_value: i64) -> Result<()> {
sqlx::query(
r#"UPDATE jetstream_cursor
SET cursor = GREATEST(cursor, $1), updated_at = now()
WHERE id = $2"#,
)
.bind(new_value)
.bind(CURSOR_ROW_ID)
.execute(db)
.await?;
Ok(())
}
// -- posts -----------------------------------------------------------------
/// A nullable JSONB column wrapper. We store `embed` as the full
/// AT-Protocol embed object verbatim — the alternative (normalising into
/// a separate `embeds` table) would cost an extra round trip per post
/// and require schema migrations for every new embed variant.
///
/// `Option<Value>` already implements `Encode<Json>` for `serde_json::Value`,
/// so we only need the Decode/Type pair to handle SQL NULL → `None`.
#[derive(Debug, Clone, Default)]
pub struct EmbedColumn(pub Option<Value>);
impl From<Option<Value>> for EmbedColumn {
fn from(v: Option<Value>) -> Self {
EmbedColumn(v)
}
}
impl From<Value> for EmbedColumn {
fn from(v: Value) -> Self {
EmbedColumn(Some(v))
}
}
impl<'r> sqlx::Decode<'r, Postgres> for EmbedColumn {
fn decode(
value: <Postgres as sqlx::Database>::ValueRef<'r>,
) -> Result<Self, sqlx::error::BoxDynError> {
let v: Option<Value> = <Option<Value> as sqlx::Decode<Postgres>>::decode(value)?;
Ok(EmbedColumn(v))
}
}
impl<'q> sqlx::Encode<'q, Postgres> for EmbedColumn {
fn encode_by_ref(
&self,
buf: &mut <Postgres as sqlx::Database>::ArgumentBuffer<'q>,
) -> Result<IsNull, BoxDynError> {
match &self.0 {
Some(v) => <&Value as sqlx::Encode<Postgres>>::encode_by_ref(&v, buf),
None => Ok(IsNull::Yes),
}
}
}
impl Type<Postgres> for EmbedColumn {
fn type_info() -> <Postgres as sqlx::Database>::TypeInfo {
<Value as Type<Postgres>>::type_info()
}
fn compatible(ty: &<Postgres as sqlx::Database>::TypeInfo) -> bool {
<Value as Type<Postgres>>::compatible(ty)
}
}
#[derive(Debug)]
pub struct PostRow {
pub uri: String,
pub did: String,
pub handle: String,
pub rkey: String,
pub collection: String,
pub text: String,
pub cid: String,
pub parent_uri: Option<String>,
pub root_uri: Option<String>,
pub embed: Option<Value>,
pub langs: Option<Vec<String>>,
pub created_at: chrono::DateTime<chrono::Utc>,
/// Resolved author avatar CID from the `profiles` cache. Populated
/// at `upsert_post` time so the PostCard can render an avatar
/// without a per-row PDS round trip. NULL for users whose profile
/// hasn't been pushed yet.
pub avatar_cid: Option<String>,
}
impl PostRow {
/// Extract a PostRow from the Jetstream / PDS event.
/// `handle` is not present in Jetstream events and resolving it would
/// require a PLC directory lookup; we store an empty placeholder and
/// expect a future handle-sync job to backfill it.
///
/// `embed` is captured verbatim from the record — the UI renders it
/// by sniffing for `$type` (`app.bsky.embed.images` / `.external` /
/// `.record`). Keeping it as raw JSON means we don't have to mirror
/// every embed variant in Rust.
///
/// `pds_handle` is the optional handle forwarded by the PDS through
/// the `/internal/ingest-commit` payload. Local-PDS users have
/// `did:key:` DIDs that no PLC directory can resolve, so the PDS is
/// the only authoritative source for their handle. Pass `Some(handle)`
/// when you have it; pass `None` (e.g. Jetstream path) and the
/// `upsert_post` COALESCE guard ensures the empty value won't
/// clobber a backfilled handle from `firehose::handle_identity`.
pub fn from_record(
did: &str,
rkey: &str,
collection: &str,
cid: &str,
record: &Value,
pds_handle: Option<&str>,
) -> Self {
let text = record
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let created_at = parse_created_at(
record.get("createdAt").and_then(|v| v.as_str()),
);
let reply = record.get("reply");
let parent_uri = reply
.and_then(|r| r.get("parent"))
.and_then(|p| p.get("uri"))
.and_then(|u| u.as_str())
.map(str::to_string);
let root_uri = reply
.and_then(|r| r.get("root"))
.and_then(|p| p.get("uri"))
.and_then(|u| u.as_str())
.map(str::to_string);
let embed = record.get("embed").cloned().filter(|v| !v.is_null());
let langs = record
.get("langs")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect::<Vec<_>>()
});
let uri = format!("at://{did}/{collection}/{rkey}");
let handle = pds_handle
.map(|h| h.trim().to_string())
.filter(|h| !h.is_empty())
.unwrap_or_default();
Self {
uri,
did: did.to_string(),
handle,
rkey: rkey.to_string(),
collection: collection.to_string(),
text,
cid: cid.to_string(),
parent_uri,
root_uri,
embed,
langs,
created_at,
// Avatar CID is populated later by the upsert path via a
// `SELECT avatar_cid FROM profiles WHERE did = $1` lookup,
// so a freshly indexed post starts at None. (The lookup
// happens in `upsert_post_with_avatar` below.)
avatar_cid: None,
}
}
}
/// Insert or update a post row keyed by URI. Idempotent.
///
/// `row.avatar_cid` is filled in-place with the current profile-avatar
/// CID for the row's author (from the `profiles` cache, NULL if the
/// profile hasn't been pushed yet). The ON CONFLICT clause uses
/// `COALESCE(EXCLUDED, posts)` so a backfill on a re-indexed post
/// won't overwrite an avatar we already had.
///
/// IMPORTANT: `indexed_at` is NOT touched on conflict. We deliberately
/// preserve the original insert time so the `(indexed_at, uri)` keyset
/// pagination order is stable across Jetstream replays / PDS re-syncs.
pub async fn upsert_post(db: &PgPool, row: &mut PostRow) -> Result<()> {
if row.avatar_cid.is_none() {
// Look up the latest avatar CID for this author from the
// profiles cache (populated by the PDS push path on profile
// updates). NULL if the profile hasn't been ingested yet —
// the post will display as the initial-letter avatar until
// the user uploads one.
row.avatar_cid = sqlx::query_scalar::<_, Option<String>>(
"SELECT avatar_cid FROM profiles WHERE did = $1",
)
.bind(&row.did)
.fetch_optional(db)
.await?
.flatten();
}
sqlx::query(
r#"INSERT INTO posts
(uri, did, handle, rkey, collection, text, cid,
parent_uri, root_uri, embed, langs, created_at,
avatar_cid)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
ON CONFLICT (uri) DO UPDATE SET
text = EXCLUDED.text,
cid = EXCLUDED.cid,
handle = COALESCE(NULLIF(EXCLUDED.handle,''), posts.handle),
parent_uri = EXCLUDED.parent_uri,
root_uri = EXCLUDED.root_uri,
embed = EXCLUDED.embed,
langs = EXCLUDED.langs,
created_at = EXCLUDED.created_at,
avatar_cid = COALESCE(EXCLUDED.avatar_cid, posts.avatar_cid)"#,
)
.bind(&row.uri)
.bind(&row.did)
.bind(&row.handle)
.bind(&row.rkey)
.bind(&row.collection)
.bind(&row.text)
.bind(&row.cid)
.bind(&row.parent_uri)
.bind(&row.root_uri)
.bind(EmbedColumn(row.embed.clone()))
.bind(&row.langs)
.bind(row.created_at)
.bind(&row.avatar_cid)
.execute(db)
.await?;
// Reply notification. A post with a `parent_uri` is a reply, so the
// parent's author gets a "someone replied to you" row.
//
// `subject_uri` is the REPLY's own URI, not the parent's: the
// notification list hydrates `subject_uri`'s text, and what the
// recipient wants to read is what the replier wrote — they already
// know the content of their own post. It also makes the row a
// direct link target for "open this reply in the thread view".
//
// The dedupe index keys on the reply URI, so re-indexing (or an
// edit that re-runs the upsert) can't produce a second row.
if let Some(parent_uri) = row.parent_uri.as_deref() {
if let Some(recipient) = post_author_did(db, parent_uri).await? {
record_notification(
db,
&recipient,
&row.did,
NOTIF_REPLY,
Some(&row.uri),
row.created_at,
)
.await?;
}
}
Ok(())
}
/// Delete a post by URI. Idempotent (returns Ok even if the row is gone).
pub async fn delete_post(db: &PgPool, uri: &str) -> Result<()> {
sqlx::query("DELETE FROM posts WHERE uri = $1")
.bind(uri)
.execute(db)
.await?;
Ok(())
}
// -- likes -----------------------------------------------------------------
/// Insert or ignore a like row keyed by URI. The partial unique index
/// on `(did, post_uri)` lets us reject double-likes at the DB level
/// instead of relying on caller discipline. We also maintain the
/// denormalized `posts.like_count` so the post endpoint doesn't have
/// to `COUNT(*)` over the entire `likes` table on every read.
pub async fn upsert_like(
db: &PgPool,
did: &str,
rkey: &str,
cid: Option<&str>,
record: Option<&Value>,
) -> Result<()> {
let uri = format!("at://{did}/app.bsky.feed.like/{rkey}");
let _cid = cid.unwrap_or("");
let post_uri = record
.and_then(|r| r.get("subject"))
.and_then(|s| s.get("uri"))
.and_then(|u| u.as_str())
.unwrap_or("")
.to_string();
let post_cid = record
.and_then(|r| r.get("subject"))
.and_then(|s| s.get("cid"))
.and_then(|u| u.as_str())
.unwrap_or("")
.to_string();
let created_at = parse_created_at(
record.and_then(|r| r.get("createdAt")).and_then(|v| v.as_str()),
);
let mut tx = db.begin().await?;
let inserted = sqlx::query(
r#"INSERT INTO likes (uri, did, post_uri, post_cid, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (did, post_uri) DO NOTHING
RETURNING uri"#,
)
.bind(&uri)
.bind(did)
.bind(&post_uri)
.bind(&post_cid)
.bind(created_at)
.fetch_optional(&mut *tx)
.await?;
if inserted.is_some() && !post_uri.is_empty() {
// Increment the denormalized counter so the post endpoint can
// serve like_count from `posts` without scanning `likes`.
sqlx::query(
"UPDATE posts SET like_count = like_count + 1 WHERE uri = $1",
)
.bind(&post_uri)
.execute(&mut *tx)
.await?;
// Notify the post's author. Runs inside the same transaction as
// the counter bump so a crash can't leave a like counted but
// un-notified (or vice versa). `post_author_did` returns None
// for a post we haven't indexed — then there's nobody to
// notify and we quietly skip.
if let Some(recipient) = post_author_did(&mut *tx, &post_uri).await? {
record_notification(
&mut *tx,
&recipient,
did,
NOTIF_LIKE,
Some(&post_uri),
created_at,
)
.await?;
}
}
tx.commit().await?;
Ok(())
}
pub async fn delete_like(db: &PgPool, did: &str, rkey: &str) -> Result<()> {
let uri = format!("at://{did}/app.bsky.feed.like/{rkey}");
let mut tx = db.begin().await?;
let row: Option<(String,)> = sqlx::query_as(
"DELETE FROM likes WHERE uri = $1 RETURNING post_uri",
)
.bind(&uri)
.fetch_optional(&mut *tx)
.await?;
if let Some((post_uri,)) = row {
if !post_uri.is_empty() {
sqlx::query(
"UPDATE posts SET like_count = GREATEST(like_count - 1, 0) WHERE uri = $1",
)
.bind(&post_uri)
.execute(&mut *tx)
.await?;
}
}
tx.commit().await?;
Ok(())
}
// -- reposts ---------------------------------------------------------------
pub async fn upsert_repost(
db: &PgPool,
did: &str,
rkey: &str,
cid: Option<&str>,
record: Option<&Value>,
) -> Result<()> {
let uri = format!("at://{did}/app.bsky.feed.repost/{rkey}");
let _cid = cid.unwrap_or("");
let post_uri = record
.and_then(|r| r.get("subject"))
.and_then(|s| s.get("uri"))
.and_then(|u| u.as_str())
.unwrap_or("")
.to_string();
let post_cid = record
.and_then(|r| r.get("subject"))
.and_then(|s| s.get("cid"))
.and_then(|u| u.as_str())
.unwrap_or("")
.to_string();
let created_at = parse_created_at(
record.and_then(|r| r.get("createdAt")).and_then(|v| v.as_str()),
);
let mut tx = db.begin().await?;
let inserted = sqlx::query(
r#"INSERT INTO reposts (uri, did, post_uri, post_cid, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (did, post_uri) DO NOTHING
RETURNING uri"#,
)
.bind(&uri)
.bind(did)
.bind(&post_uri)
.bind(&post_cid)
.bind(created_at)
.fetch_optional(&mut *tx)
.await?;
if inserted.is_some() && !post_uri.is_empty() {
sqlx::query(
"UPDATE posts SET repost_count = repost_count + 1 WHERE uri = $1",
)
.bind(&post_uri)
.execute(&mut *tx)
.await?;
// Same transactional notification write as the like path — see
// `upsert_like` for the rationale.
if let Some(recipient) = post_author_did(&mut *tx, &post_uri).await? {
record_notification(
&mut *tx,
&recipient,
did,
NOTIF_REPOST,
Some(&post_uri),
created_at,
)
.await?;
}
}
tx.commit().await?;
Ok(())
}
pub async fn delete_repost(db: &PgPool, did: &str, rkey: &str) -> Result<()> {
let uri = format!("at://{did}/app.bsky.feed.repost/{rkey}");
let mut tx = db.begin().await?;
let row: Option<(String,)> = sqlx::query_as(
"DELETE FROM reposts WHERE uri = $1 RETURNING post_uri",
)
.bind(&uri)
.fetch_optional(&mut *tx)
.await?;
if let Some((post_uri,)) = row {
if !post_uri.is_empty() {
sqlx::query(
"UPDATE posts SET repost_count = GREATEST(repost_count - 1, 0) WHERE uri = $1",
)
.bind(&post_uri)
.execute(&mut *tx)
.await?;
}
}
tx.commit().await?;
Ok(())
}
// -- notifications ---------------------------------------------------------
/// The four notification kinds the AppView produces. Kept as `&str`
/// constants rather than an enum because the value is a plain `TEXT`
/// column guarded by a CHECK constraint (migration 0008) and every
/// call site is a literal — an enum would only add a `to_string()`.
pub const NOTIF_LIKE: &str = "like";
pub const NOTIF_REPOST: &str = "repost";
pub const NOTIF_FOLLOW: &str = "follow";
pub const NOTIF_REPLY: &str = "reply";
/// Should an interaction by `author_did` aimed at `recipient_did`
/// produce a notification row?
///
/// Pure so it can be unit-tested without a database. Two rules:
///
/// 1. **No self-interactions.** Liking your own post, reposting
/// yourself, self-following or replying to yourself must stay
/// silent — the user already knows they did it, and a "you liked
/// your own post" row is pure noise.
/// 2. **No empty DIDs.** An empty recipient means we failed to resolve
/// the target (e.g. a like against a post that isn't in our index),
/// and an empty author means the event was malformed. Either way
/// the row would be unattributable in the UI.
///
/// The same two rules are ALSO enforced in SQL inside
/// [`record_notification`], so a caller that forgets this guard still
/// can't write a bad row — this function exists to skip the round trip
/// in the common self-interaction case and to make the rule testable.
pub fn should_notify(recipient_did: &str, author_did: &str) -> bool {
!recipient_did.is_empty() && !author_did.is_empty() && recipient_did != author_did
}
/// Insert one notification row, if and only if it is warranted.
///
/// Returns `Ok(true)` when a row was actually written, `Ok(false)` when
/// the write was skipped — either because the interaction failed
/// [`should_notify`], because the recipient isn't a user this AppView
/// serves, or because the same notification already exists.
///
/// **"Local user"**: the AppView deliberately has no `users` table —
/// the PDS owns account state. The closest thing we have is "a DID this
/// AppView knows about", i.e. one with a row in the `profiles` cache
/// (written by the PDS profile push / the Jetstream `app.bsky.actor.profile`
/// arm) or at least one indexed post. That is exactly the set of users
/// a client could ever poll notifications for, so restricting writes to
/// it keeps us from materialising a notification row for every like on
/// the entire public firehose while never dropping one a local user
/// would actually see.
///
/// **Idempotency**: the `ON CONFLICT` target is the expression index
/// `notifications_dedupe_idx` from migration 0008, keyed on
/// `(recipient_did, author_did, kind, COALESCE(subject_uri, ''))`. The
/// `COALESCE` is load-bearing: a plain unique index would let two
/// `follow` rows (whose `subject_uri` is NULL) coexist, because SQL
/// NULLs never collide. Re-indexing the same Jetstream event — on a
/// reconnect replay, or via the PDS `/internal/ingest-commit` push that
/// races the firehose — is therefore a no-op.
///
/// The executor is generic so this can run either on the pool (the
/// reply path) or inside the caller's transaction (the like / repost
/// paths, where the notification must commit atomically with the
/// counter bump).
pub async fn record_notification<'e, E>(
exec: E,
recipient_did: &str,
author_did: &str,
kind: &str,
subject_uri: Option<&str>,
created_at: chrono::DateTime<chrono::Utc>,
) -> Result<bool>
where
E: sqlx::PgExecutor<'e>,
{
if !should_notify(recipient_did, author_did) {
return Ok(false);
}
// One statement, so there's no TOCTOU window between "is the
// recipient local?" and "insert". Every parameter is explicitly
// cast because `INSERT ... SELECT $1, $2, ...` gives Postgres no
// column context to infer the placeholder types from.
let res = sqlx::query(
r#"INSERT INTO notifications
(recipient_did, author_did, kind, subject_uri, created_at)
SELECT $1::text, $2::text, $3::text, $4::text, $5::timestamptz
WHERE $1::text <> $2::text
AND $1::text <> ''
AND $2::text <> ''
AND (EXISTS (SELECT 1 FROM profiles WHERE did = $1::text)
OR EXISTS (SELECT 1 FROM posts WHERE did = $1::text))
ON CONFLICT (recipient_did, author_did, kind, COALESCE(subject_uri, ''))
DO NOTHING"#,
)
.bind(recipient_did)
.bind(author_did)
.bind(kind)
.bind(subject_uri)
.bind(created_at)
.execute(exec)
.await?;
Ok(res.rows_affected() > 0)
}
/// Look up the author DID of an indexed post. `None` when the post
/// isn't in our index — which is the normal case for a like/reply
/// aimed at a post hosted somewhere we don't follow. The caller then
/// simply skips the notification rather than guessing a recipient.
async fn post_author_did<'e, E>(exec: E, uri: &str) -> Result<Option<String>>
where
E: sqlx::PgExecutor<'e>,
{
let did: Option<String> = sqlx::query_scalar("SELECT did FROM posts WHERE uri = $1")
.bind(uri)
.fetch_optional(exec)
.await?;
Ok(did)
}
// -- follows ---------------------------------------------------------------
pub async fn upsert_follow(
db: &PgPool,
follower_did: &str,
subject_did: &str,
record: Option<&Value>,
) -> Result<()> {
let created_at = parse_created_at(
record.and_then(|r| r.get("createdAt")).and_then(|v| v.as_str()),
);
sqlx::query(
r#"INSERT INTO follows (follower_did, subject_did, created_at)
VALUES ($1, $2, $3)
ON CONFLICT (follower_did, subject_did) DO UPDATE SET
created_at = EXCLUDED.created_at,
indexed_at = now()"#,
)
.bind(follower_did)
.bind(subject_did)
.bind(created_at)
.execute(db)
.await?;
// Notify the followed user. `subject_uri` is NULL — a follow isn't
// about a post — which is exactly the case the dedupe index's
// `COALESCE(subject_uri, '')` exists for. Not wrapped in a
// transaction with the follow upsert: the follow row is the source
// of truth and a missed notification is recoverable noise, whereas
// taking a transaction here would serialise every follow write.
record_notification(
db,
subject_did,
follower_did,
NOTIF_FOLLOW,
None,
created_at,
)
.await?;
Ok(())
}
pub async fn delete_follow(
db: &PgPool,
follower_did: &str,
subject_did: &str,
) -> Result<()> {
sqlx::query(
"DELETE FROM follows WHERE follower_did = $1 AND subject_did = $2",
)
.bind(follower_did)
.bind(subject_did)
.execute(db)
.await?;
Ok(())
}
/// Extract the subject DID from a follow record (`{ "subject": "did:..."}`).
pub fn follow_subject_did(record: Option<&Value>) -> Option<String> {
record?
.get("subject")?
.as_str()
.map(|s| s.to_string())
}
// -- test harness ----------------------------------------------------------
#[cfg(test)]
async fn try_test_db() -> Option<PgPool> {
let url = std::env::var("DATABASE_URL_APPVIEW").ok()?;
match timeout(Duration::from_secs(2), sqlx::PgPool::connect(&url)).await {
Ok(Ok(pool)) => match sqlx::migrate!("../../migrations/appview")
.run(&pool)
.await
{
Ok(()) => Some(pool),
Err(_) => None,
},
_ => None,
}
}
/// Route a `JetstreamEvent::kind == "commit"` event to the right upsert /
/// delete function. Returns `Ok(false)` if the event was recognized but
/// not actionable (e.g. unsupported collection), `Ok(true)` if it was
/// applied, `Err(_)` if DB write failed.
pub async fn apply_commit(
db: &PgPool,
ev: &JetstreamEvent,
) -> Result<bool> {
let commit = match &ev.commit {
Some(c) => c,
None => return Ok(false),
};
let collection = match extract_collection(commit) {
Some(c) => c,
None => return Ok(false),
};
let ops = commit_op_from_jetstream_value(commit);
if ops.is_empty() {
return Ok(false);
}
let mut applied = false;
for op in &ops {
match collection.as_str() {
"app.twi.post" | "app.bsky.feed.post" => {
if op.action == "create" {
let rkey = op
.rkey
.clone()
.or_else(|| {
op.path
.as_deref()
.and_then(|p| p.rsplit('/').next().map(str::to_string))
})
.ok_or_else(|| {
anyhow!("create op missing rkey for post")
})?;
let cid = op.cid.clone().unwrap_or_default();
let record = op.record.clone().unwrap_or(Value::Null);
// Jetstream `commit` events don't carry the
// handle — leave it empty so the upsert
// COALESCE guard preserves the row's existing
// (or backfilled-from-identity) handle.
let mut row = PostRow::from_record(
&ev.did,
&rkey,
&collection,
&cid,
&record,
None,
);
upsert_post(db, &mut row).await?;
applied = true;
} else if op.action == "delete" {
let rkey = op
.rkey
.clone()
.or_else(|| {
op.path
.as_deref()
.and_then(|p| p.rsplit('/').next().map(str::to_string))
})
.ok_or_else(|| {
anyhow!("delete op missing rkey for post")
})?;
let uri = format!(
"at://{}/{}/{}",
ev.did, collection, rkey
);
delete_post(db, &uri).await?;
applied = true;
}
}
"app.bsky.feed.like" => {
let rkey = op
.rkey
.clone()
.or_else(|| {
op.path
.as_deref()
.and_then(|p| p.rsplit('/').next().map(str::to_string))
});
let rkey = match rkey {
Some(r) => r,
None => {
tracing::warn!("like op missing rkey; skipping");
continue;
}
};
if op.action == "create" {
upsert_like(
db,
&ev.did,
&rkey,
op.cid.as_deref(),
op.record.as_ref(),
)
.await?;
} else if op.action == "delete" {
delete_like(db, &ev.did, &rkey).await?;
}
applied = true;
}
"app.bsky.feed.repost" => {
let rkey = op
.rkey
.clone()
.or_else(|| {
op.path
.as_deref()
.and_then(|p| p.rsplit('/').next().map(str::to_string))
});
let rkey = match rkey {
Some(r) => r,
None => {
tracing::warn!("repost op missing rkey; skipping");
continue;
}
};
if op.action == "create" {
upsert_repost(
db,
&ev.did,
&rkey,
op.cid.as_deref(),
op.record.as_ref(),
)
.await?;
} else if op.action == "delete" {
delete_repost(db, &ev.did, &rkey).await?;
}
applied = true;
}
"app.bsky.graph.follow" => {
let subject_did = match op.action.as_str() {
"create" => match follow_subject_did(op.record.as_ref()) {
Some(s) => s,
None => {
tracing::warn!(
"follow create missing subject; skipping"
);
continue;
}
},
"delete" => {
// Jetstream delete on follows carries no record
// value, so we can't know which subject was
// unfollowed. The PDS-driven internal ingest path
// handles this — it knows the subject from its
// own snapshot.
tracing::warn!(
"follow delete via Jetstream lacks subject; \
route through /internal/ingest-commit instead"
);
continue;
}
_ => continue,
};
if op.action == "create" {
upsert_follow(
db,
&ev.did,
&subject_did,
op.record.as_ref(),
)
.await?;
} else if op.action == "delete" {
delete_follow(db, &ev.did, &subject_did).await?;
}
applied = true;
}
"app.bsky.actor.profile" => {
// Jetstream carries profile records as plain
// commit ops (no separate collection). We treat
// any rkey — usually `self`, but spec allows
// rkey-rotation — as the user's authoritative
// profile and upsert into the `profiles` cache.
//
// The Jetstream `commit` envelope doesn't carry
// the handle; we look it up from the `posts`
// table (backfilled there by the `identity`
// event stream). Empty is fine — the next
// handle_sync pass will populate it.
if op.action == "create" {
let record = match op.record.clone() {
Some(r) if !r.is_null() => r,
_ => continue,
};
let handle: String = sqlx::query_scalar(
"SELECT handle FROM posts \
WHERE did = $1 AND handle <> '' \
ORDER BY indexed_at DESC LIMIT 1",
)
.bind(&ev.did)
.fetch_optional(db)
.await?
.unwrap_or_default();
upsert_profile(db, &ev.did, &handle, &record).await?;
applied = true;
} else if op.action == "delete" {
sqlx::query("DELETE FROM profiles WHERE did = $1")
.bind(&ev.did)
.execute(db)
.await?;
applied = true;
}
}
_ => {
// Unrecognised collection — ignore (may happen when Jetstream
// sends something we didn't subscribe to).
}
}
}
Ok(applied)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn commit_op_from_jetstream_value_single_create() {
let commit = json!({
"operation": "create",
"collection": "app.bsky.feed.post",
"rkey": "3k2abc",
"cid": "bafyreicid",
"record": {"text": "hi", "createdAt": "2026-07-01T12:00:00Z"}
});
let ops = commit_op_from_jetstream_value(&commit);
assert_eq!(ops.len(), 1);
assert_eq!(ops[0].action, "create");
assert_eq!(ops[0].rkey.as_deref(), Some("3k2abc"));
assert_eq!(ops[0].cid.as_deref(), Some("bafyreicid"));
assert_eq!(
ops[0].record.as_ref().unwrap()["text"].as_str(),
Some("hi")
);
}
#[test]
fn commit_op_from_jetstream_value_batched_ops() {
let commit = json!({
"ops": [
{"action": "create", "collection": "app.bsky.feed.like", "rkey": "1",
"cid": "c1", "record": {"subject": {"uri": "at://x/y/z", "cid": "cz"},
"createdAt": "2026-07-01T12:00:00Z"}},
{"action": "delete", "collection": "app.bsky.feed.like", "rkey": "0"}
]
});
let ops = commit_op_from_jetstream_value(&commit);
assert_eq!(ops.len(), 2);
assert_eq!(ops[0].action, "create");
assert_eq!(ops[0].rkey.as_deref(), Some("1"));
assert_eq!(ops[1].action, "delete");
assert_eq!(ops[1].rkey.as_deref(), Some("0"));
}
#[test]
fn commit_op_from_jetstream_value_missing_action_returns_empty() {
let commit = json!({ "collection": "app.bsky.feed.post", "rkey": "x" });
let ops = commit_op_from_jetstream_value(&commit);
assert!(ops.is_empty());
}
#[test]
fn extract_collection_top_level_and_batched() {
let top = json!({"operation":"create","collection":"app.twi.post","rkey":"r"});
assert_eq!(extract_collection(&top).as_deref(), Some("app.twi.post"));
let batched = json!({"ops": [{"collection":"app.bsky.feed.like"}]});
assert_eq!(
extract_collection(&batched).as_deref(),
Some("app.bsky.feed.like")
);
let none = json!({});
assert!(extract_collection(&none).is_none());
}
#[test]
fn build_uri_uses_rkey_or_path() {
let op_rkey = CommitOp {
action: "create".into(),
rkey: Some("rk".into()),
path: None,
cid: None,
record: None,
};
assert_eq!(
build_uri("did:plc:a", "app.twi.post", &op_rkey).as_deref(),
Some("at://did:plc:a/app.twi.post/rk")
);
let op_path = CommitOp {
action: "delete".into(),
rkey: None,
path: Some("app.twi.post/last".into()),
cid: None,
record: None,
};
assert_eq!(
build_uri("did:plc:a", "app.twi.post", &op_path).as_deref(),
Some("at://did:plc:a/app.twi.post/last")
);
}
#[test]
fn follow_subject_did_extracts() {
let rec = json!({ "subject": "did:plc:b", "createdAt": "2026-01-01T00:00:00Z" });
assert_eq!(
follow_subject_did(Some(&rec)).as_deref(),
Some("did:plc:b")
);
assert!(follow_subject_did(None).is_none());
}
#[test]
fn from_record_captures_embed() {
let rec = json!({
"text": "look at this",
"createdAt": "2026-07-01T12:00:00Z",
"embed": {
"$type": "app.bsky.embed.images",
"images": [
{"alt": "a cat", "image": {"$type": "blob", "ref": {"$link": "bafy"}}}
]
}
});
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec, None);
let embed = row.embed.expect("embed must be captured");
assert_eq!(embed["$type"], "app.bsky.embed.images");
assert_eq!(embed["images"][0]["alt"], "a cat");
}
#[test]
fn from_record_captures_external_embed() {
let rec = json!({
"text": "see link",
"createdAt": "2026-07-01T12:00:00Z",
"embed": {
"$type": "app.bsky.embed.external",
"external": {
"uri": "https://example.com",
"title": "Example",
"description": "An example"
}
}
});
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec, None);
let embed = row.embed.expect("embed must be captured");
assert_eq!(embed["$type"], "app.bsky.embed.external");
assert_eq!(embed["external"]["uri"], "https://example.com");
}
#[test]
fn from_record_omits_embed_when_missing() {
let rec = json!({
"text": "no embed here",
"createdAt": "2026-07-01T12:00:00Z"
});
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec, None);
assert!(row.embed.is_none());
}
#[test]
fn from_record_captures_reply_uris() {
let rec = json!({
"text": "a reply",
"createdAt": "2026-07-01T12:00:00Z",
"reply": {
"parent": {"uri": "at://did:plc:b/app.twi.post/p", "cid": "cp"},
"root": {"uri": "at://did:plc:b/app.twi.post/r", "cid": "cr"}
}
});
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec, None);
assert_eq!(row.parent_uri.as_deref(), Some("at://did:plc:b/app.twi.post/p"));
assert_eq!(row.root_uri.as_deref(), Some("at://did:plc:b/app.twi.post/r"));
}
fn fake_event(collection: &str, action: &str, rkey: &str) -> JetstreamEvent {
let commit = if action == "create" {
json!({
"operation": action,
"collection": collection,
"rkey": rkey,
"cid": "bafyreicid",
"record": {
"text": "hi there",
"createdAt": "2026-07-01T12:00:00Z"
}
})
} else {
json!({
"operation": action,
"collection": collection,
"rkey": rkey
})
};
JetstreamEvent {
did: "did:plc:test".into(),
time_us: 1_700_000_000_000_000,
kind: "commit".into(),
commit: Some(commit),
identity: None,
account: None,
}
}
#[tokio::test]
async fn upsert_and_delete_post_round_trip() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
// Clean any previous row in this test's slot.
let _ = sqlx::query("DELETE FROM posts WHERE did = 'did:plc:test'")
.execute(&db)
.await
.unwrap();
let ev = fake_event("app.twi.post", "create", "abc123");
let applied = apply_commit(&db, &ev).await.unwrap();
assert!(applied);
let (uri, text): (String, String) = sqlx::query_as(
"SELECT uri, text FROM posts WHERE uri = $1",
)
.bind("at://did:plc:test/app.twi.post/abc123")
.fetch_one(&db)
.await
.unwrap();
assert_eq!(uri, "at://did:plc:test/app.twi.post/abc123");
assert_eq!(text, "hi there");
// Verify that the (currently null) embed column is readable —
// we want to fail loud if the column is missing from the
// schema rather than silently falling back to text-only posts.
let embed_json: Option<serde_json::Value> = sqlx::query_scalar(
"SELECT embed FROM posts WHERE uri = $1",
)
.bind("at://did:plc:test/app.twi.post/abc123")
.fetch_one(&db)
.await
.unwrap();
assert!(
embed_json.is_none(),
"embed should be null for plain-text posts, got: {embed_json:?}"
);
// Apply again — must be idempotent (no error, still one row).
let _ = apply_commit(&db, &ev).await.unwrap();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM posts WHERE uri = $1",
)
.bind("at://did:plc:test/app.twi.post/abc123")
.fetch_one(&db)
.await
.unwrap();
assert_eq!(count, 1);
// Delete it.
let del = fake_event("app.twi.post", "delete", "abc123");
apply_commit(&db, &del).await.unwrap();
let remaining: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM posts WHERE uri = $1",
)
.bind("at://did:plc:test/app.twi.post/abc123")
.fetch_one(&db)
.await
.unwrap();
assert_eq!(remaining, 0);
}
/// Seed a post with an `app.bsky.embed.images` embed and confirm
/// the JSONB column round-trips the embed object back as JSON.
#[tokio::test]
async fn upsert_post_stores_embed_jsonb() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
// Clean any previous row in this test's slot.
let _ = sqlx::query("DELETE FROM posts WHERE did = 'did:plc:embed'")
.execute(&db)
.await
.unwrap();
let record = json!({
"text": "with an image",
"createdAt": "2026-07-01T12:00:00Z",
"embed": {
"$type": "app.bsky.embed.images",
"images": [
{"alt": "first alt", "image": {"$type": "blob", "ref": {"$link": "bafy1"}}},
{"alt": "second alt", "image": {"$type": "blob", "ref": {"$link": "bafy2"}}}
]
}
});
let mut row = PostRow::from_record(
"did:plc:embed",
"embedkey",
"app.twi.post",
"cid-embed",
&record,
None,
);
upsert_post(&db, &mut row).await.unwrap();
let embed: serde_json::Value = sqlx::query_scalar(
"SELECT embed FROM posts WHERE uri = $1",
)
.bind("at://did:plc:embed/app.twi.post/embedkey")
.fetch_one(&db)
.await
.unwrap();
assert_eq!(embed["$type"], "app.bsky.embed.images");
let imgs = embed["images"].as_array().expect("images array");
assert_eq!(imgs.len(), 2);
assert_eq!(imgs[0]["alt"], "first alt");
assert_eq!(imgs[1]["alt"], "second alt");
// Cleanup.
let _ = sqlx::query("DELETE FROM posts WHERE did = 'did:plc:embed'")
.execute(&db)
.await;
}
#[tokio::test]
async fn upsert_and_delete_follow_round_trip() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
// Wipe test data so the test is order-independent.
let _ = sqlx::query(
"DELETE FROM follows WHERE follower_did = $1 AND subject_did = $2",
)
.bind("did:plc:test")
.bind("did:plc:b")
.execute(&db)
.await
.unwrap();
let ev = JetstreamEvent {
did: "did:plc:test".into(),
time_us: 1_700_000_000_000_000,
kind: "commit".into(),
commit: Some(json!({
"operation": "create",
"collection": "app.bsky.graph.follow",
"rkey": "frk1",
"cid": "bafyfollow",
"record": {
"subject": "did:plc:b",
"createdAt": "2026-01-01T00:00:00Z"
}
})),
identity: None,
account: None,
};
apply_commit(&db, &ev).await.unwrap();
let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM follows WHERE follower_did = $1 AND subject_did = $2",
)
.bind("did:plc:test")
.bind("did:plc:b")
.fetch_one(&db)
.await
.unwrap();
assert_eq!(count, 1);
// Idempotent re-apply.
apply_commit(&db, &ev).await.unwrap();
let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM follows WHERE follower_did = $1 AND subject_did = $2",
)
.bind("did:plc:test")
.bind("did:plc:b")
.fetch_one(&db)
.await
.unwrap();
assert_eq!(count, 1);
// Delete via the internal API (not via Jetstream — Jetstream
// delete on follows doesn't carry the subject).
delete_follow(&db, "did:plc:test", "did:plc:b").await.unwrap();
let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM follows WHERE follower_did = $1 AND subject_did = $2",
)
.bind("did:plc:test")
.bind("did:plc:b")
.fetch_one(&db)
.await
.unwrap();
assert_eq!(count, 0);
}
}
#[cfg(test)]
mod notification_tests {
use super::*;
use serde_json::json;
fn did(tag: &str) -> String {
format!("did:plc:notif_{}_{}", tag, uuid::Uuid::new_v4().simple())
}
/// Seed one post so `did` counts as a user this AppView knows
/// about (see [`record_notification`]'s "local user" note) and so
/// there's something to like / reply to.
///
/// The `handle` is deliberately non-empty. `handle_sync`'s tests
/// assert exact counts over a *global* scan of empty-handle rows,
/// so a fixture that left the column blank would silently break
/// them whenever both suites share a database.
async fn seed_post(db: &PgPool, author: &str, rkey: &str) -> String {
let uri = format!("at://{author}/app.twi.post/{rkey}");
sqlx::query(
r#"INSERT INTO posts
(uri, did, handle, rkey, collection, text, cid,
parent_uri, root_uri, langs, created_at)
VALUES ($1,$2,'notif-fixture.test','x','app.twi.post','seed','bafy',
NULL,NULL,NULL, now())
ON CONFLICT (uri) DO NOTHING"#,
)
.bind(&uri)
.bind(author)
.execute(db)
.await
.unwrap();
uri
}
async fn count_notifications(db: &PgPool, recipient: &str, kind: &str) -> i64 {
sqlx::query_scalar(
"SELECT COUNT(*)::BIGINT FROM notifications \
WHERE recipient_did = $1 AND kind = $2",
)
.bind(recipient)
.bind(kind)
.fetch_one(db)
.await
.unwrap()
}
async fn cleanup(db: &PgPool, dids: &[&str]) {
for d in dids {
let _ = sqlx::query(
"DELETE FROM notifications WHERE recipient_did = $1 OR author_did = $1",
)
.bind(d)
.execute(db)
.await;
let _ = sqlx::query("DELETE FROM likes WHERE did = $1")
.bind(d)
.execute(db)
.await;
let _ = sqlx::query("DELETE FROM reposts WHERE did = $1")
.bind(d)
.execute(db)
.await;
let _ = sqlx::query("DELETE FROM follows WHERE follower_did = $1 OR subject_did = $1")
.bind(d)
.execute(db)
.await;
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(d)
.execute(db)
.await;
}
}
#[test]
fn should_notify_rejects_self_and_empty() {
assert!(should_notify("did:plc:a", "did:plc:b"));
// Self-interaction: liking / replying to / following yourself.
assert!(!should_notify("did:plc:a", "did:plc:a"));
// Unresolvable ends of the edge.
assert!(!should_notify("", "did:plc:b"));
assert!(!should_notify("did:plc:a", ""));
assert!(!should_notify("", ""));
// Case matters — DIDs are compared verbatim, never folded.
assert!(should_notify("did:plc:A", "did:plc:a"));
}
/// A like by someone else must produce exactly one notification,
/// and re-indexing the same event (Jetstream replay racing the PDS
/// push) must not produce a second.
#[tokio::test]
async fn like_notifies_author_once() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let author = did("author");
let liker = did("liker");
// The liker also needs to exist for nothing in particular —
// only the *recipient* is checked — but seeding both keeps the
// fixture symmetric with reality.
let post_uri = seed_post(&db, &author, "p1").await;
seed_post(&db, &liker, "p1").await;
let record = json!({
"subject": { "uri": post_uri, "cid": "bafysubject" },
"createdAt": "2026-07-01T12:00:00Z"
});
upsert_like(&db, &liker, "lrk1", Some("bafylike"), Some(&record))
.await
.unwrap();
assert_eq!(count_notifications(&db, &author, NOTIF_LIKE).await, 1);
// Replay the exact same event.
upsert_like(&db, &liker, "lrk1", Some("bafylike"), Some(&record))
.await
.unwrap();
assert_eq!(
count_notifications(&db, &author, NOTIF_LIKE).await,
1,
"replayed like must not duplicate the notification"
);
// Unlike + re-like under a NEW rkey. The like row is recreated
// but the notification tuple is unchanged, so the dedupe index
// suppresses it — see migration 0008's "Idempotency" note.
delete_like(&db, &liker, "lrk1").await.unwrap();
upsert_like(&db, &liker, "lrk2", Some("bafylike2"), Some(&record))
.await
.unwrap();
assert_eq!(
count_notifications(&db, &author, NOTIF_LIKE).await,
1,
"toggling a like must not be a notification-spam vector"
);
// The stored row must point at the liked post and name the
// liker as the author.
let row: (String, Option<String>) = sqlx::query_as(
"SELECT author_did, subject_uri FROM notifications \
WHERE recipient_did = $1 AND kind = $2",
)
.bind(&author)
.bind(NOTIF_LIKE)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(row.0, liker);
assert_eq!(row.1.as_deref(), Some(post_uri.as_str()));
// Unread by default — that's what /api/notifications/count sees.
let unread: i64 = sqlx::query_scalar(
"SELECT COUNT(*)::BIGINT FROM notifications \
WHERE recipient_did = $1 AND read_at IS NULL",
)
.bind(&author)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(unread, 1);
cleanup(&db, &[&author, &liker]).await;
}
/// Liking / reposting your own post is silent.
#[tokio::test]
async fn self_interaction_writes_no_notification() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let author = did("selfie");
let post_uri = seed_post(&db, &author, "p1").await;
let record = json!({
"subject": { "uri": post_uri, "cid": "bafysubject" },
"createdAt": "2026-07-01T12:00:00Z"
});
upsert_like(&db, &author, "lrk1", None, Some(&record))
.await
.unwrap();
upsert_repost(&db, &author, "rrk1", None, Some(&record))
.await
.unwrap();
// Self-follow is legal in the protocol; it must stay silent too.
upsert_follow(&db, &author, &author, None).await.unwrap();
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*)::BIGINT FROM notifications WHERE recipient_did = $1",
)
.bind(&author)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(total, 0, "self-interactions must not notify");
// The like/repost themselves still landed — the notification
// suppression must not swallow the interaction.
let likes: i64 = sqlx::query_scalar("SELECT COUNT(*)::BIGINT FROM likes WHERE did = $1")
.bind(&author)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(likes, 1);
cleanup(&db, &[&author]).await;
}
/// A repost notifies, and a reply notifies the *parent's* author
/// with the reply's own URI as the subject.
#[tokio::test]
async fn repost_and_reply_notify() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let author = did("parent");
let other = did("replier");
let parent_uri = seed_post(&db, &author, "p1").await;
seed_post(&db, &other, "p1").await;
let record = json!({
"subject": { "uri": parent_uri, "cid": "bafysubject" },
"createdAt": "2026-07-01T12:00:00Z"
});
upsert_repost(&db, &other, "rrk1", None, Some(&record))
.await
.unwrap();
assert_eq!(count_notifications(&db, &author, NOTIF_REPOST).await, 1);
// Now a reply from `other` to `author`'s post.
let reply_record = json!({
"text": "nice one",
"createdAt": "2026-07-01T12:05:00Z",
"reply": {
"parent": { "uri": parent_uri, "cid": "bafyparent" },
"root": { "uri": parent_uri, "cid": "bafyparent" }
}
});
let mut row = PostRow::from_record(
&other,
"replykey",
"app.twi.post",
"bafyreply",
&reply_record,
None,
);
let reply_uri = row.uri.clone();
upsert_post(&db, &mut row).await.unwrap();
let subject: Option<String> = sqlx::query_scalar(
"SELECT subject_uri FROM notifications \
WHERE recipient_did = $1 AND kind = $2",
)
.bind(&author)
.bind(NOTIF_REPLY)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(
subject.as_deref(),
Some(reply_uri.as_str()),
"a reply notification's subject is the REPLY, so the list can \
preview what was written"
);
// Re-indexing the reply must not duplicate.
upsert_post(&db, &mut row).await.unwrap();
assert_eq!(count_notifications(&db, &author, NOTIF_REPLY).await, 1);
cleanup(&db, &[&author, &other]).await;
}
/// A follow notifies the followed user with a NULL subject_uri —
/// the case the dedupe index's `COALESCE(subject_uri, '')` exists
/// for, since plain SQL NULLs never collide.
#[tokio::test]
async fn follow_notifies_subject_and_dedupes_on_null_subject() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let subject = did("followee");
let follower = did("follower");
seed_post(&db, &subject, "p1").await;
let record = json!({ "subject": subject, "createdAt": "2026-01-01T00:00:00Z" });
upsert_follow(&db, &follower, &subject, Some(&record))
.await
.unwrap();
upsert_follow(&db, &follower, &subject, Some(&record))
.await
.unwrap();
assert_eq!(
count_notifications(&db, &subject, NOTIF_FOLLOW).await,
1,
"two NULL-subject follow rows must collide, not coexist"
);
let subject_uri: Option<String> = sqlx::query_scalar(
"SELECT subject_uri FROM notifications \
WHERE recipient_did = $1 AND kind = $2",
)
.bind(&subject)
.bind(NOTIF_FOLLOW)
.fetch_one(&db)
.await
.unwrap();
assert!(subject_uri.is_none(), "a follow is not about a post");
cleanup(&db, &[&subject, &follower]).await;
}
/// A recipient the AppView has never seen (no profile row, no
/// posts) gets nothing — this is what keeps us from materialising
/// a row for every like on the public firehose.
#[tokio::test]
async fn unknown_recipient_is_skipped() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let stranger = did("stranger");
let author = did("author");
let wrote = record_notification(
&db,
&stranger,
&author,
NOTIF_FOLLOW,
None,
chrono::Utc::now(),
)
.await
.unwrap();
assert!(!wrote, "unknown recipient must not get a notification row");
// Give the recipient a post — now they're a user we serve.
seed_post(&db, &stranger, "p1").await;
let wrote = record_notification(
&db,
&stranger,
&author,
NOTIF_FOLLOW,
None,
chrono::Utc::now(),
)
.await
.unwrap();
assert!(wrote, "known recipient must get the row");
// And the second call is a no-op thanks to ON CONFLICT.
let wrote = record_notification(
&db,
&stranger,
&author,
NOTIF_FOLLOW,
None,
chrono::Utc::now(),
)
.await
.unwrap();
assert!(!wrote, "duplicate must report false, not error");
cleanup(&db, &[&stranger, &author]).await;
}
}
/// Backfill the `posts.handle` column for every row belonging to
/// `did`. Used by the Jetstream `identity` handler when Jetstream
/// tells us a DID's handle has changed — every existing post row
/// needs the new value.
///
/// **Race-safety**: this only writes if the row's current handle is
/// empty OR doesn't match, so concurrent PDS pushes (which carry
/// the same handle) and concurrent identity replays don't fight.
/// The `WHERE handle IS DISTINCT FROM $1` makes the update a
/// no-op when the value is already correct, which Postgres treats
/// cheaply.
///
/// Returns the number of rows updated.
pub async fn backfill_handle(
db: &PgPool,
did: &str,
new_handle: &str,
) -> Result<u64> {
let res = sqlx::query(
"UPDATE posts SET handle = $1 WHERE did = $2 AND handle IS DISTINCT FROM $1",
)
.bind(new_handle)
.bind(did)
.execute(db)
.await?;
Ok(res.rows_affected())
}
/// Upsert a `profiles` row for `did`. The caller has just ingested
/// the profile record body (decoded CBOR), and the denormalised
/// counts are computed here (a single `SELECT COUNT(*)` over each
/// side-table — cheap with the existing PK indexes on `posts.did` and
/// `follows.{follower,subject}_did`).
pub async fn upsert_profile(
db: &PgPool,
did: &str,
handle: &str,
record: &Value,
) -> Result<()> {
let display_name = record
.get("displayName")
.and_then(|v| v.as_str())
.map(str::to_string);
let description = record
.get("description")
.and_then(|v| v.as_str())
.map(str::to_string);
let avatar_cid = blob_link_of(record, "avatar");
let banner_cid = blob_link_of(record, "banner");
// Denormalised counts. Cheap with the existing PKs.
let post_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM posts WHERE did = $1 \
AND collection IN ('app.twi.post','app.bsky.feed.post')",
)
.bind(did)
.fetch_one(db)
.await?;
let follower_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM follows WHERE subject_did = $1",
)
.bind(did)
.fetch_one(db)
.await?;
let following_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM follows WHERE follower_did = $1",
)
.bind(did)
.fetch_one(db)
.await?;
sqlx::query(
r#"INSERT INTO profiles
(did, handle, display_name, description,
avatar_cid, banner_cid,
post_count, follower_count, following_count, indexed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now())
ON CONFLICT (did) DO UPDATE SET
handle = EXCLUDED.handle,
display_name = EXCLUDED.display_name,
description = EXCLUDED.description,
avatar_cid = EXCLUDED.avatar_cid,
banner_cid = EXCLUDED.banner_cid,
post_count = EXCLUDED.post_count,
follower_count= EXCLUDED.follower_count,
following_count= EXCLUDED.following_count,
indexed_at = now()"#,
)
.bind(did)
.bind(handle)
.bind(display_name)
.bind(description)
.bind(avatar_cid)
.bind(banner_cid)
.bind(post_count)
.bind(follower_count)
.bind(following_count)
.execute(db)
.await?;
Ok(())
}
/// Pull a blob-ref `$link` out of a profile record's field.
/// Accepts both the modern shape
/// (`{ $type: "blob", ref: { $link: "..." } }`)
/// and the legacy shape (`{ $link: "..." }`) for robustness.
fn blob_link_of(record: &Value, field: &str) -> Option<String> {
let v = record.get(field)?;
// Try `ref.$link` first, then flat `$link`.
if let Some(link) = v.get("ref").and_then(|r| r.get("$link")).and_then(|s| s.as_str()) {
return Some(link.to_string());
}
v.get("$link").and_then(|s| s.as_str()).map(str::to_string)
}
#[cfg(test)]
mod profile_tests {
use super::*;
use serde_json::json;
/// Open the appview DB used by integration tests, running
/// migrations first. Returns `None` when no DB is reachable so
/// the test can `eprintln!` and bail (no panic).
async fn try_test_db() -> Option<PgPool> {
let url = std::env::var("DATABASE_URL_APPVIEW").ok()?;
match timeout(
Duration::from_secs(2),
sqlx::PgPool::connect(&url),
)
.await
{
Ok(Ok(pool)) => match sqlx::migrate!("../../migrations/appview").run(&pool).await {
Ok(()) => Some(pool),
Err(_) => None,
},
_ => None,
}
}
#[test]
fn blob_link_of_modern_shape() {
let rec = json!({
"avatar": {
"$type": "blob",
"ref": { "$link": "bafyavatar" },
"mimeType": "image/png",
"size": 1234
}
});
assert_eq!(blob_link_of(&rec, "avatar").as_deref(), Some("bafyavatar"));
}
#[test]
fn blob_link_of_legacy_flat_link() {
let rec = json!({ "banner": { "$link": "bafybanner" } });
assert_eq!(blob_link_of(&rec, "banner").as_deref(), Some("bafybanner"));
}
#[test]
fn blob_link_of_missing_field() {
let rec = json!({ "displayName": "x" });
assert_eq!(blob_link_of(&rec, "avatar"), None);
}
#[tokio::test]
async fn upsert_profile_round_trip() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
// Use a unique DID per test run so we don't collide with the
// migration backfill (which seeded a row for every distinct
// DID in `posts`).
let did = format!(
"did:plc:profile_test_{}",
uuid::Uuid::new_v4().simple()
);
let handle = format!("user.{}.test", uuid::Uuid::new_v4().simple());
// Seed a couple of posts so post_count is non-zero.
for rkey in &["p1", "p2"] {
sqlx::query(
r#"INSERT INTO posts
(uri, did, handle, rkey, collection, text, cid,
parent_uri, root_uri, langs, created_at)
VALUES ($1,$2,$3,$4,'app.twi.post','seed','bafy',NULL,NULL,NULL, now())
ON CONFLICT (uri) DO NOTHING"#,
)
.bind(format!("at://{did}/app.twi.post/{rkey}"))
.bind(&did)
.bind(&handle)
.bind(rkey)
.execute(&db)
.await
.unwrap();
}
let record = json!({
"displayName": "Alice",
"description": "tester",
"avatar": { "$type": "blob", "ref": { "$link": "bafyavatar" } },
"banner": { "$type": "blob", "ref": { "$link": "bafybanner" } }
});
upsert_profile(&db, &did, &handle, &record).await.unwrap();
let row: (
String, // handle
Option<String>, // display_name
Option<String>, // description
Option<String>, // avatar_cid
Option<String>, // banner_cid
i64, // post_count
i64, // follower_count
i64, // following_count
) = sqlx::query_as(
"SELECT handle, display_name, description, avatar_cid, banner_cid, \
post_count, follower_count, following_count \
FROM profiles WHERE did = $1",
)
.bind(&did)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(row.0, handle);
assert_eq!(row.1.as_deref(), Some("Alice"));
assert_eq!(row.2.as_deref(), Some("tester"));
assert_eq!(row.3.as_deref(), Some("bafyavatar"));
assert_eq!(row.4.as_deref(), Some("bafybanner"));
assert_eq!(row.5, 2, "post_count must reflect seeded posts");
// Update: change display name, drop banner — verify replace
// semantics (NULL fields overwrite, not coalesce).
let record2 = json!({ "displayName": "Alice 2" });
upsert_profile(&db, &did, &handle, &record2).await.unwrap();
let name: Option<String> = sqlx::query_scalar(
"SELECT display_name FROM profiles WHERE did = $1",
)
.bind(&did)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(name.as_deref(), Some("Alice 2"));
// Cleanup.
let _ = sqlx::query("DELETE FROM profiles WHERE did = $1")
.bind(&did)
.execute(&db)
.await;
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await;
}
/// `apply_commit` must dispatch an `app.bsky.actor.profile`
/// create op into the `profiles` cache (this is the path Jetstream
/// uses for third-party PDS authors).
#[tokio::test]
async fn apply_commit_indexes_profile_create() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let did = format!(
"did:plc:profile_commit_{}",
uuid::Uuid::new_v4().simple()
);
let _ = sqlx::query("DELETE FROM profiles WHERE did = $1")
.bind(&did)
.execute(&db)
.await;
// Seed a post so the indexer can find a known handle.
sqlx::query(
r#"INSERT INTO posts
(uri, did, handle, rkey, collection, text, cid,
parent_uri, root_uri, langs, created_at)
VALUES ($1,$2,$3,'seed','app.twi.post','hi','bafy',NULL,NULL,NULL, now())
ON CONFLICT (uri) DO NOTHING"#,
)
.bind(format!("at://{did}/app.twi.post/seed"))
.bind(&did)
.bind("alice.test")
.execute(&db)
.await
.unwrap();
let commit = json!({
"operation": "create",
"collection": "app.bsky.actor.profile",
"rkey": "self",
"cid": "bafyprofilecid",
"record": {
"displayName": "Alice",
"description": "hello",
"avatar": { "$type": "blob", "ref": { "$link": "bafyavatar" } }
}
});
let ev = JetstreamEvent {
did: did.clone(),
time_us: 1_700_000_000_000_000,
kind: "commit".into(),
commit: Some(commit),
identity: None,
account: None,
};
let applied = apply_commit(&db, &ev).await.unwrap();
assert!(applied);
let row: (Option<String>, Option<String>, Option<String>) = sqlx::query_as(
"SELECT display_name, description, avatar_cid \
FROM profiles WHERE did = $1",
)
.bind(&did)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(row.0.as_deref(), Some("Alice"));
assert_eq!(row.1.as_deref(), Some("hello"));
assert_eq!(row.2.as_deref(), Some("bafyavatar"));
// Delete op should wipe the row.
let del = json!({
"operation": "delete",
"collection": "app.bsky.actor.profile",
"rkey": "self"
});
let ev_del = JetstreamEvent {
did: did.clone(),
time_us: 1_700_000_001_000_000,
kind: "commit".into(),
commit: Some(del),
identity: None,
account: None,
};
apply_commit(&db, &ev_del).await.unwrap();
let remaining: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM profiles WHERE did = $1",
)
.bind(&did)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(remaining, 0, "delete op must remove profile row");
// Cleanup.
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await;
}
}