Files
maarcadetweet/crates/appview/src/ingest.rs
T
tomdebone 59a3cb02dd 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.
2026-07-18 17:56:52 +02:00

314 lines
10 KiB
Rust

//! `POST /internal/ingest-commit` — used by the PDS to push local commits
//! into the AppView so the user's own actions show up without waiting for
//! the Jetstream round-trip.
//!
//! Wire shape:
//! ```json
//! {
//! "did": "did:plc:abc",
//! "collection": "app.twi.post",
//! "action": "create",
//! "rkey": "3k2...",
//! "cid": "bafy...", // optional
//! "record": { ... }, // optional; required for follow delete
//! "subject_did": "did:plc:..." // required for app.bsky.graph.follow
//! }
//! ```
//!
//! In production this endpoint would be protected with mTLS and a token
//! minted by the PDS; for now it's open inside the cluster.
use crate::indexer;
use crate::state::AppState;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use serde::Deserialize;
use serde_json::Value;
use tracing::{info, warn};
#[derive(Debug, Deserialize)]
pub struct IngestCommitReq {
pub did: String,
/// The poster's current handle, as known by the PDS `users` table.
/// Optional in the wire payload — the AppView falls back to an
/// empty string, and the upsert COALESCE guard prevents the empty
/// value from clobbering a backfilled handle from the Jetstream
/// `identity` event path.
#[serde(default)]
pub handle: Option<String>,
pub collection: String,
pub action: String,
pub rkey: String,
#[serde(default)]
pub cid: Option<String>,
#[serde(default)]
pub record: Option<Value>,
/// Required for `app.bsky.graph.follow` because the record value isn't
/// always preserved on delete events.
#[serde(default)]
pub subject_did: Option<String>,
}
/// Authenticate internal ingest requests.
/// - If `APPVIEW_INGEST_SECRET` env var is unset: dev mode, accept anything.
/// - If set: require `X-Ingest-Secret: <value>` header to match.
pub fn check_ingest_secret(
headers: &HeaderMap,
configured: Option<&str>,
) -> Result<(), (StatusCode, Json<Value>)> {
let Some(expected) = configured else {
return Ok(()); // dev mode
};
let provided = headers
.get("x-ingest-secret")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if constant_time_eq(provided.as_bytes(), expected.as_bytes()) {
Ok(())
} else {
Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "AuthenticationRequired",
"message": "missing or invalid X-Ingest-Secret",
})),
))
}
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
pub async fn ingest_commit(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<IngestCommitReq>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
check_ingest_secret(&headers, state.cfg.appview_ingest_secret.as_deref())?;
let result = apply(&state, &req).await;
if let Err((status, body)) = &result {
warn!(
status = status.as_u16(),
body = %body.0,
did = %req.did,
collection = %req.collection,
action = %req.action,
"ingest commit failed"
);
} else {
info!(did = %req.did, collection = %req.collection,
action = %req.action, rkey = %req.rkey, "ingested commit");
}
result.map(|applied| {
Json(serde_json::json!({
"ok": true,
"applied": applied,
}))
})
}
async fn apply(
state: &AppState,
req: &IngestCommitReq,
) -> Result<bool, (StatusCode, Json<Value>)> {
match (req.collection.as_str(), req.action.as_str()) {
("app.twi.post", "create") | ("app.bsky.feed.post", "create") => {
let record = req
.record
.clone()
.unwrap_or_else(|| serde_json::json!({"text": "", "createdAt": chrono::Utc::now().to_rfc3339()}));
let cid = req.cid.clone().unwrap_or_default();
let mut row = indexer::PostRow::from_record(
&req.did,
&req.rkey,
&req.collection,
&cid,
&record,
req.handle.as_deref(),
);
indexer::upsert_post(&state.db, &mut row).await.map_err(db_err)?;
Ok(true)
}
("app.twi.post", "delete") | ("app.bsky.feed.post", "delete") => {
let uri = format!("at://{}/{}/{}", req.did, req.collection, req.rkey);
indexer::delete_post(&state.db, &uri).await.map_err(db_err)?;
Ok(true)
}
("app.bsky.feed.like", "create") => {
indexer::upsert_like(
&state.db,
&req.did,
&req.rkey,
req.cid.as_deref(),
req.record.as_ref(),
)
.await
.map_err(db_err)?;
Ok(true)
}
("app.bsky.feed.like", "delete") => {
indexer::delete_like(&state.db, &req.did, &req.rkey)
.await
.map_err(db_err)?;
Ok(true)
}
("app.bsky.feed.repost", "create") => {
indexer::upsert_repost(
&state.db,
&req.did,
&req.rkey,
req.cid.as_deref(),
req.record.as_ref(),
)
.await
.map_err(db_err)?;
Ok(true)
}
("app.bsky.feed.repost", "delete") => {
indexer::delete_repost(&state.db, &req.did, &req.rkey)
.await
.map_err(db_err)?;
Ok(true)
}
("app.bsky.graph.follow", "create") => {
let subject = req
.subject_did
.clone()
.or_else(|| {
req.record
.as_ref()
.and_then(|r| r.get("subject"))
.and_then(|s| s.as_str())
.map(str::to_string)
})
.ok_or_else(|| bad_request("follow create requires subject_did or record.subject"))?;
indexer::upsert_follow(
&state.db,
&req.did,
&subject,
req.record.as_ref(),
)
.await
.map_err(db_err)?;
Ok(true)
}
("app.bsky.graph.follow", "delete") => {
let subject = req
.subject_did
.clone()
.or_else(|| {
req.record
.as_ref()
.and_then(|r| r.get("subject"))
.and_then(|s| s.as_str())
.map(str::to_string)
})
.ok_or_else(|| bad_request("follow delete requires subject_did"))?;
indexer::delete_follow(&state.db, &req.did, &subject)
.await
.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.
tracing::debug!(collection = %coll, action = %action, "ingest: unhandled");
Ok(false)
}
}
}
fn db_err(e: impl std::fmt::Display) -> (StatusCode, Json<Value>) {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "InternalServerError",
"message": e.to_string(),
})),
)
}
fn bad_request(msg: &str) -> (StatusCode, Json<Value>) {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "InvalidRequest",
"message": msg,
})),
)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
#[test]
fn no_secret_configured_allows_anonymous() {
let h = HeaderMap::new();
assert!(check_ingest_secret(&h, None).is_ok());
}
#[test]
fn secret_required_when_configured() {
let h = HeaderMap::new();
assert!(check_ingest_secret(&h, Some("hunter2")).is_err());
}
#[test]
fn secret_matches() {
let mut h = HeaderMap::new();
h.insert("x-ingest-secret", HeaderValue::from_static("hunter2"));
assert!(check_ingest_secret(&h, Some("hunter2")).is_ok());
}
#[test]
fn secret_mismatched() {
let mut h = HeaderMap::new();
h.insert("x-ingest-secret", HeaderValue::from_static("hunter3"));
assert!(check_ingest_secret(&h, Some("hunter2")).is_err());
}
}