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
+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())
}
}
}