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:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user