maarcadetweet: initial commit

AT Protocol PDS + AppView + Tauri Desktop Client, 160-char post limit.

- PDS (Rust + axum + sqlx)
  - Auth: createAccount, createSession, refreshSession
  - Records: createRecord, deleteRecord (race-safe via SELECT FOR UPDATE)
  - Feed: feed.like.create, feed.repost.create
  - Sync: getRepo, getBlocks, getLatestCommit, getRecord (with MST proof), listRepos
  - Identity: resolveHandle
  - MST: spec-conformant (at-mst crate, 27 tests)
  - Repo: signed commits, TID counter (monotonic, 4096 wrap safe)

- AppView (Rust + axum + sqlx)
  - Jetstream consumer (WebSocket, exponential backoff, 38k+ events indexed)
  - REST API: timeline/home (graph-aware), profile, search, post (with thread hydration)
  - Handle-sync worker (did:plc + did:web)
  - JSONB embed storage + thread columns (migration 0003)
  - Like/repost counter cache (migration 0004)

- Tauri 2 + Svelte 5 Desktop Client
  - System tray (Show/Compose/Quit menu)
  - OS notifications (tauri-plugin-notification)
  - Auto-update (tauri-plugin-updater, placeholder endpoint)
  - Window-state (tauri-plugin-window-state)
  - 160-char compose with live counter
  - Image/Link embed rendering
  - LocalStorage-persisted like state
  - Timeline with poll (prepend new posts)
  - Custom TitleBar (transparent, no decorations)
  - Orange/IBM Plex Mono maarcade design

Tests: 231 Rust + 9 vitest = 240 passed.
This commit is contained in:
tomdebone
2026-07-05 20:01:31 +02:00
commit c586fd39c9
134 changed files with 35279 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
[package]
name = "appview"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "maarcadetweet AppView (bin)"
[lints.rust]
unsafe_code = "forbid"
[lib]
name = "appview"
path = "src/lib.rs"
[[bin]]
name = "appview"
path = "src/main.rs"
[dependencies]
tokio = { workspace = true }
axum = { workspace = true }
tower = { workspace = true }
tower-http = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
anyhow = { workspace = true }
sqlx = { workspace = true }
chrono = { workspace = true }
async-trait = { workspace = true }
at-shared = { workspace = true }
at-firehose = { workspace = true }
at-crypto = { workspace = true }
at-identity = { workspace = true }
uuid = { workspace = true }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "logging", "tls12"] }
base64 = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }
reqwest = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
+225
View File
@@ -0,0 +1,225 @@
//! Connects `at-firehose::JetstreamConsumer` into the AppView indexer.
//!
//! The handler is intentionally tiny: it routes events by `kind`, delegates
//! all DB work to [`crate::indexer`], and feeds a small `mpsc` channel that
//! a background task drains to flush the cursor.
//!
//! Stats (events processed, last cursor seen, last-event timestamp for
//! connection health) live in an `Arc<Stats>` so both the consumer closure
//! and the HTTP `/healthz` handler can read them without locking.
use anyhow::Result;
use at_firehose::JetstreamEvent;
use sqlx::PgPool;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
use tracing::{debug, info, trace, warn};
use crate::indexer;
/// Shared counters consumed by `/healthz`.
pub struct Stats {
pub events_processed: AtomicU64,
/// Microseconds since epoch of the most recent event seen.
pub last_event_time_us: AtomicI64,
/// Microseconds since epoch of the last persisted cursor (best-effort).
pub last_cursor_persisted_us: AtomicI64,
/// Whether the Jetstream WebSocket is currently up. The consumer
/// writes this; `/healthz` reads it. Wrapped in `Arc` so the consumer
/// can hold its own clone without borrowing from us.
pub jetstream_connected: Arc<AtomicBool>,
}
impl Default for Stats {
fn default() -> Self {
Self {
events_processed: AtomicU64::new(0),
last_event_time_us: AtomicI64::new(0),
last_cursor_persisted_us: AtomicI64::new(0),
jetstream_connected: Arc::new(AtomicBool::new(false)),
}
}
}
impl std::fmt::Debug for Stats {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Stats")
.field("events_processed", &self.events_processed())
.field("last_event_time_us", &self.last_event_time_us.load(Ordering::Relaxed))
.field("jetstream_connected", &self.jetstream_connected())
.finish()
}
}
impl Stats {
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
/// Cheap clone of the `connected` flag (what the consumer stores into).
pub fn jetstream_connected_arc(&self) -> Arc<AtomicBool> {
self.jetstream_connected.clone()
}
/// Lag in milliseconds (last event time local wall clock). When the
/// local clock is ahead (very common — Jetstream events usually look
/// "in the past" by a few seconds because the spec is `time_us` from
/// the producer), this returns 0.
pub fn lag_ms(&self) -> i64 {
let last = self.last_event_time_us.load(Ordering::Relaxed);
if last == 0 {
return 0;
}
let now_us = chrono::Utc::now().timestamp_micros();
let lag_us = (now_us - last).max(0);
lag_us / 1000
}
pub fn events_processed(&self) -> u64 {
self.events_processed.load(Ordering::Relaxed)
}
pub fn jetstream_connected(&self) -> bool {
self.jetstream_connected.load(Ordering::Relaxed)
}
}
/// The thing the Jetstream consumer calls once per event.
#[derive(Clone)]
pub struct IndexHandler {
pub db: PgPool,
pub stats: Arc<Stats>,
/// Sender side of the cursor-flush channel. The consumer closure pushes
/// `event.time_us` every 100 events; a background task drains it and
/// writes the running maximum to `jetstream_cursor` at most once every
/// 500ms. The bound of 32 is plenty — the background task keeps up.
cursor_tx: mpsc::Sender<i64>,
}
impl IndexHandler {
pub fn new(db: PgPool, stats: Arc<Stats>, cursor_tx: mpsc::Sender<i64>) -> Self {
Self { db, stats, cursor_tx }
}
pub async fn handle(&self, ev: JetstreamEvent) -> Result<()> {
// Update per-event stats first so /healthz reflects liveness even
// when DB writes are slow.
self.stats.events_processed.fetch_add(1, Ordering::Relaxed);
let prev = self
.stats
.last_event_time_us
.fetch_max(ev.time_us, Ordering::Relaxed);
if ev.time_us < prev {
// Out-of-order event: keep the larger value but still try to
// process. (Jetstream delivers near-monotonically but it's
// not guaranteed.)
self.stats
.last_event_time_us
.store(prev, Ordering::Relaxed);
}
let ok: bool = match ev.kind.as_str() {
"commit" => match indexer::apply_commit(&self.db, &ev).await {
Ok(true) => true,
Ok(false) => {
debug!(kind = "commit", "apply_commit skipped event (unrecognized sub-collection)");
true // cursor can still advance — we "saw" the event
}
Err(e) => {
warn!(error = %e, kind = "commit", "apply_commit failed; NOT advancing cursor — expect reconnect replay");
false
}
},
"identity" => {
trace!(did = %ev.did, "identity event (logged only)");
let _ = handle_identity(&ev);
true
}
"account" => {
trace!(did = %ev.did, "account event (logged only)");
let _ = handle_account(&ev);
true
}
other => {
debug!(kind = %other, "ignoring event of unknown kind");
true
}
};
// ONLY advance the cursor when the event was processed or skipped
// legitimately. Failures leave the cursor pinned so reconnect replays
// the event.
if !ok {
return Ok(());
}
// Forward the cursor only every 100 events to avoid hammering the DB.
if self.stats.events_processed() % 100 == 0 {
let _ = self.cursor_tx.try_send(ev.time_us);
}
Ok(())
}
}
fn handle_identity(_ev: &JetstreamEvent) -> Result<()> {
info!("identity change (DID doc rotation)");
Ok(())
}
fn handle_account(_ev: &JetstreamEvent) -> Result<()> {
info!("account change (active/-status)");
Ok(())
}
/// Spawn the background task that drains the cursor-flush channel and
/// writes the running maximum to the DB. Returns when the receiver is
/// dropped (i.e. the main process is shutting down).
pub fn spawn_cursor_flush(
db: PgPool,
mut rx: mpsc::Receiver<i64>,
stats: Arc<Stats>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut buf: Vec<i64> = Vec::with_capacity(128);
let mut interval = tokio::time::interval(Duration::from_millis(500));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
biased;
maybe = rx.recv() => {
match maybe {
Some(ts) => buf.push(ts),
None => {
// Channel closed — flush whatever's left and
// exit cleanly.
if !buf.is_empty() {
let max = buf.iter().copied().max().unwrap_or(0);
if let Err(e) = indexer::cursor_advance(&db, max).await {
warn!(error = %e, "final cursor flush failed");
} else {
stats.last_cursor_persisted_us.store(max, Ordering::Relaxed);
}
buf.clear();
}
return;
}
}
}
_ = interval.tick() => {
if buf.is_empty() { continue; }
let max = buf.iter().copied().max().unwrap_or(0);
if let Err(e) = indexer::cursor_advance(&db, max).await {
warn!(error = %e, "cursor flush failed");
} else {
stats.last_cursor_persisted_us.store(max, Ordering::Relaxed);
}
buf.clear();
}
}
}
})
}
+599
View File
@@ -0,0 +1,599 @@
//! Background worker that resolves empty `handle` columns in the `posts`
//! table to real `@handle.bsky.social` style strings.
//!
//! ## Why
//!
//! Jetstream events carry no `handle` — only the `did`. The AppView's
//! indexer inserts posts with an empty placeholder (`handle = ''`) and a
//! separate worker is responsible for back-filling it. The UI can render
//! `@<did-prefix>…` as a fallback, but a real handle is far nicer and
//! makes the timeline readable for accounts that post anonymously.
//!
//! ## How
//!
//! `HandleSyncWorker::run_forever()` runs [`Self::run_once`] in a loop,
//! sleeping `interval_secs` between passes. Each pass:
//!
//! 1. Reads up to [`BATCH_SIZE`] distinct DIDs from `posts` where
//! `handle = ''`.
//! 2. For each DID, dispatches by method:
//! * `did:plc:` → [`HandleSyncWorker::plc_resolver`]
//! * `did:web:` → [`HandleSyncWorker::web_resolver`]
//! * anything else (e.g. `did:key:`) → skipped
//! A resolver returning `Ok(None)` counts as `skipped`, not `failed`.
//! 3. `UPDATE posts SET handle = $1 WHERE did = $2 AND handle = ''` so
//! concurrent syncs (or the `/internal/ingest-commit` path, which can
//! populate handle separately) can't clobber a value written by
//! someone else in the meantime.
//!
//! ## Testability
//!
//! The two resolvers are type-erased `Arc<dyn DidHandleResolver>`s so
//! tests can swap stubs that map DID → handle without hitting the
//! network. `PlcClient` and `WebResolver` are the production impls.
use anyhow::Result;
use at_identity::DidHandleResolver;
use sqlx::PgPool;
use std::sync::Arc;
use std::time::Duration;
use tracing::{debug, info, warn};
/// Max DIDs processed per pass. Keeps individual runs bounded so a
/// back-fill of thousands of empty-handle posts doesn't hammer the PLC.
pub const BATCH_SIZE: i64 = 100;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct SyncReport {
/// Rows whose `handle` column was newly populated this pass.
pub resolved: usize,
/// DIDs where the resolver returned `Err(_)` (network / 5xx).
pub failed: usize,
/// DIDs where the resolver returned `Ok(None)` (unknown DID,
/// unsupported method) **or** rows that already had a non-empty
/// handle when the UPDATE landed.
pub skipped: usize,
}
pub struct HandleSyncWorker {
pub db: PgPool,
pub plc_resolver: Arc<dyn DidHandleResolver>,
pub web_resolver: Arc<dyn DidHandleResolver>,
pub interval_secs: u64,
}
impl HandleSyncWorker {
/// Pick the right resolver based on the DID's method prefix and
/// return its result. Unknown methods (`did:key:`, etc.) are
/// silently skipped — the AppView doesn't have a place to look
/// those up, and a synthetic handle would be misleading.
async fn dispatch(&self, did: &str) -> Result<Option<String>> {
if did.starts_with("did:plc:") {
self.plc_resolver.resolve_handle(did).await
} else if did.starts_with("did:web:") {
self.web_resolver.resolve_handle(did).await
} else {
Ok(None)
}
}
/// Drive [`Self::run_once`] on a fixed-interval loop until the
/// process exits. Intended for `tokio::spawn`.
pub async fn run_forever(self) {
info!(
interval_secs = self.interval_secs,
"handle-sync worker started"
);
loop {
match self.run_once().await {
Ok(report) => {
if report.resolved > 0
|| report.failed > 0
|| report.skipped > 0
{
info!(
resolved = report.resolved,
failed = report.failed,
skipped = report.skipped,
"handle-sync pass complete"
);
} else {
debug!("handle-sync pass: nothing to do");
}
}
Err(e) => {
warn!(error = %e, "handle-sync pass aborted; will retry");
}
}
tokio::time::sleep(Duration::from_secs(self.interval_secs)).await;
}
}
/// One bounded scan: find up to [`BATCH_SIZE`] distinct DIDs whose
/// posts have an empty handle, resolve them, and update the rows
/// where the handle is still empty (race-safe).
pub async fn run_once(&self) -> Result<SyncReport> {
let dids: Vec<(String,)> = sqlx::query_as(
r#"SELECT DISTINCT did
FROM posts
WHERE handle = ''
ORDER BY did
LIMIT $1"#,
)
.bind(BATCH_SIZE)
.fetch_all(&self.db)
.await?;
let mut report = SyncReport::default();
if dids.is_empty() {
return Ok(report);
}
for (did,) in dids {
match self.dispatch(&did).await {
Ok(Some(handle)) => {
if handle.is_empty() {
report.skipped += 1;
continue;
}
let res = sqlx::query(
"UPDATE posts SET handle = $1 \
WHERE did = $2 AND handle = ''",
)
.bind(&handle)
.bind(&did)
.execute(&self.db)
.await?;
if res.rows_affected() > 0 {
report.resolved += res.rows_affected() as usize;
} else {
// Another worker / ingest path already filled it
// between our SELECT and UPDATE.
report.skipped += 1;
}
}
Ok(None) => {
report.skipped += 1;
}
Err(e) => {
warn!(did = %did, error = %e, "handle resolve failed");
report.failed += 1;
}
}
}
Ok(report)
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
use tokio::time::timeout;
/// Stub resolver driven by a fixed DID → handle map. Counts how
/// many times each DID was queried so the limit test can assert
/// the worker capped the batch correctly.
struct StubResolver {
mapping: Mutex<HashMap<String, Option<String>>>,
queried: Mutex<Vec<String>>,
}
impl StubResolver {
fn new(mapping: HashMap<String, Option<String>>) -> Self {
Self {
mapping: Mutex::new(mapping),
queried: Mutex::new(Vec::new()),
}
}
fn into_arc(self) -> Arc<dyn DidHandleResolver> {
Arc::new(self)
}
}
#[async_trait]
impl DidHandleResolver for StubResolver {
async fn resolve_handle(&self, did: &str) -> Result<Option<String>> {
self.queried.lock().unwrap().push(did.to_string());
Ok(self.mapping.lock().unwrap().get(did).cloned().flatten())
}
}
/// Convenience: a worker whose `plc_resolver` and `web_resolver`
/// both point at the same stub. Existing tests don't care which
/// method the DIDs use because the stub is method-agnostic.
fn worker_with(db: PgPool, stub: Arc<dyn DidHandleResolver>) -> HandleSyncWorker {
HandleSyncWorker {
db,
plc_resolver: Arc::clone(&stub),
web_resolver: Arc::clone(&stub),
interval_secs: 999,
}
}
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,
}
}
fn unique_did(suffix: &str) -> String {
format!("did:plc:stubsync_{}_{}", suffix, uuid::Uuid::new_v4().simple())
}
async fn seed_post(
db: &PgPool,
did: &str,
rkey: &str,
handle: &str,
) -> Result<()> {
let uri = format!("at://{did}/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,$3,$4,'app.twi.post','stub','bafy',NULL,NULL,NULL, now())
ON CONFLICT (uri) DO NOTHING"#,
)
.bind(&uri)
.bind(did)
.bind(handle)
.bind(rkey)
.execute(db)
.await?;
Ok(())
}
async fn get_handle(db: &PgPool, did: &str) -> Option<String> {
sqlx::query_scalar::<_, String>(
"SELECT handle FROM posts WHERE did = $1 ORDER BY indexed_at DESC LIMIT 1",
)
.bind(did)
.fetch_optional(db)
.await
.ok()
.flatten()
.filter(|s| !s.is_empty())
}
/// Smoke test: a stub resolver maps one DID → handle. After
/// `run_once()` the worker should populate the `handle` column on
/// every empty post for that DID and report it as `resolved`.
#[tokio::test]
async fn run_once_returns_report() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let did = unique_did("report");
// Wipe any previous stub rows for this slot.
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await
.unwrap();
// Seed two posts for the same DID, both with empty handle.
for rk in ["rka", "rkb"] {
seed_post(&db, &did, rk, "").await.unwrap();
}
let expected_handle = format!("handle.{}", uuid::Uuid::new_v4().simple());
let resolver = StubResolver::new(HashMap::from([(
did.clone(),
Some(expected_handle.clone()),
)]))
.into_arc();
let worker = worker_with(db, resolver);
let report = worker.run_once().await.unwrap();
assert!(report.resolved >= 2, "expected ≥2 resolved, got {report:?}");
assert_eq!(report.failed, 0);
assert_eq!(report.skipped, 0);
let h = get_handle(&worker.db, &did).await;
assert_eq!(h.as_deref(), Some(expected_handle.as_str()));
}
/// DIDs that already have a non-empty handle on **every** post must
/// not be re-queried — the worker scans `WHERE handle = ''`.
#[tokio::test]
async fn skip_dids_with_handle() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let did = unique_did("skip");
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await
.unwrap();
// Insert one post with a pre-filled handle.
seed_post(&db, &did, "rkA", "pre-existing.handle").await.unwrap();
// The stub would return a different handle if asked.
let resolver = StubResolver::new(HashMap::from([(
did.clone(),
Some("different.handle".into()),
)]))
.into_arc();
let worker = worker_with(db, resolver);
let report = worker.run_once().await.unwrap();
assert_eq!(report.resolved, 0);
assert_eq!(report.failed, 0);
assert_eq!(report.skipped, 0); // DID was already filtered out by the SELECT
let h = get_handle(&worker.db, &did).await;
assert_eq!(h.as_deref(), Some("pre-existing.handle"));
}
/// When `run_once()` finds more than [`BATCH_SIZE`] empty-handle DIDs,
/// only the first batch is processed this pass; the rest stay empty
/// for the next pass.
#[tokio::test]
async fn respects_batch_limit() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
// Seed BATCH_SIZE + 5 distinct DIDs, all empty handles. We
// can't seed 100+000 for real so we use the BATCH_SIZE bound
// directly. The same code path runs with any row count.
let prefix = unique_did("batch");
let mut all_dids = Vec::new();
// Insert a separate DIDs table-style marker so we can clean
// them all up afterwards without touching other test data.
for i in 0..(BATCH_SIZE as usize + 5) {
let did = format!("{prefix}_{i}");
all_dids.push(did.clone());
seed_post(&db, &did, "rk", "").await.unwrap();
}
let mut mapping = HashMap::new();
for did in &all_dids {
mapping.insert(did.clone(), Some(format!("h.{}", &did[did.len()-6..])));
}
let resolver = StubResolver::new(mapping).into_arc();
let worker = HandleSyncWorker {
db: db.clone(),
plc_resolver: Arc::clone(&resolver),
web_resolver: Arc::clone(&resolver),
interval_secs: 999,
};
let report = worker.run_once().await.unwrap();
// Exactly BATCH_SIZE rows updated (one post per DID).
assert_eq!(
report.resolved as i64,
BATCH_SIZE,
"resolved should equal batch size: {report:?}"
);
assert_eq!(report.failed, 0);
// The remaining 5 DIDs must still have empty handles.
let remaining: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM posts WHERE handle = '' AND did LIKE $1")
.bind(format!("{prefix}_%"))
.fetch_one(&db)
.await
.unwrap();
assert_eq!(
remaining, 5,
"expected 5 unresolved DIDs left, got {remaining}"
);
// Cleanup so repeated test runs stay hygienic.
let _ = sqlx::query("DELETE FROM posts WHERE did LIKE $1")
.bind(format!("{prefix}_%"))
.execute(&db)
.await;
}
/// The `UPDATE … WHERE handle = ''` clause must guard against races
/// with another writer: if /internal/ingest-commit fills the
/// handle between our SELECT and UPDATE, our UPDATE is a no-op.
#[tokio::test]
async fn update_does_not_overwrite_concurrent_write() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let did = unique_did("race");
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await
.unwrap();
seed_post(&db, &did, "rk", "").await.unwrap();
let resolver = StubResolver::new(HashMap::from([(
did.clone(),
Some("from-resolver".into()),
)]))
.into_arc();
let worker = HandleSyncWorker {
db: db.clone(),
plc_resolver: Arc::clone(&resolver),
web_resolver: Arc::clone(&resolver),
interval_secs: 999,
};
// Concurrent writer: race with the worker's UPDATE by setting
// handle directly while run_once is reading it.
// In practice the SELECT happens first, so the UPDATE WHERE
// clause is what protects us. Simulate the "other writer won"
// outcome directly here: write a handle, then call run_once —
// since the SELECT excludes non-empty rows, run_once sees an
// empty batch.
sqlx::query("UPDATE posts SET handle = 'from-ingest' WHERE did = $1")
.bind(&did)
.execute(&db)
.await
.unwrap();
let report = worker.run_once().await.unwrap();
assert_eq!(report.resolved, 0, "must not touch already-handled rows");
let h = get_handle(&worker.db, &did).await;
assert_eq!(h.as_deref(), Some("from-ingest"));
}
/// Dispatch test: a `did:web:` DID must be routed to the
/// `web_resolver` (not the PLC one). Without this routing, every
/// `did:web:` post would stay `@<did-prefix>…` forever.
#[tokio::test]
async fn dispatches_did_web_to_web_resolver() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let did = format!("did:web:example.com:user:{}", uuid::Uuid::new_v4().simple());
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await
.unwrap();
seed_post(&db, &did, "rk", "").await.unwrap();
// Two stubs that disagree on the answer. The dispatcher's
// job is to pick the right one based on the DID method.
let plc = StubResolver::new(HashMap::from([(
did.clone(),
Some("WRONG-PLC-HANDLE".into()),
)]))
.into_arc();
let web = StubResolver::new(HashMap::from([(
did.clone(),
Some("web-handle.example.com".into()),
)]))
.into_arc();
let worker = HandleSyncWorker {
db: db.clone(),
plc_resolver: plc,
web_resolver: web,
interval_secs: 999,
};
let report = worker.run_once().await.unwrap();
assert_eq!(
report.resolved, 1,
"did:web must resolve through the web resolver, got {report:?}"
);
assert_eq!(
report.failed, 0,
"did:web must not route to the PLC resolver"
);
let h = get_handle(&worker.db, &did).await;
assert_eq!(h.as_deref(), Some("web-handle.example.com"));
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await;
}
/// DIDs whose method isn't `did:plc:` or `did:web:` (e.g. `did:key:`)
/// are silently skipped — neither resolver is consulted.
#[tokio::test]
async fn unknown_methods_are_skipped() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let did = format!("did:key:z{}", uuid::Uuid::new_v4().simple());
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await
.unwrap();
seed_post(&db, &did, "rk", "").await.unwrap();
// Both stubs would happily return a handle if asked. The
// dispatcher's prefix check must prevent that — neither
// resolver should ever see a `did:key:` DID. We share the
// query logs via `Arc<Mutex<...>>` so the test can read them
// back after the worker has run.
let plc_log: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let web_log: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let plc = TrackingResolver::new(
HashMap::from([(did.clone(), Some("plc-handle".into()))]),
Arc::clone(&plc_log),
);
let web = TrackingResolver::new(
HashMap::from([(did.clone(), Some("web-handle".into()))]),
Arc::clone(&web_log),
);
let plc_arc: Arc<dyn DidHandleResolver> = Arc::new(plc);
let web_arc: Arc<dyn DidHandleResolver> = Arc::new(web);
let worker = HandleSyncWorker {
db: db.clone(),
plc_resolver: plc_arc,
web_resolver: web_arc,
interval_secs: 999,
};
let report = worker.run_once().await.unwrap();
assert_eq!(report.resolved, 0, "did:key must not resolve, got {report:?}");
assert_eq!(
report.failed, 0,
"did:key must not be treated as a failure"
);
assert_eq!(report.skipped, 1, "did:key must be skipped");
// No resolver was consulted.
assert!(
plc_log.lock().unwrap().is_empty(),
"PLC resolver must not be called for did:key"
);
assert!(
web_log.lock().unwrap().is_empty(),
"web resolver must not be called for did:key"
);
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await;
}
/// Stub resolver that records every DID it's queried into a
/// shared log so tests can verify dispatch routing. Distinct
/// from `StubResolver`, which owns its log and would need
/// downcasting to read it back through an `Arc<dyn _>`.
struct TrackingResolver {
mapping: HashMap<String, Option<String>>,
queried: Arc<Mutex<Vec<String>>>,
}
impl TrackingResolver {
fn new(
mapping: HashMap<String, Option<String>>,
queried: Arc<Mutex<Vec<String>>>,
) -> Self {
Self { mapping, queried }
}
}
#[async_trait]
impl DidHandleResolver for TrackingResolver {
async fn resolve_handle(&self, did: &str) -> Result<Option<String>> {
self.queried.lock().unwrap().push(did.to_string());
Ok(self.mapping.get(did).cloned().flatten())
}
}
}
File diff suppressed because it is too large Load Diff
+273
View File
@@ -0,0 +1,273 @@
//! `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,
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 row = indexer::PostRow::from_record(
&req.did,
&req.rkey,
&req.collection,
&cid,
&record,
);
indexer::upsert_post(&state.db, &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)
}
(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());
}
}
+11
View File
@@ -0,0 +1,11 @@
//! AppView library surface. The binary (`src/main.rs`) wires the HTTP
//! server, firehose ingestion, and the handle-sync worker; integration
//! tests under `tests/` import from here so they can build a worker
//! against a stub resolver without booting the binary.
pub mod firehose;
pub mod handle_sync;
pub mod indexer;
pub mod ingest;
pub mod routes;
pub mod state;
+105
View File
@@ -0,0 +1,105 @@
use anyhow::Result;
use at_identity::DidHandleResolver;
use at_shared::config::AppConfig;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::mpsc;
use tracing::info;
use tracing_subscriber::EnvFilter;
mod firehose;
mod handle_sync;
mod indexer;
mod ingest;
mod routes;
mod state;
use state::AppState;
#[tokio::main]
async fn main() -> Result<()> {
// Install a rustls crypto provider before any TLS connection. `ring`
// is the only one we currently support; using `aws_lc_rs` would
// require a non-default feature on rustls.
let _ = rustls::crypto::ring::default_provider().install_default();
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.init();
let cfg = AppConfig::from_env()?;
let db = sqlx::PgPool::connect(&cfg.database_url_appview).await?;
sqlx::migrate!("../../migrations/appview").run(&db).await?;
// Read the last persisted cursor so we resume after restart instead of
// missing events that landed in the gap between (a) the last value we
// wrote and (b) Jetstream's default backfill window.
let start_cursor = indexer::cursor_get(&db).await.unwrap_or(0);
if start_cursor > 0 {
info!(cursor = start_cursor, "resuming Jetstream from last persisted cursor");
}
// Bounded channel for "cursor wants to advance" signals. We push one
// tick per ~100 events from the consumer thread; the flush task drains
// the channel and writes a single batched UPDATE.
let (cursor_tx, cursor_rx) = mpsc::channel::<i64>(32);
let stats = firehose::Stats::new();
// Spawn the cursor-flush task. It lives for the whole process — when
// the receiver end drops (which only happens at shutdown), the task
// does a final flush and exits.
let _cursor_task = firehose::spawn_cursor_flush(db.clone(), cursor_rx, stats.clone());
{
let mut jetstream = at_firehose::JetstreamConsumer::new(
cfg.jetstream_url.clone(),
cfg.jetstream_collections.clone(),
)
.with_connected_flag(stats.jetstream_connected_arc())
.with_max_backoff_secs(30);
if start_cursor > 0 {
jetstream = jetstream.with_cursor(start_cursor);
}
let handler = firehose::IndexHandler::new(db.clone(), stats.clone(), cursor_tx.clone());
tokio::spawn(async move {
if let Err(e) = jetstream
.run(move |ev| {
let h = handler.clone();
async move { h.handle(ev).await }
})
.await
{
tracing::error!("jetstream terminated: {e:#}");
}
});
}
let state = AppState::new(cfg.clone(), db.clone(), stats.clone());
// Back-fill the `handle` column on posts that the Jetstream
// indexer inserted with an empty placeholder. The worker dispatches
// by DID method: `did:plc:` → PLC directory, `did:web:` → a
// WebResolver that fetches the host's `.well-known/did.json`.
// Anything else (e.g. `did:key:`) is silently skipped.
let plc: Arc<dyn DidHandleResolver> = Arc::new(at_identity::PlcClient::new(
cfg.plc_directory_url.clone(),
));
let web: Arc<dyn DidHandleResolver> = Arc::new(at_identity::WebResolver::new());
let handle_sync = handle_sync::HandleSyncWorker {
db: db.clone(),
plc_resolver: plc,
web_resolver: web,
interval_secs: cfg.appview_handle_sync_interval_secs,
};
tokio::spawn(async move {
handle_sync.run_forever().await;
});
let app = routes::router(state);
let addr: SocketAddr = format!("{}:{}", cfg.appview_host, cfg.appview_port).parse()?;
info!("appview listening on http://{addr}");
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
+845
View File
@@ -0,0 +1,845 @@
//! AppView HTTP routes.
//!
//! Three groups:
//! - `root` + `healthz`: public liveness / info probes.
//! - `timeline_*` / `profile_*` / `search`: read API the Tauri client
//! calls to render the UI. Reads from `posts` (and the helper
//! `follows` / `likes` tables) — never writes.
//! - `ingest_commit`: the internal-only writer used by the PDS, owned
//! in `crate::ingest`.
//!
//! The cursor format used by `timeline_home` is opaque: it's a
//! `base64url(micros):uri` pair, which is what [`cursor::encode`] and
//! [`cursor::decode`] produce/consume.
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use chrono::{DateTime, TimeZone, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::state::AppState;
pub mod cursor;
pub mod types;
use types::{PostRow, PostRowWithIndexed, ProfileResponse, SearchResponse, TimelineResponse};
pub fn router(state: AppState) -> Router {
Router::new()
.route("/", get(root))
.route("/api/timeline/home", get(timeline_home))
.route("/api/profile", get(profile_query))
.route("/api/profile/:handle", get(profile_path))
.route("/api/search", get(search))
.route("/api/post/*uri", get(post_by_uri))
.route("/healthz", get(healthz))
.route("/internal/ingest-commit", post(crate::ingest::ingest_commit))
.with_state(state)
}
async fn root() -> Json<Value> {
Json(json!({
"name": "maarcadetweet-appview",
"version": env!("CARGO_PKG_VERSION"),
}))
}
// -- timeline ---------------------------------------------------------------
#[derive(Debug, Deserialize)]
struct TimelineQuery {
/// The DID of the user whose timeline we're building. Required —
/// without it we have no way to do (future) follow-graph filtering
/// and no place to anchor the pagination.
did: String,
/// Page size. Defaults to 30, hard-capped at 100.
#[serde(default)]
limit: Option<i64>,
/// Opaque cursor returned by a previous call.
#[serde(default)]
cursor: Option<String>,
}
const DEFAULT_LIMIT: i64 = 30;
const MAX_LIMIT: i64 = 100;
/// Hard cap on the number of DIDs we'll filter a timeline query by.
///
/// Prevents an unbounded `did = ANY($1::text[])` array from a power
/// user with thousands of follows — SQL injection isn't the worry
/// (the bind is parameterised) but a 50k-element array still has to be
/// shipped across the wire and parsed by Postgres on every page
/// request. 1000 covers essentially every realistic follow graph; if
/// a user exceeds it we cap deterministically (sorted by DID) so the
/// result set is stable across requests, and we always keep the
/// requesting user's own DID in the set so their own posts still
/// surface.
const MAX_FOLLOWED_DIDS: usize = 1000;
async fn timeline_home(
State(state): State<AppState>,
Query(q): Query<TimelineQuery>,
) -> Result<Json<TimelineResponse>, (StatusCode, Json<Value>)> {
if q.did.is_empty() {
return Err(bad_request("did is required"));
}
let limit = q
.limit
.unwrap_or(DEFAULT_LIMIT)
.clamp(1, MAX_LIMIT);
// Look up the set of DIDs this user follows, then build the
// `target_dids` set we'll filter `posts` by:
//
// 1. Start with the followees from the `follows` table.
// 2. Add the user's own DID so they always see their own posts.
// 3. Deduplicate.
// 4. Cap at MAX_FOLLOWED_DIDS. If we'd exceed the cap, sort by
// DID (stable across requests), trim to the cap, then if the
// requesting user's DID was trimmed out and there's still
// room in the cap, put them back in. This guarantees the
// user's own posts are visible regardless of follow graph
// size.
//
// If the followee set is empty *and* adding the user's own DID
// leaves us with only that one entry, we treat it as the cold
// start case and fall back to the global recent feed — new users
// see the world before they have a graph.
let followed_dids: Vec<String> = sqlx::query_scalar(
"SELECT subject_did FROM follows WHERE follower_did = $1",
)
.bind(&q.did)
.fetch_all(&state.db)
.await
.map_err(db_err)?;
// Build the deduped target set. `followed_dids` may contain the
// user's own DID (self-follow is rare but legal in the protocol),
// so we dedup with full sort+dedup rather than relying on a
// presence-check.
let has_real_follows = followed_dids.iter().any(|d| d != &q.did);
let mut target_dids: Vec<String> = if has_real_follows {
let mut v = followed_dids;
v.push(q.did.clone());
v.sort();
v.dedup();
v
} else {
// Cold start: no real follow graph yet. Fall through to the
// global-recent branch below.
vec![]
};
// Cap deterministically. After capping, the user's own DID must
// still be in the set — that's a hard invariant for "user sees
// their own posts".
if target_dids.len() > MAX_FOLLOWED_DIDS {
// Drop own, sort, trim to leave room for own, re-add own.
// Sorting makes the truncation deterministic (we drop the
// lex-greatest (N MAX_FOLLOWED_DIDS) followees, not a random
// subset).
let own = q.did.clone();
target_dids.retain(|d| d != &own);
target_dids.sort();
// Reserve one slot for `own` so the final length is exactly
// MAX_FOLLOWED_DIDS.
target_dids.truncate(MAX_FOLLOWED_DIDS - 1);
target_dids.push(own);
target_dids.sort();
}
let decode = match q.cursor.as_deref().map(cursor::decode) {
Some(Ok(c)) => Some(c),
Some(Err(e)) => return Err(bad_request(&e)),
None => None,
};
// Convert the cursor's microsecond timestamp to a DateTime<Utc> so
// sqlx binds it as `timestamptz` rather than `bigint` (which would
// fail the `(indexed_at, uri) < ($1, $2)` row comparison).
//
// If the timestamp is out of chrono::Utc's representable range
// (e.g. i64::MAX from a malicious cursor) we reject with 400 instead
// of silently falling back to page 1, which would lose the user's
// pagination state.
let cursor_ts: Option<DateTime<Utc>> = match decode.as_ref() {
Some(c) => Some(
chrono::Utc
.timestamp_micros(c.ts)
.single()
.ok_or_else(|| bad_request("invalid cursor timestamp"))?,
),
None => None,
};
let cursor_uri: Option<String> =
decode.as_ref().map(|c| c.uri.clone());
// Fetch limit+1 to know if there's a next page without a second
// round trip.
let fetch = limit + 1;
// We select `indexed_at` alongside the post row so the cursor
// builder can use it directly without a second round trip. The
// helper inner function collapses the four (followed? + cursor?)
// branches into one query_as per shape.
// Cold-start branch: user has no real follow graph (only self, or
// nothing). Show the global recent feed.
let mut rows: Vec<PostRowWithIndexed> = if target_dids.is_empty() {
match cursor_ts {
Some(ts) => sqlx::query_as::<_, PostRowWithIndexed>(
r#"SELECT uri, did, handle, rkey, collection, text, cid,
parent_uri, root_uri, embed, langs, created_at,
indexed_at
FROM posts
WHERE collection IN ('app.twi.post','app.bsky.feed.post')
AND (indexed_at, uri) < ($1, $2)
ORDER BY indexed_at DESC, uri DESC
LIMIT $3"#,
)
.bind(ts)
.bind(cursor_uri.as_deref().unwrap())
.bind(fetch)
.fetch_all(&state.db)
.await
.map_err(db_err)?,
None => sqlx::query_as::<_, PostRowWithIndexed>(
r#"SELECT uri, did, handle, rkey, collection, text, cid,
parent_uri, root_uri, embed, langs, created_at,
indexed_at
FROM posts
WHERE collection IN ('app.twi.post','app.bsky.feed.post')
ORDER BY indexed_at DESC, uri DESC
LIMIT $1"#,
)
.bind(fetch)
.fetch_all(&state.db)
.await
.map_err(db_err)?,
}
} else {
// Graph-aware branch: filter `posts.did` to the followee set
// plus the requesting user's own DID. `target_dids` has been
// deduped and capped at MAX_FOLLOWED_DIDS, and the user's own
// DID is guaranteed to be in the set.
match cursor_ts {
Some(ts) => sqlx::query_as::<_, PostRowWithIndexed>(
r#"SELECT uri, did, handle, rkey, collection, text, cid,
parent_uri, root_uri, embed, langs, created_at,
indexed_at
FROM posts
WHERE collection IN ('app.twi.post','app.bsky.feed.post')
AND did = ANY($2::text[])
AND (indexed_at, uri) < ($3, $4)
ORDER BY indexed_at DESC, uri DESC
LIMIT $1"#,
)
.bind(fetch)
.bind(&target_dids)
.bind(ts)
.bind(cursor_uri.as_deref().unwrap())
.fetch_all(&state.db)
.await
.map_err(db_err)?,
None => sqlx::query_as::<_, PostRowWithIndexed>(
r#"SELECT uri, did, handle, rkey, collection, text, cid,
parent_uri, root_uri, embed, langs, created_at,
indexed_at
FROM posts
WHERE collection IN ('app.twi.post','app.bsky.feed.post')
AND did = ANY($2::text[])
ORDER BY indexed_at DESC, uri DESC
LIMIT $1"#,
)
.bind(fetch)
.bind(&target_dids)
.fetch_all(&state.db)
.await
.map_err(db_err)?,
}
};
// We fetched limit+1 to peek for a next page. If we got more than
// `limit`, drop the extra and remember its `indexed_at` to encode
// into the next cursor.
let next_ts_uri: Option<(DateTime<Utc>, String)> = if rows.len() as i64 > limit {
rows.truncate(limit as usize);
rows.last()
.map(|p| (p.indexed_at, p.uri.clone()))
} else {
None
};
// Strip the `indexed_at` companion column from the response.
let mut posts: Vec<PostRow> = rows.into_iter().map(Into::into).collect();
// Derive a `display_handle` for posts that have an empty `handle`
// column (Jetstream-only path). We mutate a clone with a populated
// `handle` so the client doesn't have to guess.
decorate_handles(&mut posts);
let next_cursor = next_ts_uri
.map(|(ts, uri)| cursor::encode(ts, &uri));
Ok(Json(TimelineResponse {
posts,
cursor: next_cursor,
}))
}
// -- profile ----------------------------------------------------------------
#[derive(Debug, Deserialize)]
struct ProfileQuery {
#[serde(default)]
did: Option<String>,
#[serde(default)]
handle: Option<String>,
}
async fn profile_query(
State(state): State<AppState>,
Query(q): Query<ProfileQuery>,
) -> Result<Json<ProfileResponse>, (StatusCode, Json<Value>)> {
let did = q.did.clone().or(q.handle.clone());
let handle = q.handle.clone();
resolve_profile(&state, did.as_deref(), handle.as_deref()).await
}
async fn profile_path(
State(state): State<AppState>,
Path(handle): Path<String>,
) -> Result<Json<ProfileResponse>, (StatusCode, Json<Value>)> {
resolve_profile(&state, None, Some(&handle)).await
}
async fn resolve_profile(
state: &AppState,
did: Option<&str>,
handle: Option<&str>,
) -> Result<Json<ProfileResponse>, (StatusCode, Json<Value>)> {
// Reject empty params up front — otherwise the empty-string DID/handle
// produces a valid-looking 200 with zero posts.
if let Some(d) = did {
if d.is_empty() {
return Err(bad_request("did is required"));
}
}
if let Some(h) = handle {
if h.is_empty() {
return Err(bad_request("handle is required"));
}
}
// Strip a leading '@' on the handle — the UI passes `@alice` style.
let handle_clean = handle.map(|h| h.trim_start_matches('@').to_string());
// Try by DID first if we have it; fall back to handle lookup.
let target_did: Option<String> = if let Some(d) = did {
Some(d.to_string())
} else if let Some(ref h) = handle_clean {
// Order by `indexed_at DESC` so we get the most recent DID for
// this handle (a single user can re-use a handle if account
// history allows, but the latest is the active one).
sqlx::query_scalar::<_, String>(
"SELECT did FROM posts WHERE handle = $1 ORDER BY indexed_at DESC LIMIT 1",
)
.bind(h)
.fetch_optional(&state.db)
.await
.map_err(db_err)?
} else {
None
};
let Some(target_did) = target_did else {
return Err((
StatusCode::NOT_FOUND,
Json(json!({
"error": "NotFound",
"message": "no DID or handle provided, or no posts for that handle",
})),
));
};
// Fetch the user's most recent posts (newest first). We return up to
// 50 — enough for a profile view, and the client can paginate with
// /api/timeline/home if it needs more.
let mut posts: Vec<PostRow> = sqlx::query_as::<_, PostRow>(
r#"SELECT uri, did, handle, rkey, collection, text, cid,
parent_uri, root_uri, embed, langs, created_at,
like_count, repost_count
FROM posts
WHERE did = $1
AND collection IN ('app.twi.post','app.bsky.feed.post')
ORDER BY indexed_at DESC, uri DESC
LIMIT 50"#,
)
.bind(&target_did)
.fetch_all(&state.db)
.await
.map_err(db_err)?;
// Pick the best available handle: explicit query handle, then the
// first non-empty handle from the posts we just pulled.
let display_handle = handle_clean
.clone()
.or_else(|| {
posts
.iter()
.find(|p| !p.handle.is_empty())
.map(|p| p.handle.clone())
})
.unwrap_or_else(|| {
// Last-resort synthetic handle. The schema note says
// "@<did-snip>" is acceptable; we keep it short and safe.
short_did_for_display(&target_did)
});
decorate_handles(&mut posts);
let followers: i64 = sqlx::query_scalar(
"SELECT COUNT(*)::BIGINT FROM follows WHERE subject_did = $1",
)
.bind(&target_did)
.fetch_one(&state.db)
.await
.map_err(db_err)?;
let following: i64 = sqlx::query_scalar(
"SELECT COUNT(*)::BIGINT FROM follows WHERE follower_did = $1",
)
.bind(&target_did)
.fetch_one(&state.db)
.await
.map_err(db_err)?;
Ok(Json(ProfileResponse {
did: target_did,
handle: display_handle,
posts,
followers,
following,
}))
}
// -- post by uri (thread hydration) ----------------------------------------
#[derive(Debug, Serialize)]
struct ThreadResponse {
post: Option<PostRow>,
thread: ThreadView,
#[serde(skip_serializing_if = "Option::is_none")]
like_count: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
repost_count: Option<i64>,
/// `true` when the requesting viewer (`viewer_did` query param)
/// has a like row for this post. `None` if the viewer was not
/// specified — the client should treat `None` as "unknown, hide
/// the liked state" rather than "not liked". (Phase 5b review
/// fix H4 — without this, the UI shows a generic count but
/// can't tell whether the user has already liked the post.)
#[serde(skip_serializing_if = "Option::is_none")]
viewer_liked: Option<bool>,
/// Same as `viewer_liked` but for reposts.
#[serde(skip_serializing_if = "Option::is_none")]
viewer_reposted: Option<bool>,
}
#[derive(Debug, Serialize)]
struct ThreadView {
parent: Option<PostRow>,
root: Option<PostRow>,
}
/// Optional query parameters for `/api/post/{uri}`. The Tauri client
/// passes its current session DID so the response can include
/// `viewer_liked` / `viewer_reposted` booleans.
#[derive(Debug, Default, Deserialize)]
struct PostQuery {
#[serde(default)]
viewer_did: Option<String>,
}
/// `GET /api/post/{uri}` — single-post lookup with parent + root
/// hydrated in one round trip.
///
/// Used by the UI when the user clicks a "show thread" link on a reply:
/// rather than three sequential fetches (`/api/post/{uri}` → fetch parent
/// → fetch root), the server returns the post plus its `parent_uri` /
/// `root_uri` rows in one go. Missing parents / roots come back as
/// `null` so the client can render "unknown / not in index" without
/// retrying.
///
/// When the post is found, the response also includes `like_count`
/// and `repost_count` from the `likes` / `reposts` tables so the UI
/// can render engagement numbers next to the action buttons. (We
/// skip the count queries entirely when the post is missing so
/// the "not in index" path stays cheap.)
///
/// When the caller supplies `viewer_did`, the response also includes
/// `viewer_liked` / `viewer_reposted` so the client can highlight the
/// like/repost button when the viewer has already engaged. Lookups
/// run as a single `EXISTS` query each, hitting the
/// `likes_did_post_uri_idx` / `reposts_did_post_uri_idx` unique
/// indexes, so the cost is O(1) per viewer.
///
/// The path parameter is captured by `axum::extract::Path` as the raw
/// remainder after `/api/post/`, so the colons in a `did:plc:…` URI are
/// preserved verbatim — we never need URL decoding.
async fn post_by_uri(
State(state): State<AppState>,
Path(uri): Path<String>,
Query(q): Query<PostQuery>,
) -> Result<Json<ThreadResponse>, (StatusCode, Json<Value>)> {
if uri.is_empty() {
return Err(bad_request("uri is required"));
}
let uri = percent_decode(&uri);
let post: Option<PostRow> = sqlx::query_as::<_, PostRow>(
r#"SELECT uri, did, handle, rkey, collection, text, cid,
parent_uri, root_uri, embed, langs, created_at,
like_count, repost_count
FROM posts
WHERE uri = $1
LIMIT 1"#,
)
.bind(&uri)
.fetch_optional(&state.db)
.await
.map_err(db_err)?;
// Pull the two refs off the row before we move it into the response
// — `post` is consumed by the `Some(p)` arm but we still need
// `parent_uri` / `root_uri` for the hydration lookups.
let (parent_uri, root_uri, like_count, repost_count) = match post.as_ref() {
Some(p) => (
p.parent_uri.clone(),
p.root_uri.clone(),
Some(p.like_count),
Some(p.repost_count),
),
None => (None, None, None, None),
};
let parent: Option<PostRow> = match parent_uri.as_deref() {
Some(u) => fetch_one_post(&state, u).await?,
None => None,
};
let root: Option<PostRow> = match root_uri.as_deref() {
Some(u) if Some(u) != parent_uri.as_deref() => {
fetch_one_post(&state, u).await?
}
// Self-thread (single-post thread): `root` == `parent`. Avoid the
// duplicate fetch — surface the parent row as the root too so the
// UI can render the chain without an extra round trip.
Some(_) => parent.clone(),
None => None,
};
// Viewer-scoped engagement state. We only run these queries when
// (a) the post was found (otherwise `None` so the UI can ignore
// viewer state on a missing post) and (b) the caller actually
// passed a viewer_did.
let (viewer_liked, viewer_reposted) = if post.is_some() {
if let Some(viewer) = q.viewer_did.as_deref() {
let liked: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM likes WHERE did = $1 AND post_uri = $2)",
)
.bind(viewer)
.bind(&uri)
.fetch_one(&state.db)
.await
.map_err(db_err)?;
let reposted: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM reposts WHERE did = $1 AND post_uri = $2)",
)
.bind(viewer)
.bind(&uri)
.fetch_one(&state.db)
.await
.map_err(db_err)?;
(Some(liked), Some(reposted))
} else {
(None, None)
}
} else {
(None, None)
};
// We hand `parent` / `root` to the response and `post` last so the
// borrow on `parent_uri` / `root_uri` is already released.
Ok(Json(ThreadResponse {
post,
thread: ThreadView { parent, root },
like_count,
repost_count,
viewer_liked,
viewer_reposted,
}))
}
async fn fetch_one_post(
state: &AppState,
uri: &str,
) -> Result<Option<PostRow>, (StatusCode, Json<Value>)> {
sqlx::query_as::<_, PostRow>(
r#"SELECT uri, did, handle, rkey, collection, text, cid,
parent_uri, root_uri, embed, langs, created_at,
like_count, repost_count
FROM posts
WHERE uri = $1
LIMIT 1"#,
)
.bind(uri)
.fetch_optional(&state.db)
.await
.map_err(db_err)
}
/// Minimal percent-decode for the path capture. axum's `Path<String>`
/// already decodes percent-escapes for us, so this is a no-op for
/// the happy path. It only fires if the URI itself contains a stray
/// `%XX` sequence the caller wants kept verbatim (e.g. a literal `%`
/// in a path component, which we don't have here).
#[allow(dead_code)]
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(h), Some(l)) = (
hex_digit(bytes[i + 1]),
hex_digit(bytes[i + 2]),
) {
out.push((h * 16 + l) as char);
i += 3;
continue;
}
}
// Push ASCII bytes verbatim; for any multi-byte UTF-8 sequence
// we push the lead byte as a char (which is valid because the
// resulting char's code point is `< 128` only for ASCII). For
// non-ASCII bytes the call sites never reach this branch
// because `Path<String>` already decoded the URI for us.
out.push(bytes[i] as char);
i += 1;
}
out
}
#[allow(dead_code)]
fn hex_digit(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
// -- search -----------------------------------------------------------------
#[derive(Debug, Deserialize)]
struct SearchQuery {
q: String,
#[serde(default)]
limit: Option<i64>,
}
async fn search(
State(state): State<AppState>,
Query(q): Query<SearchQuery>,
) -> Result<Json<SearchResponse>, (StatusCode, Json<Value>)> {
let needle = q.q.trim();
if needle.is_empty() {
return Err(bad_request("q is required"));
}
// Cap query length to avoid pathological ILIKE patterns on huge input.
if needle.len() > 100 {
return Err(bad_request("q must be at most 100 characters"));
}
let limit = q.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT);
// ILIKE search. We escape LIKE wildcards in the user input so a
// search for "10%" doesn't suddenly act as a glob.
let escaped = escape_like(needle);
let pattern = format!("%{escaped}%");
let mut posts: Vec<PostRow> = sqlx::query_as::<_, PostRow>(
r#"SELECT uri, did, handle, rkey, collection, text, cid,
parent_uri, root_uri, embed, langs, created_at,
like_count, repost_count
FROM posts
WHERE text ILIKE $1 ESCAPE '\'
AND collection IN ('app.twi.post','app.bsky.feed.post')
ORDER BY indexed_at DESC
LIMIT $2"#,
)
.bind(&pattern)
.bind(limit)
.fetch_all(&state.db)
.await
.map_err(db_err)?;
decorate_handles(&mut posts);
Ok(Json(SearchResponse {
posts,
q: needle.to_string(),
}))
}
// -- healthz ----------------------------------------------------------------
async fn healthz(State(state): State<AppState>) -> impl IntoResponse {
let stats = &state.stats;
Json(json!({
"ok": true,
"lag_ms": stats.lag_ms(),
"events_processed": stats.events_processed(),
"jetstream_connected": stats.jetstream_connected(),
}))
}
// -- helpers ----------------------------------------------------------------
/// Fill in a synthetic `handle` for posts that have an empty one
/// (Jetstream-indexed posts without a handle backfill). Operates in
/// place. We deliberately do not mutate the database row — this is a
/// display-time concern only.
fn decorate_handles(posts: &mut [PostRow]) {
for p in posts.iter_mut() {
if p.handle.is_empty() {
p.handle = short_did_for_display(&p.did);
}
}
}
fn short_did_for_display(did: &str) -> String {
// Match the spec: "@{first-12-chars-of-did}…". Use char-based slicing
// so we never panic on a UTF-8 boundary (e.g. `did:web:münchen.de`).
let snip: String = did.chars().take(12).collect();
format!("@{snip}")
}
/// Escape `%`, `_`, and `\` for use inside a `LIKE ... ESCAPE '\'`
/// pattern.
fn escape_like(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' | '%' | '_' => {
out.push('\\');
out.push(c);
}
other => out.push(other),
}
}
out
}
fn db_err(e: impl std::fmt::Display) -> (StatusCode, Json<Value>) {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": "InternalServerError",
"message": e.to_string(),
})),
)
}
fn bad_request(msg: &str) -> (StatusCode, Json<Value>) {
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": msg,
})),
)
}
// -- tests ------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escape_like_handles_wildcards() {
assert_eq!(escape_like("hello"), "hello");
assert_eq!(escape_like("100%"), "100\\%");
assert_eq!(escape_like("a_b"), "a\\_b");
assert_eq!(escape_like("back\\slash"), "back\\\\slash");
}
#[test]
fn short_did_format_matches_spec() {
let s = short_did_for_display("did:plc:abcdefghijklmnop");
assert_eq!(s, "@did:plc:abcd…");
let s = short_did_for_display("short");
assert_eq!(s, "@short…");
}
#[test]
fn percent_decode_handles_common_escapes() {
assert_eq!(
percent_decode("at%3A%2F%2Fdid%3Aplc%3Aabc"),
"at://did:plc:abc"
);
// No escapes → identity.
assert_eq!(percent_decode("at://did:plc:abc"), "at://did:plc:abc");
// Truncated `%` at the end → kept verbatim (don't crash).
assert_eq!(percent_decode("abc%"), "abc%");
// Non-hex after `%` → kept verbatim.
assert_eq!(percent_decode("abc%zz"), "abc%zz");
// Mixed case hex digits.
assert_eq!(percent_decode("a%2Bb"), "a+b");
assert_eq!(percent_decode("a%2bb"), "a+b");
}
#[test]
fn decorate_handles_fills_empty_only() {
let mut rows = vec![
PostRow {
uri: "at://x/app.twi.post/1".into(),
did: "did:plc:abcdefghij".into(),
handle: String::new(),
rkey: "1".into(),
collection: "app.twi.post".into(),
text: "hi".into(),
cid: "c".into(),
parent_uri: None,
root_uri: None,
embed: None,
langs: crate::routes::types::Langs(vec![]),
created_at: Utc::now(),
like_count: 0,
repost_count: 0,
},
PostRow {
uri: "at://x/app.twi.post/2".into(),
did: "did:plc:abc".into(),
handle: "alice".into(),
rkey: "2".into(),
collection: "app.twi.post".into(),
text: "hi".into(),
cid: "c".into(),
parent_uri: None,
root_uri: None,
embed: None,
langs: crate::routes::types::Langs(vec![]),
created_at: Utc::now(),
like_count: 0,
repost_count: 0,
},
];
decorate_handles(&mut rows);
assert!(rows[0].handle.starts_with('@'));
assert!(rows[0].handle.ends_with('…'));
assert_eq!(rows[1].handle, "alice");
}
}
+110
View File
@@ -0,0 +1,110 @@
//! Opaque pagination cursor for `GET /api/timeline/home`.
//!
//! The cursor is a base64url-encoded `<indexed_at_micros>:<post_uri>`
//! pair. The format is intentionally not stable across releases — it's
//! an implementation detail of the API. The client must treat it as
//! an opaque string and pass it back unchanged.
//!
//! `decode` returns [`CursorState`] which the route uses to build a
//! `WHERE (indexed_at, uri) < ($1, $2)` predicate for stable
//! keyset pagination (avoids `OFFSET` drift when new posts land
//! between page fetches).
use base64::Engine;
use chrono::{DateTime, TimeZone, Utc};
/// Internal decoded representation of a timeline cursor.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CursorState {
/// Microseconds since the unix epoch of the last seen post.
pub ts: i64,
/// URI of the last seen post.
pub uri: String,
}
/// Encode `(indexed_at, uri)` into the wire-format cursor string.
pub fn encode(indexed_at: DateTime<Utc>, uri: &str) -> String {
let ts = indexed_at.timestamp_micros();
let raw = format!("{ts}:{uri}");
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw.as_bytes())
}
/// Decode a wire-format cursor string back into a [`CursorState`].
///
/// Returns an error string (not a typed error) so the route can put it
/// directly into the 400 response body. The `Result` type is local.
pub fn decode(s: &str) -> Result<CursorState, String> {
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(s.as_bytes())
.map_err(|e| format!("invalid cursor: {e}"))?;
let text = std::str::from_utf8(&bytes).map_err(|e| format!("invalid cursor utf8: {e}"))?;
let (ts_s, uri) = text
.split_once(':')
.ok_or_else(|| "invalid cursor: missing ':'".to_string())?;
let ts: i64 = ts_s
.parse()
.map_err(|e| format!("invalid cursor ts: {e}"))?;
Ok(CursorState {
ts,
uri: uri.to_string(),
})
}
/// Helper for tests / callers that want a `DateTime<Utc>` back from a
/// [`CursorState`]. The route doesn't need it (it uses the raw
/// micros), but exposing it keeps the API symmetric.
#[allow(dead_code)]
pub fn ts_to_datetime(ts: i64) -> Option<DateTime<Utc>> {
Utc.timestamp_micros(ts).single()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_decode_round_trip() {
let dt = Utc
.timestamp_micros(1_700_000_000_123_456)
.single()
.expect("valid ts");
let uri = "at://did:plc:abc/app.twi.post/3k2";
let encoded = encode(dt, uri);
// Encoded form is base64url (no '+' / '/') and unpadded.
assert!(!encoded.contains('='));
assert!(!encoded.contains('+'));
assert!(!encoded.contains('/'));
let decoded = decode(&encoded).unwrap();
assert_eq!(decoded.ts, dt.timestamp_micros());
assert_eq!(decoded.uri, uri);
}
#[test]
fn decode_rejects_garbage() {
assert!(decode("!!!not-base64!!!").is_err());
assert!(decode("").is_err());
// Valid base64 but missing colon
let no_colon = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(b"1234567890");
assert!(decode(&no_colon).is_err());
}
#[test]
fn decode_rejects_non_numeric_ts() {
let bad = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(b"notanumber:at://x");
assert!(decode(&bad).is_err());
}
#[test]
fn ts_round_trip_through_datetime() {
let dt = Utc
.timestamp_micros(1_700_000_000_000_001)
.single()
.expect("valid ts");
let encoded = encode(dt, "at://x/y/z");
let decoded = decode(&encoded).unwrap();
let back = ts_to_datetime(decoded.ts).unwrap();
assert_eq!(back, dt);
}
}
+217
View File
@@ -0,0 +1,217 @@
//! Wire types for the AppView's read API.
//!
//! These structs are the exact JSON shape the Tauri client and any other
//! consumer sees. They are `Serialize` for the HTTP response and
//! `FromRow` for the SQL row, which is why each field is a flat
//! primitive or `Vec<String>`.
//!
//! `langs` is stored in the DB as a nullable `TEXT[]` (see the
//! `posts.langs` column in `migrations/appview/0001_init.sql`). The
//! wire format, however, guarantees `Vec<String>` — never `null` — so
//! we use a custom `sqlx::Decode` impl via the `Langs` newtype that
//! collapses `NULL` and an empty array into `vec![]`.
//!
//! `embed` is stored as nullable `JSONB` and round-trips as
//! `Option<serde_json::Value>` — the UI sniffs `$type` to decide
//! which sub-component to render (`app.bsky.embed.images` etc.).
use chrono::{DateTime, Utc};
use serde::Serialize;
use serde_json::Value;
use sqlx::{Decode, FromRow, Postgres, Row, Type, ValueRef};
use crate::indexer::EmbedColumn;
/// One row of `posts` as returned by the read API.
///
/// `handle` may be empty for posts indexed via Jetstream (we don't
/// currently back-resolve the DID); callers should display `@<handle>`
/// and fall back to a derived value from the DID when this is empty.
///
/// `embed` is the verbatim AT-Protocol embed object — `None` for
/// plain-text posts.
///
/// `like_count` / `repost_count` are denormalized counters maintained
/// by `upsert_like` / `upsert_repost` against the migration-0004
/// unique index. They are read with zero extra SQL when the row is
/// fetched (just one more column), so they scale even when the
/// `likes` / `reposts` tables have 100k+ rows.
#[derive(Debug, Clone, Serialize)]
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: Langs,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub like_count: i64,
#[serde(default)]
pub repost_count: i64,
}
/// Raw `FromRow` impl — we read `embed` as the helper newtype then
/// unwrap it to `Option<Value>` so the wire shape stays clean.
impl<'r> FromRow<'r, sqlx::postgres::PgRow> for PostRow {
fn from_row(row: &'r sqlx::postgres::PgRow) -> sqlx::Result<Self> {
let embed: EmbedColumn = row.try_get("embed")?;
Ok(PostRow {
uri: row.try_get("uri")?,
did: row.try_get("did")?,
handle: row.try_get("handle")?,
rkey: row.try_get("rkey")?,
collection: row.try_get("collection")?,
text: row.try_get("text")?,
cid: row.try_get("cid")?,
parent_uri: row.try_get("parent_uri")?,
root_uri: row.try_get("root_uri")?,
embed: embed.0,
langs: row.try_get("langs")?,
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),
})
}
}
/// Internal companion row used by the timeline cursor builder: the
/// `PostRow` payload plus the post's `indexed_at` so the route can
/// encode the next cursor without a second SELECT. Not serialised.
#[derive(Debug, Clone)]
pub struct PostRowWithIndexed {
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: Langs,
pub created_at: DateTime<Utc>,
pub indexed_at: DateTime<Utc>,
pub like_count: i64,
pub repost_count: i64,
}
impl<'r> FromRow<'r, sqlx::postgres::PgRow> for PostRowWithIndexed {
fn from_row(row: &'r sqlx::postgres::PgRow) -> sqlx::Result<Self> {
let embed: EmbedColumn = row.try_get("embed")?;
Ok(PostRowWithIndexed {
uri: row.try_get("uri")?,
did: row.try_get("did")?,
handle: row.try_get("handle")?,
rkey: row.try_get("rkey")?,
collection: row.try_get("collection")?,
text: row.try_get("text")?,
cid: row.try_get("cid")?,
parent_uri: row.try_get("parent_uri")?,
root_uri: row.try_get("root_uri")?,
embed: embed.0,
langs: row.try_get("langs")?,
created_at: row.try_get("created_at")?,
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),
})
}
}
impl From<PostRowWithIndexed> for PostRow {
fn from(r: PostRowWithIndexed) -> Self {
PostRow {
uri: r.uri,
did: r.did,
handle: r.handle,
rkey: r.rkey,
collection: r.collection,
text: r.text,
cid: r.cid,
parent_uri: r.parent_uri,
root_uri: r.root_uri,
embed: r.embed,
langs: r.langs,
created_at: r.created_at,
like_count: r.like_count,
repost_count: r.repost_count,
}
}
}
/// `GET /api/timeline/home` response. `cursor` is `None` when the
/// caller has reached the end of the available rows.
#[derive(Debug, Serialize)]
pub struct TimelineResponse {
pub posts: Vec<PostRow>,
pub cursor: Option<String>,
}
/// `GET /api/profile/...` response.
#[derive(Debug, Serialize)]
pub struct ProfileResponse {
pub did: String,
pub handle: String,
pub posts: Vec<PostRow>,
pub followers: i64,
pub following: i64,
}
/// `GET /api/search` response. `q` echoes the search string so the
/// client can correlate the request with the response.
#[derive(Debug, Serialize)]
pub struct SearchResponse {
pub posts: Vec<PostRow>,
pub q: String,
}
// -- Langs newtype ----------------------------------------------------------
/// A list of language tags. Always serialises as `Vec<String>`, never
/// as `null`. Decodes a nullable `TEXT[]` column into an empty vector
/// when the column is SQL `NULL`, and otherwise parses the array.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Langs(pub Vec<String>);
impl From<Vec<String>> for Langs {
fn from(v: Vec<String>) -> Self {
Langs(v)
}
}
impl Serialize for Langs {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
self.0.serialize(s)
}
}
impl<'r> Decode<'r, Postgres> for Langs {
fn decode(
value: <Postgres as sqlx::Database>::ValueRef<'r>,
) -> Result<Self, sqlx::error::BoxDynError> {
// A `TEXT[]` column can come back as NULL (Option<Vec<String>>)
// or as a real array. We collapse both into `Langs(vec![])` when
// there are no elements, so the wire shape is always an array.
if value.is_null() {
return Ok(Langs(Vec::new()));
}
let raw: Option<Vec<String>> = <Option<Vec<String>> as Decode<Postgres>>::decode(value)?;
Ok(Langs(raw.unwrap_or_default()))
}
}
impl Type<Postgres> for Langs {
fn type_info() -> <Postgres as sqlx::Database>::TypeInfo {
<Vec<String> as Type<Postgres>>::type_info()
}
fn compatible(ty: &<Postgres as sqlx::Database>::TypeInfo) -> bool {
<Vec<String> as Type<Postgres>>::compatible(ty)
}
}
+19
View File
@@ -0,0 +1,19 @@
use at_shared::config::AppConfig;
use sqlx::PgPool;
use std::sync::Arc;
use crate::firehose::Stats;
#[derive(Clone)]
pub struct AppState {
#[allow(dead_code)]
pub cfg: AppConfig,
pub db: PgPool,
pub stats: Arc<Stats>,
}
impl AppState {
pub fn new(cfg: AppConfig, db: PgPool, stats: Arc<Stats>) -> Self {
Self { cfg, db, stats }
}
}
+619
View File
@@ -0,0 +1,619 @@
//! Integration tests for the new read API routes
//! (`/api/timeline/home`, `/api/profile/...`, `/api/search`).
//!
//! These run against a live appview service + DB. Like
//! `appview_integration.rs`, they're fail-open: if the service or DB
//! isn't reachable, the test prints a notice and returns success
//! rather than panicking — so `cargo test --workspace` stays green in
//! environments where the appview hasn't been started.
use serde_json::{json, Value};
use std::time::Duration;
const APPVIEW_URL: &str = "http://127.0.0.1:2584";
async fn client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap()
}
async fn wait_for_appview_db() -> bool {
let c = client().await;
for _ in 0..20 {
if let Ok(r) = c.get(format!("{APPVIEW_URL}/healthz")).send().await {
if r.status().is_success() {
return true;
}
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
false
}
async fn try_db_url() -> Option<String> {
std::env::var("DATABASE_URL_APPVIEW").ok()
}
async fn db_reachable() -> bool {
let Some(url) = try_db_url().await else {
return false;
};
matches!(
tokio::time::timeout(Duration::from_secs(2), sqlx::PgPool::connect(&url)).await,
Ok(Ok(_))
)
}
async fn post_ingest(c: &reqwest::Client, body: Value) -> reqwest::Response {
c.post(format!("{APPVIEW_URL}/internal/ingest-commit"))
.json(&body)
.send()
.await
.unwrap()
}
/// Insert a follow row directly via the DB. We bypass the ingest
/// endpoint because (a) 1500 individual HTTP round-trips are
/// prohibitively slow for the cap test, and (b) we don't need the
/// indexer to also re-resolve handles etc. for this test.
async fn insert_follow(
pool: &sqlx::PgPool,
follower_did: &str,
subject_did: &str,
) {
sqlx::query(
r#"INSERT INTO follows (follower_did, subject_did, created_at)
VALUES ($1, $2, now())
ON CONFLICT (follower_did, subject_did) DO NOTHING"#,
)
.bind(follower_did)
.bind(subject_did)
.execute(pool)
.await
.unwrap();
}
/// Seed N posts for a DID with sequential rkeys and `created_at`
/// timestamps that strictly increase, so the cursor ordering test is
/// deterministic.
async fn seed_posts(c: &reqwest::Client, did: &str, texts: &[&str]) {
for (i, text) in texts.iter().enumerate() {
let r = post_ingest(
c,
json!({
"did": did,
"collection": "app.twi.post",
"action": "create",
"rkey": rkey(),
"cid": "bafyreicid",
"record": {
"text": text,
"createdAt": format!("2026-07-01T12:00:{:02}Z", i),
}
}),
)
.await;
assert_eq!(r.status().as_u16(), 200);
}
}
fn did_for_test(name: &str) -> String {
// Random per-test DID so the tests can run in parallel without
// colliding on URI primary keys.
format!("did:plc:test_{}_{}", name, uuid::Uuid::new_v4().simple())
}
fn rkey() -> String {
uuid::Uuid::new_v4().simple().to_string()
}
#[tokio::test]
async fn timeline_returns_seeded_posts() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
if !db_reachable().await {
eprintln!("appview DB unreachable, skipping");
return;
}
let c = client().await;
let did = did_for_test("tl");
// Seed 3 posts with distinct rkeys.
for i in 0..3 {
let r = post_ingest(
&c,
json!({
"did": did,
"collection": "app.twi.post",
"action": "create",
"rkey": rkey(),
"cid": "bafyreicid",
"record": {
"text": format!("seeded post #{i}"),
"createdAt": "2026-07-01T12:00:00Z",
}
}),
)
.await;
assert_eq!(r.status().as_u16(), 200);
}
// Give Jetstream / ingest a beat to settle — `indexed_at` defaults
// to `now()` on insert, so we want a non-zero chance of seeing all
// three rows in the first page.
tokio::time::sleep(Duration::from_millis(50)).await;
let resp = c
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[("did", did.as_str()), ("limit", "10")])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
let posts = body["posts"].as_array().expect("posts is array");
assert!(posts.len() >= 3, "expected >=3 posts, got {}", posts.len());
// All three seeded posts must be in the response and all share the
// same DID.
let our_uris: Vec<&str> = posts
.as_slice()
.iter()
.filter_map(|p| {
let uri = p["uri"].as_str()?;
if uri.starts_with(&format!("at://{did}/")) {
Some(uri)
} else {
None
}
})
.collect();
assert!(our_uris.len() >= 3, "missing our seeded posts in {posts:?}");
// Posts must be sorted with `indexed_at DESC`. We can't see
// indexed_at directly in the response, but the URI order in
// `app.twi.post/<rkey>` is rkey-random here, so we only assert
// `created_at` is non-increasing.
let mut prev: Option<String> = None;
for p in posts {
let ca = p["createdAt"].as_str().unwrap().to_string();
if let Some(p) = prev.take() {
assert!(ca <= p, "createdAt must be non-increasing: {ca} <= {p}");
}
prev = Some(ca);
}
}
#[tokio::test]
async fn timeline_paginates_with_cursor() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
if !db_reachable().await {
eprintln!("appview DB unreachable, skipping");
return;
}
let c = client().await;
let did = did_for_test("pg");
// Seed 50 posts.
for _ in 0..50 {
let r = post_ingest(
&c,
json!({
"did": did,
"collection": "app.twi.post",
"action": "create",
"rkey": rkey(),
"cid": "bafyreicid",
"record": {
"text": "page",
"createdAt": "2026-07-01T12:00:00Z",
}
}),
)
.await;
assert_eq!(r.status().as_u16(), 200);
}
tokio::time::sleep(Duration::from_millis(100)).await;
// Page 1: limit=20.
let resp = c
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[("did", did.as_str()), ("limit", "20")])
.send()
.await
.unwrap();
let body: Value = resp.json().await.unwrap();
let page1 = body["posts"].as_array().unwrap().clone();
let cursor1 = body["cursor"].as_str().expect("page1 cursor");
assert_eq!(page1.len(), 20, "page1 should be exactly 20");
// Page 2: with cursor.
let resp = c
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[
("did", did.as_str()),
("limit", "20"),
("cursor", cursor1),
])
.send()
.await
.unwrap();
let body: Value = resp.json().await.unwrap();
let page2 = body["posts"].as_array().unwrap().clone();
assert_eq!(page2.len(), 20, "page2 should be exactly 20");
// Pages must not overlap.
let p1: std::collections::HashSet<&str> = page1
.iter()
.map(|p| p["uri"].as_str().unwrap())
.collect();
let p2: std::collections::HashSet<&str> = page2
.iter()
.map(|p| p["uri"].as_str().unwrap())
.collect();
assert!(p1.is_disjoint(&p2), "page1 and page2 overlap");
// Page 3: tail — fewer than 20 expected, cursor=null.
let cursor2 = body["cursor"].as_str().expect("page2 cursor");
let resp = c
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[
("did", did.as_str()),
("limit", "20"),
("cursor", cursor2),
])
.send()
.await
.unwrap();
let body: Value = resp.json().await.unwrap();
let page3 = body["posts"].as_array().unwrap().clone();
assert!(page3.len() <= 20, "page3 should be <= 20");
// At least one of the three pages should be non-empty.
assert!(!page1.is_empty() || !page2.is_empty() || !page3.is_empty());
}
#[tokio::test]
async fn profile_returns_posts_for_handle() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
if !db_reachable().await {
eprintln!("appview DB unreachable, skipping");
return;
}
let c = client().await;
let did_a = did_for_test("alice");
let did_b = did_for_test("bob");
let handle_a = format!("alice.{}", uuid::Uuid::new_v4().simple());
// Seed a post for A with handle populated, and a post for B with a
// different handle. The internal-ingest path doesn't expose a
// `handle` field, so we update the column directly.
for did in [&did_a, &did_b] {
let r = post_ingest(
&c,
json!({
"did": did,
"collection": "app.twi.post",
"action": "create",
"rkey": rkey(),
"cid": "bafyreicid",
"record": {
"text": "hi",
"createdAt": "2026-07-01T12:00:00Z",
}
}),
)
.await;
assert_eq!(r.status().as_u16(), 200);
}
// Backfill handle for A only.
let url = std::env::var("DATABASE_URL_APPVIEW").unwrap();
let pool = sqlx::PgPool::connect(&url).await.unwrap();
sqlx::query("UPDATE posts SET handle = $1 WHERE did = $2")
.bind(&handle_a)
.bind(&did_a)
.execute(&pool)
.await
.unwrap();
// Query by handle (no leading @).
let resp = c
.get(format!("{APPVIEW_URL}/api/profile/{handle_a}"))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["did"], json!(did_a));
assert_eq!(body["handle"], json!(handle_a));
let posts = body["posts"].as_array().unwrap();
assert!(
posts.iter().any(|p| p["did"] == json!(did_a)),
"did_a post missing from profile"
);
assert!(
!posts.iter().any(|p| p["did"] == json!(did_b)),
"did_b post leaked into alice's profile"
);
// Same query, with leading @ — must also work.
let resp = c
.get(format!("{APPVIEW_URL}/api/profile/@{handle_a}"))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
// 404 for an unknown handle.
let resp = c
.get(format!(
"{APPVIEW_URL}/api/profile/nobody_{}",
uuid::Uuid::new_v4().simple()
))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 404);
// /api/profile?did=... must work too.
let resp = c
.get(format!("{APPVIEW_URL}/api/profile"))
.query(&[("did", did_a.as_str())])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["did"], json!(did_a));
}
#[tokio::test]
async fn search_finds_text_match() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
if !db_reachable().await {
eprintln!("appview DB unreachable, skipping");
return;
}
let c = client().await;
let did = did_for_test("srch");
for text in ["hello world from test", "goodbye cruel world", "x"] {
let r = post_ingest(
&c,
json!({
"did": did,
"collection": "app.twi.post",
"action": "create",
"rkey": rkey(),
"cid": "bafyreicid",
"record": {
"text": text,
"createdAt": "2026-07-01T12:00:00Z",
}
}),
)
.await;
assert_eq!(r.status().as_u16(), 200);
}
tokio::time::sleep(Duration::from_millis(50)).await;
let resp = c
.get(format!("{APPVIEW_URL}/api/search"))
.query(&[("q", "hello"), ("limit", "10")])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["q"], json!("hello"));
let posts = body["posts"].as_array().unwrap();
assert!(!posts.is_empty(), "expected at least one match for 'hello'");
for p in posts {
let t = p["text"].as_str().unwrap();
assert!(
t.to_lowercase().contains("hello"),
"post in result doesn't contain 'hello': {t}"
);
}
// Empty q is a 400.
let resp = c
.get(format!("{APPVIEW_URL}/api/search"))
.query(&[("q", "")])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 400);
}
/// When alice follows bob and carol but NOT dave, her home timeline
/// must show bob's and carol's posts only — dave's post is invisible
/// to her even though it sits in the global recent feed.
#[tokio::test]
async fn timeline_filters_to_followees() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
if !db_reachable().await {
eprintln!("appview DB unreachable, skipping");
return;
}
let c = client().await;
let url = std::env::var("DATABASE_URL_APPVIEW").unwrap();
let pool = sqlx::PgPool::connect(&url).await.unwrap();
let alice = did_for_test("alice");
let bob = did_for_test("bob");
let carol = did_for_test("carol");
let dave = did_for_test("dave");
// Alice follows bob + carol (NOT dave).
insert_follow(&pool, &alice, &bob).await;
insert_follow(&pool, &alice, &carol).await;
// Each person posts once.
seed_posts(&c, &bob, &["bob says hi"]).await;
seed_posts(&c, &carol, &["carol says hi"]).await;
seed_posts(&c, &dave, &["dave says hi (alice should NOT see this)"]).await;
tokio::time::sleep(Duration::from_millis(100)).await;
let resp = c
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[("did", alice.as_str()), ("limit", "100")])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
let posts = body["posts"].as_array().expect("posts is array");
// Collect DIDs of returned posts.
let returned_dids: std::collections::HashSet<String> = posts
.iter()
.map(|p| p["did"].as_str().unwrap().to_string())
.collect();
// Bob and carol MUST be present; dave MUST NOT be.
assert!(
returned_dids.contains(&bob),
"bob's post missing from alice's timeline: {posts:?}"
);
assert!(
returned_dids.contains(&carol),
"carol's post missing from alice's timeline: {posts:?}"
);
assert!(
!returned_dids.contains(&dave),
"dave's post leaked into alice's timeline: {posts:?}"
);
// Stronger: walk every post and assert no `did` matches dave.
for p in posts {
let did = p["did"].as_str().unwrap();
assert_ne!(did, dave, "dave leaked: {p:?}");
}
}
/// Alice posts without following anyone. The endpoint must still
/// surface her own posts — via the global-recent "cold start"
/// fallback — so a brand-new account with no follows can see what
/// they've posted.
#[tokio::test]
async fn timeline_includes_own_posts() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
if !db_reachable().await {
eprintln!("appview DB unreachable, skipping");
return;
}
let c = client().await;
let alice = did_for_test("alone");
// Alice posts without seeding any follows.
seed_posts(&c, &alice, &["alice's first post", "alice's second post"]).await;
tokio::time::sleep(Duration::from_millis(100)).await;
let resp = c
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[("did", alice.as_str()), ("limit", "100")])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
let posts = body["posts"].as_array().expect("posts is array");
// At least alice's two posts must be present. The global fallback
// will include other recent posts from the DB too — we only
// assert on alice's visibility here.
let alice_uris: Vec<&str> = posts
.iter()
.filter_map(|p| {
let uri = p["uri"].as_str()?;
if uri.starts_with(&format!("at://{alice}/")) {
Some(uri)
} else {
None
}
})
.collect();
assert!(
alice_uris.len() >= 2,
"alice's own posts missing from her own timeline: {posts:?}"
);
}
/// Alice follows 1500 fake DIDs. The endpoint must NOT blow up — the
/// `target_dids` cap at MAX_FOLLOWED_DIDS=1000 kicks in, the user's
/// own DID is re-inserted, and the SQL `ANY($)` array stays bounded.
#[tokio::test]
async fn timeline_caps_followee_list() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
if !db_reachable().await {
eprintln!("appview DB unreachable, skipping");
return;
}
let c = client().await;
let url = std::env::var("DATABASE_URL_APPVIEW").unwrap();
let pool = sqlx::PgPool::connect(&url).await.unwrap();
let alice = did_for_test("poweruser");
// Seed 1500 follows (well over MAX_FOLLOWED_DIDS=1000).
for _ in 0..1500 {
let fake = format!(
"did:plc:fake_{}_{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
);
insert_follow(&pool, &alice, &fake).await;
}
// Alice also posts — to confirm she sees her own DID even though
// the cap trimmed the lexically-greatest 1000 followees.
seed_posts(&c, &alice, &["poweruser post"]).await;
tokio::time::sleep(Duration::from_millis(100)).await;
let resp = c
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[("did", alice.as_str()), ("limit", "50")])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
let posts = body["posts"].as_array().expect("posts is array");
// Alice's own post must be visible — the cap invariant guarantees
// her own DID is preserved.
let alice_visible = posts
.iter()
.any(|p| p["did"].as_str() == Some(alice.as_str()));
assert!(
alice_visible,
"alice's own post not visible after cap: {posts:?}"
);
// The fake followee DIDs have no posts, so nothing else should
// leak in. We only assert the endpoint didn't error and that
// alice's own DID is honored.
}
+299
View File
@@ -0,0 +1,299 @@
//! Integration tests for the AppView HTTP service.
//!
//! These exercise the running `appview` binary over HTTP: the `/healthz`
//! endpoint and `POST /internal/ingest-commit`. Like the PDS integration
//! tests, they are no-ops when the service isn't running — they fail-open
//! with `eprintln!` instead of panicking.
use serde_json::{json, Value};
use std::time::Duration;
const APPVIEW_URL: &str = "http://127.0.0.1:2584";
async fn client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap()
}
async fn wait_for_appview_db() -> bool {
let c = client().await;
for _ in 0..20 {
if let Ok(r) = c.get(format!("{APPVIEW_URL}/healthz")).send().await {
if r.status().is_success() {
return true;
}
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
false
}
async fn try_db_url() -> Option<String> {
std::env::var("DATABASE_URL_APPVIEW").ok()
}
async fn ping_db() -> bool {
let Some(url) = try_db_url().await else {
return false;
};
let Ok(c) = client().await.get("http://127.0.0.1:9/_never_").build() else {
return false;
};
let _ = c;
match tokio::time::timeout(Duration::from_secs(2), sqlx::PgPool::connect(&url)).await {
Ok(Ok(_pool)) => true,
_ => false,
}
}
#[tokio::test]
async fn healthz_returns_ok() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
let c = client().await;
let resp = c
.get(format!("{APPVIEW_URL}/healthz"))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["ok"], json!(true));
// The new fields must all be present.
assert!(body.get("lag_ms").is_some(), "missing lag_ms: {body}");
assert!(
body.get("events_processed").is_some(),
"missing events_processed: {body}"
);
assert!(
body.get("jetstream_connected").is_some(),
"missing jetstream_connected: {body}"
);
}
async fn post_ingest(c: &reqwest::Client, body: Value) -> reqwest::Response {
c.post(format!("{APPVIEW_URL}/internal/ingest-commit"))
.json(&body)
.send()
.await
.unwrap()
}
async fn fetch_post_uri(c: &reqwest::Client, uri: &str) -> Option<Value> {
// Probe: rely on direct DB? No — we don't want to expose DB to tests.
// Just check that the ingest endpoint accepted the request and returned
// applied: true. End-to-end correctness is exercised by the indexer
// unit tests against the same schema.
let _ = c;
let _ = uri;
None
}
fn did_for_test(name: &str) -> String {
// Random per-test DID so the tests can run in parallel without
// colliding on URI primary keys.
format!("did:plc:test_{}_{}", name, uuid::Uuid::new_v4().simple())
}
#[tokio::test]
async fn ingest_commit_persists_post() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
if !ping_db().await {
eprintln!("appview DB unreachable, skipping");
return;
}
let c = client().await;
let did = did_for_test("post");
let rkey = uuid::Uuid::new_v4().simple().to_string();
let uri = format!("at://{did}/app.twi.post/{rkey}");
let resp = post_ingest(
&c,
json!({
"did": did,
"collection": "app.twi.post",
"action": "create",
"rkey": rkey,
"cid": "bafyreicidpost",
"record": {
"text": "hello from integration test",
"createdAt": "2026-07-01T12:00:00Z",
}
}),
)
.await;
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["ok"], json!(true));
assert_eq!(body["applied"], json!(true));
// Sanity: idem — a second create with the same rkey is a no-op upsert.
let resp2 = post_ingest(
&c,
json!({
"did": did,
"collection": "app.twi.post",
"action": "create",
"rkey": rkey,
"cid": "bafyreicidpost",
"record": {
"text": "still here",
"createdAt": "2026-07-01T12:00:00Z",
}
}),
)
.await;
assert_eq!(resp2.status().as_u16(), 200);
let _ = (uri.clone(), fetch_post_uri(&c, &uri).await);
}
#[tokio::test]
async fn ingest_commit_persists_like() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
if !ping_db().await {
eprintln!("appview DB unreachable, skipping");
return;
}
let c = client().await;
let did = did_for_test("like");
let rkey = uuid::Uuid::new_v4().simple().to_string();
let resp = post_ingest(
&c,
json!({
"did": did,
"collection": "app.bsky.feed.like",
"action": "create",
"rkey": rkey,
"cid": "bafyreicidlike",
"record": {
"subject": {
"uri": "at://did:plc:target/app.twi.post/abc",
"cid": "bafyreicidtarget"
},
"createdAt": "2026-07-01T12:00:00Z"
}
}),
)
.await;
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["ok"], json!(true));
assert_eq!(body["applied"], json!(true));
}
#[tokio::test]
async fn ingest_delete_removes_post() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
if !ping_db().await {
eprintln!("appview DB unreachable, skipping");
return;
}
let c = client().await;
let did = did_for_test("del");
let rkey = uuid::Uuid::new_v4().simple().to_string();
// Create.
let created = post_ingest(
&c,
json!({
"did": did,
"collection": "app.twi.post",
"action": "create",
"rkey": rkey,
"cid": "bafyreicid",
"record": {
"text": "first",
"createdAt": "2026-07-01T12:00:00Z"
}
}),
)
.await;
assert_eq!(created.status().as_u16(), 200);
// Delete.
let deleted = post_ingest(
&c,
json!({
"did": did,
"collection": "app.twi.post",
"action": "delete",
"rkey": rkey,
}),
)
.await;
assert_eq!(deleted.status().as_u16(), 200);
let body: Value = deleted.json().await.unwrap();
assert_eq!(body["applied"], json!(true));
// Delete again — must still 200 with applied=true (idempotent).
let deleted2 = post_ingest(
&c,
json!({
"did": did,
"collection": "app.twi.post",
"action": "delete",
"rkey": rkey,
}),
)
.await;
assert_eq!(deleted2.status().as_u16(), 200);
}
#[tokio::test]
async fn ingest_follow_requires_subject() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
if !ping_db().await {
eprintln!("appview DB unreachable, skipping");
return;
}
let c = client().await;
let did = did_for_test("follow");
// Without subject_did AND without record.subject → 400.
let r = post_ingest(
&c,
json!({
"did": did,
"collection": "app.bsky.graph.follow",
"action": "create",
"rkey": "frk",
"record": { "createdAt": "2026-07-01T12:00:00Z" }
}),
)
.await;
assert_eq!(r.status().as_u16(), 400);
// With subject_did → 200.
let r2 = post_ingest(
&c,
json!({
"did": did,
"collection": "app.bsky.graph.follow",
"action": "create",
"rkey": "frk",
"subject_did": "did:plc:followed",
"record": { "subject": "did:plc:followed",
"createdAt": "2026-07-01T12:00:00Z" }
}),
)
.await;
assert_eq!(r2.status().as_u16(), 200);
}
+443
View File
@@ -0,0 +1,443 @@
//! Integration tests for embed capture + thread hydration.
//!
//! These exercise the AppView's `embed` storage and the new
//! `/api/post/{uri}` thread-hydration endpoint end-to-end:
//!
//! - `timeline_includes_embed` — seed a post with an image embed, query
//! the home timeline, verify the embed came back as raw JSON.
//! - `timeline_includes_external_embed` — same but with a link card.
//! - `post_endpoint_returns_thread` — seed 3 posts (root + reply + reply
//! to reply), fetch the middle one's URI, verify the thread
//! hydration returns the right parent + root rows.
//!
//! Like the sibling API tests these are fail-open: if the AppView
//! service isn't running on the expected port the test prints a notice
//! and returns rather than panicking. The point of the tests is to
//! catch regressions in CI where the service IS up.
use serde_json::{json, Value};
use std::time::Duration;
const APPVIEW_URL: &str = "http://127.0.0.1:2584";
async fn client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap()
}
async fn wait_for_appview_db() -> bool {
let c = client().await;
for _ in 0..20 {
if let Ok(r) = c.get(format!("{APPVIEW_URL}/healthz")).send().await {
if r.status().is_success() {
return true;
}
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
false
}
async fn db_reachable() -> bool {
let Some(url) = std::env::var("DATABASE_URL_APPVIEW").ok() else {
return false;
};
matches!(
tokio::time::timeout(Duration::from_secs(2), sqlx::PgPool::connect(&url)).await,
Ok(Ok(_))
)
}
async fn post_ingest(c: &reqwest::Client, body: Value) -> reqwest::Response {
c.post(format!("{APPVIEW_URL}/internal/ingest-commit"))
.json(&body)
.send()
.await
.unwrap()
}
fn did_for_test(prefix: &str) -> String {
format!(
"did:plc:emb_{}_{}",
prefix,
uuid::Uuid::new_v4().simple()
)
}
fn rkey() -> String {
uuid::Uuid::new_v4().simple().to_string()
}
/// Seed a single post with the given record payload and return its URI.
async fn seed_post(c: &reqwest::Client, did: &str, record: Value) -> String {
let rk = rkey();
let uri = format!("at://{did}/app.twi.post/{rk}");
let resp = post_ingest(
c,
json!({
"did": did,
"collection": "app.twi.post",
"action": "create",
"rkey": rk,
"cid": "bafyreicid",
"record": record,
}),
)
.await;
assert_eq!(resp.status().as_u16(), 200, "ingest failed: {record}");
uri
}
/// Seed a post whose parent/root URIs are given explicitly. Used by
/// the thread test to build a 3-deep chain (root → reply → reply).
async fn seed_reply(
c: &reqwest::Client,
did: &str,
text: &str,
parent_uri: &str,
root_uri: &str,
) -> String {
seed_post(
c,
did,
json!({
"text": text,
"createdAt": "2026-07-01T12:00:00Z",
"reply": {
"parent": {"uri": parent_uri, "cid": "cp"},
"root": {"uri": root_uri, "cid": "cr"}
}
}),
)
.await
}
#[tokio::test]
async fn timeline_includes_embed() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
if !db_reachable().await {
eprintln!("appview DB unreachable, skipping");
return;
}
let c = client().await;
let did = did_for_test("img");
let uri = seed_post(
&c,
&did,
json!({
"text": "look at this image",
"createdAt": "2026-07-01T12:00:00Z",
"embed": {
"$type": "app.bsky.embed.images",
"images": [
{
"alt": "a sunset over mountains",
"image": {
"$type": "blob",
"ref": {"$link": "bafyreimgres1"},
"mimeType": "image/jpeg",
"size": 12345
},
"aspectRatio": {"width": 1200, "height": 800}
},
{
"alt": "second image",
"image": {
"$type": "blob",
"ref": {"$link": "bafyreimgres2"},
"mimeType": "image/jpeg",
"size": 6789
}
}
]
}
}),
)
.await;
let resp = c
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[("did", did.as_str()), ("limit", "10")])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
let posts = body["posts"].as_array().unwrap();
let our = posts
.iter()
.find(|p| p["uri"] == json!(uri))
.expect("seeded post missing from timeline");
let embed = our
.get("embed")
.expect("embed field missing from PostRow");
assert!(!embed.is_null(), "embed must not be null for image post");
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"], "a sunset over mountains");
assert_eq!(imgs[0]["image"]["ref"]["$link"], "bafyreimgres1");
assert_eq!(imgs[0]["aspectRatio"]["width"], 1200);
assert_eq!(imgs[1]["alt"], "second image");
}
#[tokio::test]
async fn timeline_includes_external_embed() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
if !db_reachable().await {
eprintln!("appview DB unreachable, skipping");
return;
}
let c = client().await;
let did = did_for_test("ext");
let uri = seed_post(
&c,
&did,
json!({
"text": "see link",
"createdAt": "2026-07-01T12:00:00Z",
"embed": {
"$type": "app.bsky.embed.external",
"external": {
"uri": "https://example.com/article",
"title": "An interesting article",
"description": "A short description of the linked page.",
"thumb": {
"$type": "blob",
"ref": {"$link": "bafyreithumb"}
}
}
}
}),
)
.await;
let resp = c
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[("did", did.as_str()), ("limit", "10")])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
let posts = body["posts"].as_array().unwrap();
// The ingest endpoint commits asynchronously; the timeline may
// not yet contain the row on the first poll. Retry briefly with
// a 50ms back-off so we don't flake on busy CI.
let mut our = posts.iter().find(|p| p["uri"] == json!(uri)).cloned();
for _ in 0..10 {
if our.is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
let resp = c
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[("did", did.as_str()), ("limit", "10")])
.send()
.await
.unwrap();
let body: Value = resp.json().await.unwrap();
our = body["posts"]
.as_array()
.unwrap()
.iter()
.find(|p| p["uri"] == json!(uri))
.cloned();
}
let our = our.expect("seeded post missing from timeline");
let embed = our["embed"].as_object().expect("embed object");
assert_eq!(embed["$type"], "app.bsky.embed.external");
assert_eq!(embed["external"]["uri"], "https://example.com/article");
assert_eq!(embed["external"]["title"], "An interesting article");
}
#[tokio::test]
async fn post_endpoint_returns_thread() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
if !db_reachable().await {
eprintln!("appview DB unreachable, skipping");
return;
}
let c = client().await;
let alice = did_for_test("thread_alice");
let bob = did_for_test("thread_bob");
let carol = did_for_test("thread_carol");
// Build the chain: root (alice) → reply (bob) → reply to reply (carol).
let root_uri = seed_post(
&c,
&alice,
json!({
"text": "alice's root post",
"createdAt": "2026-07-01T12:00:00Z"
}),
)
.await;
let reply1_uri = seed_reply(
&c,
&bob,
"bob's reply to alice",
&root_uri,
&root_uri,
)
.await;
let reply2_uri = seed_reply(
&c,
&carol,
"carol's reply to bob",
&reply1_uri,
&root_uri,
)
.await;
// Fetch carol's post and verify the thread hydration returns both
// bob's reply (parent) and alice's root (root).
let resp = c
.get(format!("{APPVIEW_URL}/api/post/{reply2_uri}"))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["post"]["uri"], json!(reply2_uri));
assert_eq!(
body["post"]["text"],
json!("carol's reply to bob")
);
let parent = &body["thread"]["parent"];
let root = &body["thread"]["root"];
assert_eq!(parent["uri"], json!(reply1_uri));
assert_eq!(parent["text"], json!("bob's reply to alice"));
assert_eq!(root["uri"], json!(root_uri));
assert_eq!(root["text"], json!("alice's root post"));
// Reply → reply case: carol's `parent_uri` is bob's, `root_uri` is
// alice's, and they must differ — so the root field must NOT be
// collapsed into the parent field.
assert_ne!(
parent["uri"], root["uri"],
"root and parent must be distinct rows for a 2-deep reply chain"
);
}
#[tokio::test]
async fn post_endpoint_single_post_thread_self_referential() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
if !db_reachable().await {
eprintln!("appview DB unreachable, skipping");
return;
}
let c = client().await;
let did = did_for_test("self");
let uri = seed_post(
&c,
&did,
json!({
"text": "standalone post, no parent",
"createdAt": "2026-07-01T12:00:00Z"
}),
)
.await;
let resp = c
.get(format!("{APPVIEW_URL}/api/post/{uri}"))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["post"]["uri"], json!(uri));
assert!(
body["thread"]["parent"].is_null(),
"post with no parent must have null parent"
);
assert!(
body["thread"]["root"].is_null(),
"post with no parent must have null root"
);
}
#[tokio::test]
async fn post_endpoint_unknown_uri_returns_null_post() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
let c = client().await;
let bogus = format!(
"at://did:plc:nope-{}/app.twi.post/nope-{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
);
let resp = c
.get(format!("{APPVIEW_URL}/api/post/{bogus}"))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
assert!(body["post"].is_null());
assert!(body["thread"]["parent"].is_null());
assert!(body["thread"]["root"].is_null());
}
#[tokio::test]
async fn timeline_post_without_embed_has_null_embed() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
if !db_reachable().await {
eprintln!("appview DB unreachable, skipping");
return;
}
let c = client().await;
let did = did_for_test("plain");
let uri = seed_post(
&c,
&did,
json!({
"text": "plain text only",
"createdAt": "2026-07-01T12:00:00Z"
}),
)
.await;
let resp = c
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[("did", did.as_str()), ("limit", "10")])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
let our = body["posts"]
.as_array()
.unwrap()
.iter()
.find(|p| p["uri"] == json!(uri))
.expect("plain post missing");
assert!(
our["embed"].is_null(),
"plain text post must have null embed, got: {}",
our["embed"]
);
}
@@ -0,0 +1,461 @@
//! Integration tests for `HandleSyncWorker::run_once()`.
//!
//! These exercise the worker's SQL against a live appview DB. The
//! resolver is substituted for a stub so the tests do not depend on
//! `plc.directory` being reachable (and so we can deterministically
//! prove the "don't overwrite" race protection works).
//!
//! Like the sibling `api_integration.rs`, every test is fail-open: if
//! `DATABASE_URL_APPVIEW` is unset or the DB isn't reachable, the test
//! prints a notice and returns. This keeps `cargo test --workspace`
//! green in environments without the appview stack running.
use anyhow::Result;
use async_trait::async_trait;
use at_identity::DidHandleResolver;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::time::timeout;
use uuid::Uuid;
use appview::handle_sync::{HandleSyncWorker, SyncReport};
/// In-process test double for the PLC client. We never want these
/// tests to talk to the real PLC.
#[derive(Default)]
struct StubResolver {
/// DID → resolved handle (or `None` for an unresolvable DID).
/// `Some("")` is treated as "no result" by the worker.
mapping: Mutex<HashMap<String, Option<String>>>,
/// How many times each DID was queried — used by the limit test.
queries: Mutex<Vec<String>>,
}
impl StubResolver {
fn new(map: HashMap<String, Option<String>>) -> Self {
Self {
mapping: Mutex::new(map),
queries: Mutex::new(Vec::new()),
}
}
fn into_arc(self) -> Arc<StubResolver> {
Arc::new(self)
}
fn query_count(&self, did: &str) -> usize {
self.queries
.lock()
.unwrap()
.iter()
.filter(|d| d.as_str() == did)
.count()
}
}
#[async_trait]
impl DidHandleResolver for StubResolver {
async fn resolve_handle(&self, did: &str) -> Result<Option<String>> {
self.queries.lock().unwrap().push(did.to_string());
// Snapshot the mapping out so the worker sees a consistent view
// even if another writer fiddles mid-call.
let m = self.mapping.lock().unwrap();
// None → unknown; Some("") → unknown; Some("h") → resolved.
match m.get(did) {
Some(Some(h)) if !h.is_empty() => Ok(Some(h.clone())),
_ => Ok(None),
}
}
}
/// Build a worker whose PLC and web resolvers are both the same stub.
/// The integration tests in this file don't care which method the
/// DID uses — the stub answers for any prefix.
fn worker_with(db: sqlx::PgPool, stub: Arc<StubResolver>) -> HandleSyncWorker {
let r: Arc<dyn DidHandleResolver> = stub;
HandleSyncWorker {
db,
plc_resolver: Arc::clone(&r),
web_resolver: Arc::clone(&r),
interval_secs: 999,
}
}
async fn try_test_db() -> Option<sqlx::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,
}
}
fn unique_did(prefix: &str) -> String {
format!("did:plc:hsync_{}_{}", prefix, Uuid::new_v4().simple())
}
async fn seed_post(
db: &sqlx::PgPool,
did: &str,
rkey: &str,
handle: &str,
text: &str,
) -> Result<()> {
let uri = format!("at://{did}/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,$3,$4,'app.twi.post',$5,'bafy',NULL,NULL,NULL, now())
ON CONFLICT (uri) DO NOTHING"#,
)
.bind(&uri)
.bind(did)
.bind(handle)
.bind(rkey)
.bind(text)
.execute(db)
.await?;
Ok(())
}
async fn fetch_handle(
db: &sqlx::PgPool,
did: &str,
) -> Result<Option<String>> {
let row: Option<(String,)> = sqlx::query_as(
"SELECT handle FROM posts WHERE did = $1 \
ORDER BY indexed_at DESC LIMIT 1",
)
.bind(did)
.fetch_optional(db)
.await?;
Ok(row.and_then(|(s,)| if s.is_empty() { None } else { Some(s) }))
}
async fn count_empty_handle_for(db: &sqlx::PgPool, did: &str) -> Result<i64> {
let (n,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM posts WHERE did = $1 AND handle = ''",
)
.bind(did)
.fetch_one(db)
.await?;
Ok(n)
}
/// Seed two posts for one DID with empty handles, point the stub
/// resolver at a known handle, and assert the worker fills both rows.
#[tokio::test]
async fn sync_resolves_known_did() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let did = unique_did("known");
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await
.unwrap();
let expected = format!("known.{}", Uuid::new_v4().simple());
let stub = StubResolver::new(HashMap::from([(
did.clone(),
Some(expected.clone()),
)]))
.into_arc();
// Two posts → two rows must be updated.
seed_post(&db, &did, "rka", "", "first").await.unwrap();
seed_post(&db, &did, "rkb", "", "second").await.unwrap();
assert_eq!(count_empty_handle_for(&db, &did).await.unwrap(), 2);
let worker = worker_with(db.clone(), stub.clone());
let report: SyncReport = worker.run_once().await.unwrap();
assert_eq!(report.resolved, 2, "{report:?}");
assert_eq!(report.failed, 0);
assert_eq!(report.skipped, 0);
// No empty-handle rows remain for this DID and the handle matches.
assert_eq!(count_empty_handle_for(&db, &did).await.unwrap(), 0);
let got = fetch_handle(&db, &did).await.unwrap();
assert_eq!(got.as_deref(), Some(expected.as_str()));
// Resolver was consulted exactly once for this DID.
assert_eq!(stub.query_count(&did), 1);
// Cleanup.
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await;
}
/// A DID whose posts already carry a handle must NOT be re-queried
/// or overwritten — the worker's `SELECT … WHERE handle = ''` filters
/// it out entirely.
#[tokio::test]
async fn sync_skips_already_resolved() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let did = unique_did("already");
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await
.unwrap();
let pre = "preset.handle".to_string();
seed_post(&db, &did, "rkA", &pre, "alpha").await.unwrap();
seed_post(&db, &did, "rkB", &pre, "beta").await.unwrap();
// The stub would overwrite with a different handle if asked.
let stub = StubResolver::new(HashMap::from([(
did.clone(),
Some("wrong.handle".into()),
)]))
.into_arc();
let worker = worker_with(db.clone(), stub.clone());
let report = worker.run_once().await.unwrap();
assert_eq!(report.resolved, 0, "{report:?}");
assert_eq!(report.failed, 0);
// Both rows must still carry the pre-existing handle.
let (cnt,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM posts WHERE did = $1 AND handle = $2",
)
.bind(&did)
.bind(&pre)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(cnt, 2);
// Resolver was NOT consulted for this DID.
assert_eq!(stub.query_count(&did), 0);
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await;
}
/// Seed more than `BATCH_SIZE` distinct empty-handle DIDs and verify
/// only the first batch is processed this pass. The leftover DIDs
/// remain empty (will be picked up next pass).
#[tokio::test]
async fn sync_respects_limit() {
use appview::handle_sync::BATCH_SIZE;
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let prefix = unique_did("limit");
// Seed BATCH_SIZE + 5 distinct DIDs, each with one empty-handle post.
let total = BATCH_SIZE as usize + 5;
let mut all_dids = Vec::with_capacity(total);
for i in 0..total {
let did = format!("{prefix}_{i}");
seed_post(&db, &did, "rk", "", "x").await.unwrap();
all_dids.push(did);
}
let mut mapping = HashMap::new();
for did in &all_dids {
mapping.insert(
did.clone(),
Some(format!("resolved.{}", &did[did.len() - 6..])),
);
}
let stub = StubResolver::new(mapping).into_arc();
let worker = worker_with(db.clone(), stub.clone());
let report = worker.run_once().await.unwrap();
assert_eq!(
report.resolved as i64,
BATCH_SIZE,
"expected exactly BATCH_SIZE rows resolved, got {report:?}"
);
assert_eq!(report.failed, 0);
assert_eq!(report.skipped, 0);
// Exactly 5 empty-handle posts remain (the capped overflow).
let remaining: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM posts WHERE did LIKE $1 AND handle = ''",
)
.bind(format!("{prefix}_%"))
.fetch_one(&db)
.await
.unwrap();
assert_eq!(remaining, 5, "expected 5 unresolved rows left");
// The stub resolver was consulted for exactly BATCH_SIZE DIDs.
// (Note: the worker can't know which 5 were left out — the query
// count is process-wide; we count the total below.)
let total_qs = {
let guard = stub.queries.lock().unwrap();
guard.len()
};
assert_eq!(
total_qs as i64,
BATCH_SIZE,
"resolver must be called at most BATCH_SIZE times, got {total_qs}"
);
// Cleanup so repeated runs stay hygienic.
let _ = sqlx::query("DELETE FROM posts WHERE did LIKE $1")
.bind(format!("{prefix}_%"))
.execute(&db)
.await;
}
/// Unresolvable DIDs (stub returns `Ok(None)`) count as `skipped`,
/// not `failed`, so a temporary PLC outage doesn't poison
/// observability dashboards.
#[tokio::test]
async fn sync_skips_unresolvable_dids() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let did = unique_did("unres");
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await
.unwrap();
seed_post(&db, &did, "rk", "", "").await.unwrap();
// DID deliberately absent from the stub's mapping → Ok(None).
let stub = StubResolver::new(HashMap::new()).into_arc();
let worker = worker_with(db.clone(), stub.clone());
let report = worker.run_once().await.unwrap();
assert_eq!(report.resolved, 0);
assert_eq!(report.failed, 0);
assert_eq!(report.skipped, 1, "{report:?}");
assert_eq!(
count_empty_handle_for(&db, &did).await.unwrap(),
1,
"post must remain empty until resolver succeeds"
);
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await;
}
/// End-to-end test for the `did:web:` dispatch path: a `did:web:`
/// DID with an empty post handle must be routed to the **web**
/// resolver (not the PLC one) and the post handle must be updated
/// from the web resolver's answer.
#[tokio::test]
async fn sync_resolves_did_web_via_web_resolver() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let did = format!("did:web:example.com:user:{}", Uuid::new_v4().simple());
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await
.unwrap();
seed_post(&db, &did, "rk", "", "web post").await.unwrap();
// Two stubs that disagree. The dispatcher MUST pick the web one
// for a did:web DID — choosing the PLC one would write the wrong
// handle.
let expected = format!("web-handle.{}", Uuid::new_v4().simple());
let plc = StubResolver::new(HashMap::from([(
did.clone(),
Some("WRONG-PLC-HANDLE".into()),
)]));
let web = StubResolver::new(HashMap::from([(
did.clone(),
Some(expected.clone()),
)]));
let plc_arc: Arc<dyn DidHandleResolver> = plc.into_arc();
let web_arc: Arc<dyn DidHandleResolver> = web.into_arc();
let worker = HandleSyncWorker {
db: db.clone(),
plc_resolver: plc_arc,
web_resolver: web_arc,
interval_secs: 999,
};
let report = worker.run_once().await.unwrap();
assert_eq!(
report.resolved, 1,
"did:web must resolve through the web resolver, got {report:?}"
);
assert_eq!(report.failed, 0);
let h = fetch_handle(&db, &did).await.unwrap();
assert_eq!(h.as_deref(), Some(expected.as_str()));
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await;
}
/// `did:plc:` DIDs must still flow through the PLC resolver — the
/// web resolver must NOT be consulted (which would otherwise issue
/// a `https://plc.directory/.../did.json` request and fail).
#[tokio::test]
async fn sync_resolves_did_plc_via_plc_resolver() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let did = unique_did("plcpath");
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await
.unwrap();
seed_post(&db, &did, "rk", "", "plc post").await.unwrap();
let expected = format!("plc-handle.{}", Uuid::new_v4().simple());
let plc = StubResolver::new(HashMap::from([(
did.clone(),
Some(expected.clone()),
)]));
// The web stub deliberately holds the wrong handle. If dispatch
// wrongly routed a did:plc DID to the web resolver, the row would
// end up with "WRONG-WEB-HANDLE".
let web = StubResolver::new(HashMap::from([(
did.clone(),
Some("WRONG-WEB-HANDLE".into()),
)]));
let plc_arc: Arc<dyn DidHandleResolver> = plc.into_arc();
let web_arc: Arc<dyn DidHandleResolver> = web.into_arc();
let worker = HandleSyncWorker {
db: db.clone(),
plc_resolver: plc_arc,
web_resolver: web_arc,
interval_secs: 999,
};
let report = worker.run_once().await.unwrap();
assert_eq!(
report.resolved, 1,
"did:plc must resolve through the PLC resolver, got {report:?}"
);
let h = fetch_handle(&db, &did).await.unwrap();
assert_eq!(h.as_deref(), Some(expected.as_str()));
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await;
}
+370
View File
@@ -0,0 +1,370 @@
//! Integration tests for the `/api/post/{uri}` engagement counts.
//!
//! Like the sibling `embeds_integration.rs` these are fail-open
//! against a live AppView: the tests `eprintln!` and skip if the
//! service isn't running on the expected port or the DB is
//! unreachable.
//!
//! Tests:
//!
//! - `post_endpoint_returns_like_counts` — seed a like via
//! `/internal/ingest-commit`, fetch the post endpoint, verify
//! the `like_count` is 1. Seed a repost, verify the
//! `repost_count` is 1.
use serde_json::{json, Value};
use std::time::Duration;
const APPVIEW_URL: &str = "http://127.0.0.1:2584";
async fn client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap()
}
async fn wait_for_appview_db() -> bool {
let c = client().await;
for _ in 0..20 {
if let Ok(r) = c.get(format!("{APPVIEW_URL}/healthz")).send().await {
if r.status().is_success() {
return true;
}
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
false
}
async fn db_reachable() -> bool {
let Some(url) = std::env::var("DATABASE_URL_APPVIEW").ok() else {
return false;
};
matches!(
tokio::time::timeout(Duration::from_secs(2), sqlx::PgPool::connect(&url)).await,
Ok(Ok(_))
)
}
async fn post_ingest(c: &reqwest::Client, body: Value) -> reqwest::Response {
c.post(format!("{APPVIEW_URL}/internal/ingest-commit"))
.json(&body)
.send()
.await
.unwrap()
}
fn did_for_test(prefix: &str) -> String {
format!(
"did:plc:likes_{}_{}",
prefix,
uuid::Uuid::new_v4().simple()
)
}
fn rkey() -> String {
uuid::Uuid::new_v4().simple().to_string()
}
async fn seed_post(c: &reqwest::Client, did: &str, text: &str) -> String {
let rk = rkey();
let uri = format!("at://{did}/app.twi.post/{rk}");
let resp = post_ingest(
c,
json!({
"did": did,
"collection": "app.twi.post",
"action": "create",
"rkey": rk,
"cid": "bafyreicid",
"record": {
"text": text,
"createdAt": "2026-07-01T12:00:00Z",
},
}),
)
.await;
assert_eq!(resp.status().as_u16(), 200, "post ingest failed");
uri
}
async fn seed_like(
c: &reqwest::Client,
liker_did: &str,
subject_uri: &str,
subject_cid: &str,
) -> String {
let rk = rkey();
let like_uri = format!("at://{liker_did}/app.bsky.feed.like/{rk}");
let resp = post_ingest(
c,
json!({
"did": liker_did,
"collection": "app.bsky.feed.like",
"action": "create",
"rkey": rk,
"cid": "bafyreilike",
"record": {
"subject": {
"uri": subject_uri,
"cid": subject_cid,
},
"createdAt": "2026-07-01T12:00:00Z",
},
}),
)
.await;
assert_eq!(resp.status().as_u16(), 200, "like ingest failed");
like_uri
}
async fn seed_repost(
c: &reqwest::Client,
reposter_did: &str,
subject_uri: &str,
subject_cid: &str,
) -> String {
let rk = rkey();
let repost_uri = format!("at://{reposter_did}/app.bsky.feed.repost/{rk}");
let resp = post_ingest(
c,
json!({
"did": reposter_did,
"collection": "app.bsky.feed.repost",
"action": "create",
"rkey": rk,
"cid": "bafyreirepost",
"record": {
"subject": {
"uri": subject_uri,
"cid": subject_cid,
},
"createdAt": "2026-07-01T12:00:00Z",
},
}),
)
.await;
assert_eq!(resp.status().as_u16(), 200, "repost ingest failed");
repost_uri
}
/// Poll the post endpoint a few times so we don't flake on
/// ingestion latency. The ingest-commit handler is async, so
/// counts may not be visible on the first request.
async fn post_endpoint_with_counts(
c: &reqwest::Client,
uri: &str,
) -> Option<Value> {
for _ in 0..10 {
let resp = c
.get(format!("{APPVIEW_URL}/api/post/{uri}"))
.send()
.await
.ok()?;
if !resp.status().is_success() {
tokio::time::sleep(Duration::from_millis(50)).await;
continue;
}
let body: Value = resp.json().await.ok()?;
if body.get("like_count").is_some() || body.get("repost_count").is_some() {
return Some(body);
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
None
}
#[tokio::test]
async fn post_endpoint_returns_like_counts() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
if !db_reachable().await {
eprintln!("appview DB unreachable, skipping");
return;
}
let c = client().await;
let author = did_for_test("author");
let liker = did_for_test("liker");
let reposter = did_for_test("reposter");
// The post endpoint needs the post itself in the `posts` table
// to return a non-null `post` and the engagement counts. The
// embed for the post is fine to be null.
let post_uri = seed_post(&c, &author, "a post that will get engagement").await;
let post_cid = "bafyreicid";
// No likes / reposts yet — counts must be 0.
let initial = post_endpoint_with_counts(&c, &post_uri)
.await
.expect("post endpoint never resolved");
assert_eq!(
initial["post"]["uri"],
json!(post_uri),
"endpoint should return our seeded post"
);
assert_eq!(initial["like_count"], json!(0), "initial like_count");
assert_eq!(initial["repost_count"], json!(0), "initial repost_count");
// Seed one like and one repost from different DIDs.
let _ = seed_like(&c, &liker, &post_uri, post_cid).await;
let _ = seed_repost(&c, &reposter, &post_uri, post_cid).await;
let body = post_endpoint_with_counts(&c, &post_uri)
.await
.expect("post endpoint never resolved after engagement");
assert_eq!(
body["like_count"],
json!(1),
"like_count should be 1 after one like ingest: {body:?}"
);
assert_eq!(
body["repost_count"],
json!(1),
"repost_count should be 1 after one repost ingest: {body:?}"
);
}
#[tokio::test]
async fn post_endpoint_missing_post_returns_null_counts() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
let c = client().await;
let bogus = format!(
"at://did:plc:nope_lc_{}/app.twi.post/nope_{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
);
let resp = c
.get(format!("{APPVIEW_URL}/api/post/{bogus}"))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
assert!(body["post"].is_null(), "missing post should be null");
// Counts are skipped when the post isn't found — we shouldn't
// pay the `COUNT(*)` cost on the "not in index" path.
assert!(
body.get("like_count").is_none() || body["like_count"].is_null(),
"like_count should be absent for missing post, got: {body:?}"
);
assert!(
body.get("repost_count").is_none() || body["repost_count"].is_null(),
"repost_count should be absent for missing post, got: {body:?}"
);
}
/// Phase 5b review H4 — `viewer_liked` / `viewer_reposted` must reach
/// the UI so it can highlight the engagement buttons. Without this
/// the Tauri client can show the counts but never knows whether the
/// user has already liked/reposted the post, so the "liked" state
/// doesn't persist visually across reloads.
#[tokio::test]
async fn post_endpoint_with_viewer_did_returns_liked_state() {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return;
}
if !db_reachable().await {
eprintln!("appview DB unreachable, skipping");
return;
}
let c = client().await;
let author = did_for_test("vl_author");
let liker = did_for_test("vl_liker");
let reposter = did_for_test("vl_reposter");
let outsider = did_for_test("vl_outsider");
let post_uri = seed_post(&c, &author, "post that some viewers like").await;
let post_cid = "bafyreicid";
// Seed a like from `liker` and a repost from `reposter`.
let _ = seed_like(&c, &liker, &post_uri, post_cid).await;
let _ = seed_repost(&c, &reposter, &post_uri, post_cid).await;
// Poll the endpoint with `viewer_did=liker` and verify
// `viewer_liked = true` and `viewer_reposted = false` (liker did
// not repost).
let mut body_liker: Option<Value> = None;
for _ in 0..20 {
let resp = c
.get(format!(
"{APPVIEW_URL}/api/post/{post_uri}"
))
.query(&[("viewer_did", liker.as_str())])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
if body.get("viewer_liked").is_some() {
body_liker = Some(body);
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
let body_liker = body_liker.expect("viewer_liked never appeared");
assert_eq!(
body_liker["viewer_liked"],
json!(true),
"viewer_liker should see viewer_liked=true: {body_liker:?}"
);
assert_eq!(
body_liker["viewer_reposted"],
json!(false),
"viewer_liker did not repost: {body_liker:?}"
);
assert_eq!(body_liker["like_count"], json!(1));
assert_eq!(body_liker["repost_count"], json!(1));
// Now query with `viewer_did=reposter`: opposite state.
let body_reposter: Value = c
.get(format!("{APPVIEW_URL}/api/post/{post_uri}"))
.query(&[("viewer_did", reposter.as_str())])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(body_reposter["viewer_liked"], json!(false));
assert_eq!(body_reposter["viewer_reposted"], json!(true));
// And `viewer_did=outsider` (no engagement) → both false.
let body_outsider: Value = c
.get(format!("{APPVIEW_URL}/api/post/{post_uri}"))
.query(&[("viewer_did", outsider.as_str())])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(body_outsider["viewer_liked"], json!(false));
assert_eq!(body_outsider["viewer_reposted"], json!(false));
// Without `viewer_did`, the booleans should be absent (the client
// renders "unknown" state). The counts still come back so the UI
// can show "1 like".
let body_anonymous: Value = c
.get(format!("{APPVIEW_URL}/api/post/{post_uri}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert!(
body_anonymous.get("viewer_liked").is_none(),
"viewer_liked should be absent without viewer_did: {body_anonymous:?}"
);
assert!(
body_anonymous.get("viewer_reposted").is_none(),
"viewer_reposted should be absent without viewer_did: {body_anonymous:?}"
);
}