Compare commits

...
7 Commits
Author SHA1 Message Date
tomdeboneandClaude Opus 5 ec8fe187fe docs: Deployment-, Architektur- und Release-Doku; README aktualisiert
docs/ war leer. Jetzt drei Dateien, jede Behauptung am Code verifiziert:

* deployment.md — docker compose, Migrationsweg, vollständige Env-Referenz
  mit den Fallstricken (P-256-taugliches PDS_JWT_SECRET, Pflicht-aber-tot
  S3_BUCKET_APPVIEW), Release-Build, systemd-Units, Reverse-Proxy inkl. des
  Hinweises, dass die AppView CORS Any liefert und der Proxy den Header
  ersetzen statt ergänzen muss, Health-Checks und Cursor-Verhalten beim
  Neustart.
* architecture.md — Crate-Verantwortlichkeiten, ASCII-Datenfluss, beide
  DB-Schemata. Hält fest, was die Topologie erklärt: die eigene PDS speist
  keinen Firehose, eigene Records erreichen die AppView nur über den
  Best-Effort-Push.
* tauri-release.md — Signing-Keys, v2-Updater-Config, latest.json, Build pro
  Plattform. Der _comment in tauri.conf.json war irreführend: active/dialog
  sind v1-Reste, die der v2-Updater ignoriert; dass nichts passiert, liegt
  daran, dass niemand check() aufruft und das Plugin nicht installiert ist.

README bekommt Phase 8, eine Doku-Übersicht, korrigierte Testanleitung
(src-tauri ist ein eigener Workspace und wird von cargo test --workspace
nicht erfasst) und einen Abschnitt "Bekannte Lücken" statt der bisher
lückenlosen Erfolgsmeldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 21:37:12 +02:00
tomdeboneandClaude Opus 5 31880e1005 feat(tauri-app): Notifications-View mit Badge, Follower-Listen, DID-Navigation
Bindet die neuen AppView-Endpoints an: sechs IPC-Commands
(fetch_notifications, notification_count, mark_notifications_seen,
fetch_followers, fetch_following, fetch_thread) plus profile_get_by_did,
dazu die TS-Gegenstücke.

* NotificationsView: Liste mit Icon/Text je Art, Avatar, Vorschau des
  subject_text, Cursor-Pagination; Klick auf eine Zeile mit Subject öffnet
  den Thread. Beim Öffnen wird mit dem indexed_at der obersten Zeile als
  Wasserzeichen quittiert.
* NavRail: Eintrag mit Unread-Badge, gepollt im vorhandenen 5s-Timer und
  über den bestehenden Teardown-Pfad abgeräumt; der Poll pausiert, solange
  die Liste offen ist.
* ProfileView: Follower- und Following-Zahlen sind jetzt Buttons und öffnen
  die jeweilige Liste inline.

Navigiert wird über die DID, nicht über den Handle: für Actors, die weder
profiles noch posts kennen, liefert die AppView einen abgeschnittenen
Platzhalter im handle-Feld ("did:plc:abcd…"), und ein Klick darauf landete
in einem synthetischen Leerprofil. Die echte DID steht im DTO und wird jetzt
explizit durchgereicht — keine Heuristik auf das Platzhalter-Format.

Dabei aufgefallen und mitgefixt: ProfileView lud nur in onMount, obwohl die
Komponente bei Profil-zu-Profil-Navigation gemountet bleibt — Posts und
Zahlen des vorherigen Nutzers wären unter dem neuen Namen stehengeblieben.
Jetzt ein auf (did, handle) gekeyter Effekt.

Das Thread-Overlay hing im else-Zweig der leeren Timeline und wäre aus dem
Notifications-View unsichtbar gewesen; es ist jetzt ein Snippet, das beide
Views rendern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 21:37:00 +02:00
tomdeboneandClaude Opus 5 bce4c7862f test(pds-server): listRepos-Test unabhängig von der Tabellengröße machen
sync_list_repos_includes_recent_user paginierte von vorn durch listRepos und
gab nach 50 Seiten à 50 Zeilen auf. Die repos-Tabelle der Dev-Instanz ist
inzwischen auf ~3800 Zeilen gewachsen, der frisch angelegte DID sortierte
dahinter — der Test war rot, obwohl der Endpoint korrekt antwortet
(manuell mit passendem Cursor verifiziert).

Der Cursor startet jetzt unmittelbar *vor* dem Ziel-DID. did_cursor_lt()
taugte dafür nicht: es dekrementiert das erste Byte und landet damit vor
jedem did:..., also wieder am Tabellenanfang.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 21:36:47 +02:00
tomdeboneandClaude Opus 5 465a88e4e5 test(appview): handle-sync-Tests vom globalen DB-Zustand entkoppeln
Fünf handle_sync-Tests schlugen fehl, sobald der Indexer parallel lief oder
die Datenbank schon andere handle-lose Posts enthielt: run_once() scannt
global, geordnet nach did und gedeckelt auf BATCH_SIZE, also landete der
frisch geseedete Test-DID schlicht nicht im Batch — die Assertions über
report.resolved sagten dann etwas über fremde Zeilen aus.

run_once() ist jetzt select_candidates() + resolve_batch(dids); das
Verhalten in Produktion ist unverändert. Die Dispatch-Tests treiben
resolve_batch mit ihrem eigenen DID, der Batch-Limit-Test prüft den Deckel
dort, wo er sitzt (im SELECT), statt ihn aus einem globalen Report
abzuleiten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 21:36:47 +02:00
tomdeboneandClaude Opus 5 c4ca218d97 feat(appview): Notifications, Follower-/Following-Listen, Thread-Route
Bisher erfuhr ein Nutzer nie, dass jemand anderes mit ihm interagiert hat:
Like, Repost, Follow und Reply hinterließen keine Spur, an der der Client
hätte pollen können. Der Tray-/Notification-Pfad im Desktop-Client (Phase 7)
hing damit in der Luft.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 21:36:32 +02:00
tomdeboneandClaude Opus 5 9d009bfcba feat(at-blob): InMemoryBlobStore echt implementieren
Der Store war ein Platzhalter aus vier unimplemented!() — jeder Codepfad,
der ihn statt S3BlobStore benutzt hätte, wäre gepaniced.

Backing store ist eine RwLock<HashMap<key, (Bytes, mime)>>, damit der Typ
Send + Sync bleibt und hinter Arc<dyn BlobStore> funktioniert. put()
berechnet die CID identisch zu s3.rs (sha256 → cid_for_raw(0x55, hash)),
get() liefert None statt Fehler, delete() ist idempotent, public_url()
zeigt auf die vorhandene PDS-Route /blob/:cid.

5 Tests: Roundtrip, fehlender Key, delete-dann-get, delete auf Unbekanntes,
gleiche Bytes → gleiche CID.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 21:36:00 +02:00
tomdeboneandClaude Opus 5 3c6f4dd67c chore(config): Binaries laden .env selbst; .env.example korrigiert
`cp .env.example .env && cargo run` — der im README dokumentierte Ablauf —
schlug bisher mit `missing env: PDS_HOST` fehl: nichts im Prozess hat die
Datei je gelesen. Beide Bins rufen jetzt als erstes `dotenvy::dotenv()` auf;
echte Umgebungsvariablen gewinnen weiterhin.

Dazu .env.example am Code verifiziert:

* PDS_JWT_SECRET war weder Hex noch ein gültiger P-256-Skalar. jwt_issuer.rs
  macht hex::decode + p256::SecretKey::from_bytes; ein ungültiger Wert lässt
  den Server starten, aber jeder Pfad über server_p256_public_multibase
  antwortet 500 — also nicht nur create/refreshSession, sondern auch jeder
  Record-Write (repo.rs, feed.rs, blob.rs, profile.rs).
* JETSTREAM_COLLECTIONS fehlten app.twi.post (das eigene 160-Zeichen-Lexicon)
  und app.bsky.actor.profile, obwohl der Indexer beide verarbeitet.
* APP_ENV entfernt — wird nirgends gelesen.
* PDS_INTERNAL_URL, APPVIEW_INTERNAL_URL, APPVIEW_HANDLE_SYNC_INTERVAL_SECS
  und die MAARCADETWEET_*-Overrides des Clients ergänzt.
* S3_BUCKET_APPVIEW als das markiert, was es ist: Pflichtvariable ohne Leser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 21:35:53 +02:00
32 changed files with 6134 additions and 218 deletions
+32 -5
View File
@@ -1,26 +1,46 @@
# =====================================================
# maarcadetweet — environment
# =====================================================
# Copy to .env and adjust.
# Copy to .env and adjust. Beide Binaries laden `.env` beim Start
# selbst (dotenvy); echte Umgebungsvariablen haben Vorrang.
# --- General ---
RUST_LOG=info,maarcadetweet=debug,sqlx=warn
APP_ENV=dev
# --- PDS server ---
PDS_HOST=127.0.0.1
PDS_PORT=2583
PDS_PUBLIC_URL=http://127.0.0.1:2583
PDS_HANDLE_DNS_ZONE=.maarcadetweet.local
PDS_JWT_SECRET=change-me-to-a-32-byte-random-string-please
# MUSS Hex sein, >= 32 Bytes ergeben und ein gültiger P-256-Skalar
# sein (also nicht lauter Nullen) — `jwt_issuer.rs` macht hex::decode()
# und p256::SecretKey::from_bytes(). Ein ungültiger Wert lässt den
# Server zwar starten, aber alles, was den Serverschlüssel ableitet,
# antwortet mit 500: createAccount/createSession/refreshSession UND
# jeder Record-Write (repo.rs, feed.rs, blob.rs, profile.rs).
# Der Wert hier ist ein Zufallswert fürs Beispiel — für echte
# Instanzen einen eigenen erzeugen:
# openssl rand -hex 32
PDS_JWT_SECRET=522084586f3d3abb835d43b7c4726735d78803dd58490f62a92571cd29033a95
# Wohin die PDS ihre Commits pusht (POST /internal/ingest-commit).
# Default: APPVIEW_PUBLIC_URL.
# APPVIEW_INTERNAL_URL=http://127.0.0.1:2584
# --- AppView service ---
APPVIEW_HOST=127.0.0.1
APPVIEW_PORT=2584
APPVIEW_PUBLIC_URL=http://127.0.0.1:2584
JETSTREAM_URL=wss://jetstream1.us-east.bsky.network/subscribe
# Collections the AppView will index
JETSTREAM_COLLECTIONS=app.bsky.feed.post,app.bsky.feed.like,app.bsky.feed.repost,app.bsky.graph.follow
# Collections, die die AppView indexiert. `app.twi.post` ist das
# eigene 160-Zeichen-Lexicon und muss mit rein, sonst tauchen eigene
# Posts nur über den PDS-Push auf; `app.bsky.actor.profile` füttert
# den Profil-Cache.
JETSTREAM_COLLECTIONS=app.twi.post,app.bsky.feed.post,app.bsky.feed.like,app.bsky.feed.repost,app.bsky.graph.follow,app.bsky.actor.profile
# Für den Handle-Sync-Worker: welche PDS nach lokalen Handles gefragt
# wird. Default: PDS_PUBLIC_URL.
# PDS_INTERNAL_URL=http://127.0.0.1:2583
# Intervall des Handle-Sync-Workers in Sekunden (Default: 300).
# APPVIEW_HANDLE_SYNC_INTERVAL_SECS=60
# --- Databases ---
DATABASE_URL_PDS=postgres://pds:pds@127.0.0.1:5434/pds
@@ -32,6 +52,9 @@ S3_REGION=us-east-1
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_BUCKET_PDS=maarcadetweet-pds
# Pflichtvariable in AppConfig::from_env(), wird derzeit von keinem
# Code-Pfad gelesen — muss gesetzt sein, damit der Start nicht
# fehlschlägt.
S3_BUCKET_APPVIEW=maarcadetweet-appview
# --- PLC Directory (dev: leave default; can mock) ---
@@ -40,3 +63,7 @@ PLC_DIRECTORY_URL=https://plc.directory
# --- AppView ingest auth (optional, dev ok if unset) ---
# APPVIEW_INGEST_SECRET=change-me-to-a-shared-secret-between-pds-and-appview
# --- Tauri-Client (Build-/Laufzeit-Overrides des Desktop-Clients) ---
# MAARCADETWEET_PDS_URL=http://127.0.0.1:2583
# MAARCADETWEET_APPVIEW_URL=http://127.0.0.1:2584
Generated
+2
View File
@@ -51,6 +51,7 @@ dependencies = [
"axum",
"base64",
"chrono",
"dotenvy",
"futures",
"reqwest",
"rustls",
@@ -1882,6 +1883,7 @@ dependencies = [
"chrono",
"ciborium",
"cid",
"dotenvy",
"hex",
"k256",
"p256",
+1
View File
@@ -54,6 +54,7 @@ blake3 = "1"
rand = "0.8"
rand_core = "0.6"
hex = "0.4"
dotenvy = "0.15"
base64 = "0.22"
parking_lot = "0.12"
async-stream = "0.3"
+51 -18
View File
@@ -12,20 +12,22 @@ crates/
├── at-identity/ DID, PLC, Handle-Resolution
├── at-mst/ Merkle-Search-Tree
├── at-repo/ Repos, Commits, Blöcke, TID-Revs
├── at-blob/ S3-kompatibler Blob-Store (MinIO)
├── at-blob/ S3-kompatibler Blob-Store (MinIO) + In-Memory-Store
├── at-firehose/ Jetstream-Consumer (WebSocket)
├── at-shared/ Config, Errors, DID, Cursor
├── pds-server/ axum HTTP PDS (bin)
└── appview/ Jetstream-Indexer + REST-API (bin)
crates/tauri-app/ Tauri 2 + Svelte 5 + Vite + TS Desktop-Client
├── src/ Svelte-Components (Terminal, StatusBar, NavRail, PostCard, ComposeBox, LoginScreen)
├── src/ Svelte-Components (Terminal, StatusBar, NavRail, PostCard, ComposeBox,
│ LoginScreen, ProfileView, NotificationsView)
├── src/lib/styles/ tokens.css (1:1 vom maarcade-Design)
└── src-tauri/ Rust-IPC-Layer
lexicons/app/twi/post.json Custom Lexicon mit maxLength: 160
migrations/pds/ PDS-DB-Schema (users, repos, blobs, sessions, plc_ops)
migrations/appview/ AppView-DB-Schema (posts, likes, follows, timeline_cache, jetstream_cursor)
migrations/appview/ AppView-DB-Schema (posts, likes, follows, notifications, profiles, jetstream_cursor)
docs/ Deployment, Architektur, Tauri-Release (siehe unten)
```
## Setup
@@ -34,7 +36,8 @@ migrations/appview/ AppView-DB-Schema (posts, likes, follows, timelin
# 1) Datenbanken + MinIO starten
docker compose up -d
# 2) Umgebungsvariablen
# 2) Umgebungsvariablen — beide Binaries laden .env selbst (dotenvy).
# PDS_JWT_SECRET muss Hex sein: openssl rand -hex 32
cp .env.example .env
# 3) Workspace kompilieren + Tests
@@ -61,26 +64,56 @@ cargo run -p appview
| 2 MST + Repo (Spec-konforme CBOR-Encoding) | ✅ done — `encode_key` = `base64url(sha256(raw_key))` per atproto-Spec, `split_around`/`wrap_with_split` threaden den recursive right_sub korrekt als `k_tree` weiter. 27 MST + 13 Repo + 4 Commit Tests grün. |
| 3 PDS-Server (com.atproto.* XRPC) | ✅ done — createAccount/Session/Refresh, createRecord/deleteRecord, like/repost, follow |
| 4 AppView-Foundation (Jetstream-Index) | ✅ done — Jetstream-Indexer + identity-Event-Backfill + PLC-handle-sync-Worker |
| 5 AppView-REST-API | ✅ done — timeline, profile (by-did + by-handle), search, post-by-uri, thread-context |
| 5 AppView-REST-API | ✅ done — timeline, profile (by-did + by-handle), search, post-by-uri, thread, notifications, followers/following |
| 6 Tauri-UI-Logik an Backend koppeln | ✅ done — LoginScreen, NavRail, PostCard, ComposeBox, Profile/Compose/Search/Settings-Views |
| 7 Polish (Tray, Notifications, Auto-Update) | ✅ done — Tray-Icon custom (`tauri::include_image!`), Notification-Click navigiert via `app://notification`-Event + `openThread`-Helper zu Thread-Detail, Auto-Update in Dev deaktiviert (siehe `_comment` in `tauri.conf.json` für Production-Setup) |
| 7 Polish (Tray, Notifications, Auto-Update) | ✅ done — Tray-Icon custom (`tauri::include_image!`), Notification-Click navigiert via `app://notification`-Event + `openThread`-Helper zu Thread-Detail, Auto-Update in Dev inert (Production-Weg: [`docs/tauri-release.md`](docs/tauri-release.md)) |
| 8 Social-Graph + Benachrichtigungen | ✅ done — `notifications`-Tabelle, Schreibpfad im Jetstream-Indexer (idempotent, keine Selbst-Notifications), `/api/notifications[/count|/seen]`, `/api/followers`, `/api/following`, eigene `/api/thread`-Route; im Client Notifications-View mit Unread-Badge und klickbare Follower-/Following-Listen im Profil |
## Tests
```
running 16 tests (at-crypto)
test result: ok. 16 passed; 0 failed; 0 ignored
running 3 tests (at-lexicon)
test result: ok. 3 passed; 0 failed
running 2 tests (at-shared)
test result: ok. 2 passed; 0 failed
running 2 tests (at-repo)
test result: ok. 2 passed; 0 failed
running 4 tests (at-crypto plc_op — Phase 1)
test result: ok. 4 passed; 0 failed
```bash
cargo test --workspace # Rust: PDS, AppView, at-* Crates
cd crates/tauri-app && npx vitest run # Svelte/TS
cd crates/tauri-app/src-tauri && cargo test # Tauri-IPC (eigener Workspace!)
```
Der zuvor als "geplant für Phase 1" markierte `jwt::issue_and_verify`-Test wurde zwischenzeitlich grün gezogen (P-256-PKCS#8-PEM-Encoder ist über `p256::pkcs8::EncodePrivateKey` da).
Stand zuletzt gegen den lokalen Dev-Stack (docker compose + laufender PDS + AppView):
Rust-Workspace grün (u.a. 27 MST, 24 PDS-Integration, 49 AppView-Lib, 14 AppView-Integration),
Frontend grün. Zwei Vorbehalte:
* Die DB-gestützten Integrationstests sind *fail-open* — ohne erreichbare Postgres/PDS
überspringen sie sich selbst und melden das nur auf stderr. Ein grüner Lauf ohne
laufenden Stack sagt also weniger, als er aussieht.
* Einige Tests hängen am Zustand der Dev-Datenbank; auf einer frischen DB können
`handle_sync`-Tests abweichen. Wer sie ernst nimmt, prüft sie gegen eine definierte DB.
`crates/tauri-app/src-tauri` hat ein eigenes `[workspace]` und ist **nicht** Teil des
Root-Workspace; `cargo test --workspace` von oben erfasst den IPC-Layer nicht.
## Dokumentation
| Datei | Inhalt |
|---|---|
| [`docs/architecture.md`](docs/architecture.md) | Crate-Verantwortlichkeiten, Datenfluss PDS → Jetstream → AppView → Client, Tabellenübersicht |
| [`docs/deployment.md`](docs/deployment.md) | Betrieb: docker compose, Migrationen, alle Env-Variablen, Release-Build, systemd-Units, Reverse-Proxy, Health-Checks |
| [`docs/tauri-release.md`](docs/tauri-release.md) | Signing-Keys, Updater-Config, `latest.json`, Build pro Plattform, Artefaktpfade |
## Bekannte Lücken
* Die eigene PDS speist **keinen** Firehose (`com.atproto.sync.subscribeRepos` fehlt) —
eigene Records erreichen die AppView nur über den Best-Effort-Push
`POST /internal/ingest-commit`.
* Die AppView-Leseschnittstelle hat **keine Auth** und CORS `Any`; bei
`/api/notifications` sind das erstmals halbwegs private Daten.
* Notifications werden nie gelöscht: Unlike/Unfollow lässt die Zeile stehen, und der
Dedupe-Key macht sie „einmal pro (Empfänger, Autor, Art, Subject) für immer".
* Auto-Update ist nur dokumentiert, nicht verdrahtet: niemand ruft `check()` auf, das
Updater-Plugin ist nicht installiert (siehe `docs/tauri-release.md`).
* Reply-Notifications gehen verloren, wenn die Antwort vor ihrem Parent indiziert wird
(kein Nachlauf) — bei Jetstream möglich.
* `at-blob` spricht MinIO ohne Signature V4 — echtes AWS S3 funktioniert damit nicht.
* Die PDS liefert kein `.well-known/did.json`; `describeServer` gibt die DID hart
als `did:web:pds.maarcadetweet.local` zurück.
## Design
+1
View File
@@ -19,6 +19,7 @@ path = "src/main.rs"
[dependencies]
tokio = { workspace = true }
dotenvy = { workspace = true }
axum = { workspace = true }
tower = { workspace = true }
tower-http = { workspace = true }
+55 -13
View File
@@ -147,7 +147,14 @@ impl HandleSyncWorker {
/// `handle` is filled, so a DID that becomes resolvable later
/// (e.g. the user joins the local PDS) gets re-attempted.
pub async fn run_once(&self) -> Result<SyncReport> {
let dids: Vec<(String,)> = sqlx::query_as(
let dids = self.select_candidates().await?;
self.resolve_batch(dids).await
}
/// The SELECT half of [`Self::run_once`]: up to [`BATCH_SIZE`]
/// distinct DIDs still waiting for a handle.
async fn select_candidates(&self) -> Result<Vec<String>> {
let rows: Vec<(String,)> = sqlx::query_as(
r#"SELECT DISTINCT did
FROM posts
WHERE handle = ''
@@ -159,7 +166,18 @@ impl HandleSyncWorker {
.bind(BATCH_SIZE)
.fetch_all(&self.db)
.await?;
Ok(rows.into_iter().map(|(did,)| did).collect())
}
/// The resolve-and-write half of [`Self::run_once`], split out so
/// tests can drive it with a DID set of their own.
///
/// `run_once`'s own scan is global and capped at [`BATCH_SIZE`],
/// so against a shared database with a live indexer a test's
/// freshly seeded DID may simply not make the batch — which made
/// the dispatch tests fail for reasons that had nothing to do with
/// dispatch. Passing the DIDs in removes that coupling.
async fn resolve_batch(&self, dids: Vec<String>) -> Result<SyncReport> {
let mut report = SyncReport::default();
if dids.is_empty() {
return Ok(report);
@@ -174,7 +192,7 @@ impl HandleSyncWorker {
// the parallel-write benefit at this batch size.
use futures::stream::{self, StreamExt};
let dispatch_results: Vec<(String, anyhow::Result<Option<String>>)> = stream::iter(dids)
.map(|(did,)| async move {
.map(|did| async move {
let r = self.dispatch(&did).await;
(did, r)
})
@@ -376,7 +394,11 @@ mod tests {
.into_arc();
let worker = worker_with(db, resolver);
let report = worker.run_once().await.unwrap();
// Drive the resolve half with our own DID instead of
// `run_once()`: the global scan is capped at BATCH_SIZE and
// this database is shared with a live indexer, so a freshly
// seeded DID is not guaranteed to make the batch.
let report = worker.resolve_batch(vec![did.clone()]).await.unwrap();
assert!(report.resolved >= 2, "expected ≥2 resolved, got {report:?}");
assert_eq!(report.failed, 0);
assert_eq!(report.skipped, 0);
@@ -411,10 +433,14 @@ mod tests {
.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
// The SELECT is what filters an already-handled DID out, so
// this one goes through the full `run_once()` — but scoped to
// the assertion that OUR did is untouched, not to global counts.
let candidates = worker.select_candidates().await.unwrap();
assert!(
!candidates.contains(&did),
"a DID with a handle must not be selected"
);
let h = get_handle(&worker.db, &did).await;
assert_eq!(h.as_deref(), Some("pre-existing.handle"));
@@ -456,8 +482,22 @@ mod tests {
web_resolver: Arc::clone(&resolver),
interval_secs: 999,
};
let report = worker.run_once().await.unwrap();
// Exactly BATCH_SIZE rows updated (one post per DID).
// The cap lives in the SELECT, so that's what we assert on.
// Asserting `report.resolved == BATCH_SIZE` after a full
// `run_once()` only holds on a database where nothing else is
// waiting for a handle — against the shared dev DB with a live
// Jetstream indexer, other DIDs legitimately fill the batch.
let candidates = worker.select_candidates().await.unwrap();
assert!(
candidates.len() as i64 <= BATCH_SIZE,
"select must never exceed BATCH_SIZE, got {}",
candidates.len()
);
// And the resolve half must handle a full batch of our own
// DIDs: one row per DID, all of them updated.
let batch: Vec<String> = all_dids.iter().take(BATCH_SIZE as usize).cloned().collect();
let report = worker.resolve_batch(batch).await.unwrap();
assert_eq!(
report.resolved as i64,
BATCH_SIZE,
@@ -465,7 +505,7 @@ mod tests {
);
assert_eq!(report.failed, 0);
// The remaining 5 DIDs must still have empty handles.
// The 5 DIDs we left out must still have empty handles.
let remaining: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM posts WHERE handle = '' AND did LIKE $1")
.bind(format!("{prefix}_%"))
@@ -528,7 +568,9 @@ mod tests {
.await
.unwrap();
let report = worker.run_once().await.unwrap();
// Scoped to our own DID: `run_once()`'s global batch would
// report whatever else the indexer left pending.
let report = worker.resolve_batch(vec![did.clone()]).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"));
@@ -571,7 +613,7 @@ mod tests {
web_resolver: web,
interval_secs: 999,
};
let report = worker.run_once().await.unwrap();
let report = worker.resolve_batch(vec![did.clone()]).await.unwrap();
assert_eq!(
report.resolved, 1,
"did:web must resolve through the web resolver, got {report:?}"
@@ -630,7 +672,7 @@ mod tests {
web_resolver: web_arc,
interval_secs: 999,
};
let report = worker.run_once().await.unwrap();
let report = worker.resolve_batch(vec![did.clone()]).await.unwrap();
assert_eq!(report.resolved, 0, "did:key must not resolve, got {report:?}");
assert_eq!(
report.failed, 0,
+557
View File
@@ -348,6 +348,31 @@ pub async fn upsert_post(db: &PgPool, row: &mut PostRow) -> Result<()> {
.bind(&row.avatar_cid)
.execute(db)
.await?;
// Reply notification. A post with a `parent_uri` is a reply, so the
// parent's author gets a "someone replied to you" row.
//
// `subject_uri` is the REPLY's own URI, not the parent's: the
// notification list hydrates `subject_uri`'s text, and what the
// recipient wants to read is what the replier wrote — they already
// know the content of their own post. It also makes the row a
// direct link target for "open this reply in the thread view".
//
// The dedupe index keys on the reply URI, so re-indexing (or an
// edit that re-runs the upsert) can't produce a second row.
if let Some(parent_uri) = row.parent_uri.as_deref() {
if let Some(recipient) = post_author_did(db, parent_uri).await? {
record_notification(
db,
&recipient,
&row.did,
NOTIF_REPLY,
Some(&row.uri),
row.created_at,
)
.await?;
}
}
Ok(())
}
@@ -414,6 +439,23 @@ pub async fn upsert_like(
.bind(&post_uri)
.execute(&mut *tx)
.await?;
// Notify the post's author. Runs inside the same transaction as
// the counter bump so a crash can't leave a like counted but
// un-notified (or vice versa). `post_author_did` returns None
// for a post we haven't indexed — then there's nobody to
// notify and we quietly skip.
if let Some(recipient) = post_author_did(&mut *tx, &post_uri).await? {
record_notification(
&mut *tx,
&recipient,
did,
NOTIF_LIKE,
Some(&post_uri),
created_at,
)
.await?;
}
}
tx.commit().await?;
Ok(())
@@ -489,6 +531,20 @@ pub async fn upsert_repost(
.bind(&post_uri)
.execute(&mut *tx)
.await?;
// Same transactional notification write as the like path — see
// `upsert_like` for the rationale.
if let Some(recipient) = post_author_did(&mut *tx, &post_uri).await? {
record_notification(
&mut *tx,
&recipient,
did,
NOTIF_REPOST,
Some(&post_uri),
created_at,
)
.await?;
}
}
tx.commit().await?;
Ok(())
@@ -517,6 +573,124 @@ pub async fn delete_repost(db: &PgPool, did: &str, rkey: &str) -> Result<()> {
Ok(())
}
// -- notifications ---------------------------------------------------------
/// The four notification kinds the AppView produces. Kept as `&str`
/// constants rather than an enum because the value is a plain `TEXT`
/// column guarded by a CHECK constraint (migration 0008) and every
/// call site is a literal — an enum would only add a `to_string()`.
pub const NOTIF_LIKE: &str = "like";
pub const NOTIF_REPOST: &str = "repost";
pub const NOTIF_FOLLOW: &str = "follow";
pub const NOTIF_REPLY: &str = "reply";
/// Should an interaction by `author_did` aimed at `recipient_did`
/// produce a notification row?
///
/// Pure so it can be unit-tested without a database. Two rules:
///
/// 1. **No self-interactions.** Liking your own post, reposting
/// yourself, self-following or replying to yourself must stay
/// silent — the user already knows they did it, and a "you liked
/// your own post" row is pure noise.
/// 2. **No empty DIDs.** An empty recipient means we failed to resolve
/// the target (e.g. a like against a post that isn't in our index),
/// and an empty author means the event was malformed. Either way
/// the row would be unattributable in the UI.
///
/// The same two rules are ALSO enforced in SQL inside
/// [`record_notification`], so a caller that forgets this guard still
/// can't write a bad row — this function exists to skip the round trip
/// in the common self-interaction case and to make the rule testable.
pub fn should_notify(recipient_did: &str, author_did: &str) -> bool {
!recipient_did.is_empty() && !author_did.is_empty() && recipient_did != author_did
}
/// Insert one notification row, if and only if it is warranted.
///
/// Returns `Ok(true)` when a row was actually written, `Ok(false)` when
/// the write was skipped — either because the interaction failed
/// [`should_notify`], because the recipient isn't a user this AppView
/// serves, or because the same notification already exists.
///
/// **"Local user"**: the AppView deliberately has no `users` table —
/// the PDS owns account state. The closest thing we have is "a DID this
/// AppView knows about", i.e. one with a row in the `profiles` cache
/// (written by the PDS profile push / the Jetstream `app.bsky.actor.profile`
/// arm) or at least one indexed post. That is exactly the set of users
/// a client could ever poll notifications for, so restricting writes to
/// it keeps us from materialising a notification row for every like on
/// the entire public firehose while never dropping one a local user
/// would actually see.
///
/// **Idempotency**: the `ON CONFLICT` target is the expression index
/// `notifications_dedupe_idx` from migration 0008, keyed on
/// `(recipient_did, author_did, kind, COALESCE(subject_uri, ''))`. The
/// `COALESCE` is load-bearing: a plain unique index would let two
/// `follow` rows (whose `subject_uri` is NULL) coexist, because SQL
/// NULLs never collide. Re-indexing the same Jetstream event — on a
/// reconnect replay, or via the PDS `/internal/ingest-commit` push that
/// races the firehose — is therefore a no-op.
///
/// The executor is generic so this can run either on the pool (the
/// reply path) or inside the caller's transaction (the like / repost
/// paths, where the notification must commit atomically with the
/// counter bump).
pub async fn record_notification<'e, E>(
exec: E,
recipient_did: &str,
author_did: &str,
kind: &str,
subject_uri: Option<&str>,
created_at: chrono::DateTime<chrono::Utc>,
) -> Result<bool>
where
E: sqlx::PgExecutor<'e>,
{
if !should_notify(recipient_did, author_did) {
return Ok(false);
}
// One statement, so there's no TOCTOU window between "is the
// recipient local?" and "insert". Every parameter is explicitly
// cast because `INSERT ... SELECT $1, $2, ...` gives Postgres no
// column context to infer the placeholder types from.
let res = sqlx::query(
r#"INSERT INTO notifications
(recipient_did, author_did, kind, subject_uri, created_at)
SELECT $1::text, $2::text, $3::text, $4::text, $5::timestamptz
WHERE $1::text <> $2::text
AND $1::text <> ''
AND $2::text <> ''
AND (EXISTS (SELECT 1 FROM profiles WHERE did = $1::text)
OR EXISTS (SELECT 1 FROM posts WHERE did = $1::text))
ON CONFLICT (recipient_did, author_did, kind, COALESCE(subject_uri, ''))
DO NOTHING"#,
)
.bind(recipient_did)
.bind(author_did)
.bind(kind)
.bind(subject_uri)
.bind(created_at)
.execute(exec)
.await?;
Ok(res.rows_affected() > 0)
}
/// Look up the author DID of an indexed post. `None` when the post
/// isn't in our index — which is the normal case for a like/reply
/// aimed at a post hosted somewhere we don't follow. The caller then
/// simply skips the notification rather than guessing a recipient.
async fn post_author_did<'e, E>(exec: E, uri: &str) -> Result<Option<String>>
where
E: sqlx::PgExecutor<'e>,
{
let did: Option<String> = sqlx::query_scalar("SELECT did FROM posts WHERE uri = $1")
.bind(uri)
.fetch_optional(exec)
.await?;
Ok(did)
}
// -- follows ---------------------------------------------------------------
pub async fn upsert_follow(
@@ -540,6 +714,22 @@ pub async fn upsert_follow(
.bind(created_at)
.execute(db)
.await?;
// Notify the followed user. `subject_uri` is NULL — a follow isn't
// about a post — which is exactly the case the dedupe index's
// `COALESCE(subject_uri, '')` exists for. Not wrapped in a
// transaction with the follow upsert: the follow row is the source
// of truth and a missed notification is recoverable noise, whereas
// taking a transaction here would serialise every follow write.
record_notification(
db,
subject_did,
follower_did,
NOTIF_FOLLOW,
None,
created_at,
)
.await?;
Ok(())
}
@@ -1181,6 +1371,373 @@ mod tests {
}
}
#[cfg(test)]
mod notification_tests {
use super::*;
use serde_json::json;
fn did(tag: &str) -> String {
format!("did:plc:notif_{}_{}", tag, uuid::Uuid::new_v4().simple())
}
/// Seed one post so `did` counts as a user this AppView knows
/// about (see [`record_notification`]'s "local user" note) and so
/// there's something to like / reply to.
///
/// The `handle` is deliberately non-empty. `handle_sync`'s tests
/// assert exact counts over a *global* scan of empty-handle rows,
/// so a fixture that left the column blank would silently break
/// them whenever both suites share a database.
async fn seed_post(db: &PgPool, author: &str, rkey: &str) -> String {
let uri = format!("at://{author}/app.twi.post/{rkey}");
sqlx::query(
r#"INSERT INTO posts
(uri, did, handle, rkey, collection, text, cid,
parent_uri, root_uri, langs, created_at)
VALUES ($1,$2,'notif-fixture.test','x','app.twi.post','seed','bafy',
NULL,NULL,NULL, now())
ON CONFLICT (uri) DO NOTHING"#,
)
.bind(&uri)
.bind(author)
.execute(db)
.await
.unwrap();
uri
}
async fn count_notifications(db: &PgPool, recipient: &str, kind: &str) -> i64 {
sqlx::query_scalar(
"SELECT COUNT(*)::BIGINT FROM notifications \
WHERE recipient_did = $1 AND kind = $2",
)
.bind(recipient)
.bind(kind)
.fetch_one(db)
.await
.unwrap()
}
async fn cleanup(db: &PgPool, dids: &[&str]) {
for d in dids {
let _ = sqlx::query(
"DELETE FROM notifications WHERE recipient_did = $1 OR author_did = $1",
)
.bind(d)
.execute(db)
.await;
let _ = sqlx::query("DELETE FROM likes WHERE did = $1")
.bind(d)
.execute(db)
.await;
let _ = sqlx::query("DELETE FROM reposts WHERE did = $1")
.bind(d)
.execute(db)
.await;
let _ = sqlx::query("DELETE FROM follows WHERE follower_did = $1 OR subject_did = $1")
.bind(d)
.execute(db)
.await;
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(d)
.execute(db)
.await;
}
}
#[test]
fn should_notify_rejects_self_and_empty() {
assert!(should_notify("did:plc:a", "did:plc:b"));
// Self-interaction: liking / replying to / following yourself.
assert!(!should_notify("did:plc:a", "did:plc:a"));
// Unresolvable ends of the edge.
assert!(!should_notify("", "did:plc:b"));
assert!(!should_notify("did:plc:a", ""));
assert!(!should_notify("", ""));
// Case matters — DIDs are compared verbatim, never folded.
assert!(should_notify("did:plc:A", "did:plc:a"));
}
/// A like by someone else must produce exactly one notification,
/// and re-indexing the same event (Jetstream replay racing the PDS
/// push) must not produce a second.
#[tokio::test]
async fn like_notifies_author_once() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let author = did("author");
let liker = did("liker");
// The liker also needs to exist for nothing in particular —
// only the *recipient* is checked — but seeding both keeps the
// fixture symmetric with reality.
let post_uri = seed_post(&db, &author, "p1").await;
seed_post(&db, &liker, "p1").await;
let record = json!({
"subject": { "uri": post_uri, "cid": "bafysubject" },
"createdAt": "2026-07-01T12:00:00Z"
});
upsert_like(&db, &liker, "lrk1", Some("bafylike"), Some(&record))
.await
.unwrap();
assert_eq!(count_notifications(&db, &author, NOTIF_LIKE).await, 1);
// Replay the exact same event.
upsert_like(&db, &liker, "lrk1", Some("bafylike"), Some(&record))
.await
.unwrap();
assert_eq!(
count_notifications(&db, &author, NOTIF_LIKE).await,
1,
"replayed like must not duplicate the notification"
);
// Unlike + re-like under a NEW rkey. The like row is recreated
// but the notification tuple is unchanged, so the dedupe index
// suppresses it — see migration 0008's "Idempotency" note.
delete_like(&db, &liker, "lrk1").await.unwrap();
upsert_like(&db, &liker, "lrk2", Some("bafylike2"), Some(&record))
.await
.unwrap();
assert_eq!(
count_notifications(&db, &author, NOTIF_LIKE).await,
1,
"toggling a like must not be a notification-spam vector"
);
// The stored row must point at the liked post and name the
// liker as the author.
let row: (String, Option<String>) = sqlx::query_as(
"SELECT author_did, subject_uri FROM notifications \
WHERE recipient_did = $1 AND kind = $2",
)
.bind(&author)
.bind(NOTIF_LIKE)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(row.0, liker);
assert_eq!(row.1.as_deref(), Some(post_uri.as_str()));
// Unread by default — that's what /api/notifications/count sees.
let unread: i64 = sqlx::query_scalar(
"SELECT COUNT(*)::BIGINT FROM notifications \
WHERE recipient_did = $1 AND read_at IS NULL",
)
.bind(&author)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(unread, 1);
cleanup(&db, &[&author, &liker]).await;
}
/// Liking / reposting your own post is silent.
#[tokio::test]
async fn self_interaction_writes_no_notification() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let author = did("selfie");
let post_uri = seed_post(&db, &author, "p1").await;
let record = json!({
"subject": { "uri": post_uri, "cid": "bafysubject" },
"createdAt": "2026-07-01T12:00:00Z"
});
upsert_like(&db, &author, "lrk1", None, Some(&record))
.await
.unwrap();
upsert_repost(&db, &author, "rrk1", None, Some(&record))
.await
.unwrap();
// Self-follow is legal in the protocol; it must stay silent too.
upsert_follow(&db, &author, &author, None).await.unwrap();
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*)::BIGINT FROM notifications WHERE recipient_did = $1",
)
.bind(&author)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(total, 0, "self-interactions must not notify");
// The like/repost themselves still landed — the notification
// suppression must not swallow the interaction.
let likes: i64 = sqlx::query_scalar("SELECT COUNT(*)::BIGINT FROM likes WHERE did = $1")
.bind(&author)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(likes, 1);
cleanup(&db, &[&author]).await;
}
/// A repost notifies, and a reply notifies the *parent's* author
/// with the reply's own URI as the subject.
#[tokio::test]
async fn repost_and_reply_notify() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let author = did("parent");
let other = did("replier");
let parent_uri = seed_post(&db, &author, "p1").await;
seed_post(&db, &other, "p1").await;
let record = json!({
"subject": { "uri": parent_uri, "cid": "bafysubject" },
"createdAt": "2026-07-01T12:00:00Z"
});
upsert_repost(&db, &other, "rrk1", None, Some(&record))
.await
.unwrap();
assert_eq!(count_notifications(&db, &author, NOTIF_REPOST).await, 1);
// Now a reply from `other` to `author`'s post.
let reply_record = json!({
"text": "nice one",
"createdAt": "2026-07-01T12:05:00Z",
"reply": {
"parent": { "uri": parent_uri, "cid": "bafyparent" },
"root": { "uri": parent_uri, "cid": "bafyparent" }
}
});
let mut row = PostRow::from_record(
&other,
"replykey",
"app.twi.post",
"bafyreply",
&reply_record,
None,
);
let reply_uri = row.uri.clone();
upsert_post(&db, &mut row).await.unwrap();
let subject: Option<String> = sqlx::query_scalar(
"SELECT subject_uri FROM notifications \
WHERE recipient_did = $1 AND kind = $2",
)
.bind(&author)
.bind(NOTIF_REPLY)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(
subject.as_deref(),
Some(reply_uri.as_str()),
"a reply notification's subject is the REPLY, so the list can \
preview what was written"
);
// Re-indexing the reply must not duplicate.
upsert_post(&db, &mut row).await.unwrap();
assert_eq!(count_notifications(&db, &author, NOTIF_REPLY).await, 1);
cleanup(&db, &[&author, &other]).await;
}
/// A follow notifies the followed user with a NULL subject_uri —
/// the case the dedupe index's `COALESCE(subject_uri, '')` exists
/// for, since plain SQL NULLs never collide.
#[tokio::test]
async fn follow_notifies_subject_and_dedupes_on_null_subject() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let subject = did("followee");
let follower = did("follower");
seed_post(&db, &subject, "p1").await;
let record = json!({ "subject": subject, "createdAt": "2026-01-01T00:00:00Z" });
upsert_follow(&db, &follower, &subject, Some(&record))
.await
.unwrap();
upsert_follow(&db, &follower, &subject, Some(&record))
.await
.unwrap();
assert_eq!(
count_notifications(&db, &subject, NOTIF_FOLLOW).await,
1,
"two NULL-subject follow rows must collide, not coexist"
);
let subject_uri: Option<String> = sqlx::query_scalar(
"SELECT subject_uri FROM notifications \
WHERE recipient_did = $1 AND kind = $2",
)
.bind(&subject)
.bind(NOTIF_FOLLOW)
.fetch_one(&db)
.await
.unwrap();
assert!(subject_uri.is_none(), "a follow is not about a post");
cleanup(&db, &[&subject, &follower]).await;
}
/// A recipient the AppView has never seen (no profile row, no
/// posts) gets nothing — this is what keeps us from materialising
/// a row for every like on the public firehose.
#[tokio::test]
async fn unknown_recipient_is_skipped() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let stranger = did("stranger");
let author = did("author");
let wrote = record_notification(
&db,
&stranger,
&author,
NOTIF_FOLLOW,
None,
chrono::Utc::now(),
)
.await
.unwrap();
assert!(!wrote, "unknown recipient must not get a notification row");
// Give the recipient a post — now they're a user we serve.
seed_post(&db, &stranger, "p1").await;
let wrote = record_notification(
&db,
&stranger,
&author,
NOTIF_FOLLOW,
None,
chrono::Utc::now(),
)
.await
.unwrap();
assert!(wrote, "known recipient must get the row");
// And the second call is a no-op thanks to ON CONFLICT.
let wrote = record_notification(
&db,
&stranger,
&author,
NOTIF_FOLLOW,
None,
chrono::Utc::now(),
)
.await
.unwrap();
assert!(!wrote, "duplicate must report false, not error");
cleanup(&db, &[&stranger, &author]).await;
}
}
/// Backfill the `posts.handle` column for every row belonging to
/// `did`. Used by the Jetstream `identity` handler when Jetstream
/// tells us a DID's handle has changed — every existing post row
+6
View File
@@ -18,6 +18,12 @@ use state::AppState;
#[tokio::main]
async fn main() -> Result<()> {
// Load `.env` from the working directory (and upwards) if present.
// Nothing else in the process reads it, so without this
// `cp .env.example .env && cargo run` fails with `missing env:
// PDS_HOST`. Real environment variables always win over the file.
let _ = dotenvy::dotenv();
// 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.
File diff suppressed because it is too large Load Diff
+159
View File
@@ -199,6 +199,165 @@ pub struct SearchResponse {
pub q: String,
}
// -- thread -----------------------------------------------------------------
/// `GET /api/thread` response.
///
/// This is the *full* thread shape: the ancestor chain above the post
/// and the direct replies below it. `/api/post/{uri}` keeps its own,
/// narrower `{ post, thread: { parent, root } }` shape for backwards
/// compatibility — both are built from the same
/// `routes::load_thread_context` call, so they can never disagree
/// about what the parent or root is.
///
/// `post` is `None` when the URI isn't in our index. In that case
/// `parents` / `replies` are empty and the counters are omitted, so a
/// client can render "post not found" from a single field check.
#[derive(Debug, Serialize)]
pub struct ThreadFullResponse {
pub post: Option<PostRow>,
/// Ancestor chain ordered **root first, immediate parent last**.
/// Empty for a top-level post, and truncated (from the top) when
/// the chain is longer than the walk limit or when an ancestor
/// isn't in our index — the client should treat a `parents[0]`
/// whose `parent_uri` is non-null as "chain continues above,
/// not loaded".
pub parents: Vec<PostRow>,
/// The thread root as named by the post's own `root_uri`. May be
/// the same row as `parents[0]`, and is `None` for a top-level
/// post (which is its own root).
pub root: Option<PostRow>,
/// Direct replies to `post`, oldest first. Only direct children —
/// the client re-requests `/api/thread` for a nested branch.
pub replies: Vec<PostRow>,
#[serde(skip_serializing_if = "Option::is_none")]
pub like_count: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repost_count: Option<i64>,
/// Same semantics as on `/api/post/{uri}`: `None` means "no
/// viewer_did was supplied, state unknown" — not "not liked".
#[serde(skip_serializing_if = "Option::is_none")]
pub viewer_liked: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub viewer_reposted: Option<bool>,
}
// -- notifications ----------------------------------------------------------
/// One hydrated row of `GET /api/notifications`.
///
/// The DB row only stores DIDs and a subject URI; the read query joins
/// the `profiles` cache (falling back to the newest non-empty
/// `posts.handle` for authors we've seen post but never had a profile
/// record for) and the `posts` table so the client can render a full
/// notification line without any follow-up fetch.
///
/// `read_at` is serialised even when `None` — unlike the optional
/// fields around it — because "unread" is the state the client's badge
/// keys off, and a *missing* key would be indistinguishable from a
/// field the client forgot to read.
#[derive(Debug, Clone, Serialize)]
pub struct NotificationItem {
/// Row id. Also the tiebreaker inside the opaque cursor.
pub id: i64,
/// `"like"` | `"repost"` | `"follow"` | `"reply"`.
pub kind: String,
pub author_did: String,
/// Best known handle for the author, WITHOUT a leading `@` (the UI
/// renders `@{handle}`). Falls back to a truncated DID, and is
/// never null.
pub author_handle: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub author_display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author_avatar_cid: Option<String>,
/// The post this notification is about. `null` for `"follow"`. For
/// `"like"`/`"repost"` it's the recipient's own post; for
/// `"reply"` it's the reply itself.
pub subject_uri: Option<String>,
/// Text of `subject_uri`'s post. Omitted when the subject is not
/// (or no longer) in our index, and always for `"follow"`.
#[serde(skip_serializing_if = "Option::is_none")]
pub subject_text: Option<String>,
/// The interaction's own `createdAt` from the AT record.
pub created_at: DateTime<Utc>,
/// When the AppView indexed it. This is the list's sort key, and
/// the value a client should echo back as `seenAt`.
pub indexed_at: DateTime<Utc>,
/// `null` while unread.
pub read_at: Option<DateTime<Utc>>,
}
impl<'r> FromRow<'r, sqlx::postgres::PgRow> for NotificationItem {
fn from_row(row: &'r sqlx::postgres::PgRow) -> sqlx::Result<Self> {
Ok(NotificationItem {
id: row.try_get("id")?,
kind: row.try_get("kind")?,
author_did: row.try_get("author_did")?,
author_handle: row.try_get("author_handle")?,
author_display_name: row.try_get("author_display_name")?,
author_avatar_cid: row.try_get("author_avatar_cid")?,
subject_uri: row.try_get("subject_uri")?,
subject_text: row.try_get("subject_text")?,
created_at: row.try_get("created_at")?,
indexed_at: row.try_get("indexed_at")?,
read_at: row.try_get("read_at")?,
})
}
}
/// `GET /api/notifications` response. `cursor` is `None` at the end of
/// the list — same contract as [`TimelineResponse`].
#[derive(Debug, Serialize)]
pub struct NotificationsResponse {
pub notifications: Vec<NotificationItem>,
pub cursor: Option<String>,
}
/// `GET /api/notifications/count` response.
#[derive(Debug, Serialize)]
pub struct NotificationCountResponse {
/// Number of rows with `read_at IS NULL` for this DID.
pub count: i64,
}
/// `POST /api/notifications/seen` response.
#[derive(Debug, Serialize)]
pub struct NotificationSeenResponse {
pub ok: bool,
/// How many previously-unread rows this call flipped to read.
/// Zero is a normal, successful outcome (nothing was unread).
pub updated: i64,
}
// -- actor lists (followers / following) ------------------------------------
/// A minimal profile card, as returned by `GET /api/followers` and
/// `GET /api/following`.
///
/// Deliberately *not* [`ProfileResponse`]: those endpoints return a
/// list, and shipping each entry's posts + counts would turn one page
/// of 30 followers into 30 post queries. The client renders a row with
/// avatar + name + handle and navigates to `/api/profile` on click.
#[derive(Debug, Clone, Serialize)]
pub struct ActorProfile {
pub did: String,
/// Without a leading `@`; falls back to a truncated DID and is
/// never null.
pub handle: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_cid: Option<String>,
}
/// `GET /api/followers` / `GET /api/following` response.
#[derive(Debug, Serialize)]
pub struct ActorListResponse {
pub profiles: Vec<ActorProfile>,
pub cursor: Option<String>,
}
// -- Langs newtype ----------------------------------------------------------
/// A list of language tags. Always serialises as `Vec<String>`, never
+47 -16
View File
@@ -134,7 +134,11 @@ async fn timeline_returns_seeded_posts() {
"cid": "bafyreicid",
"record": {
"text": format!("seeded post #{i}"),
"createdAt": "2026-07-01T12:00:00Z",
// Strictly increasing so the ordering assertion
// below has something to actually check: the rows
// are inserted in this order, so `indexed_at` and
// `created_at` agree for *our* posts.
"createdAt": format!("2026-07-01T12:00:{:02}Z", i),
}
}),
)
@@ -147,9 +151,15 @@ async fn timeline_returns_seeded_posts() {
// three rows in the first page.
tokio::time::sleep(Duration::from_millis(50)).await;
// `limit=100`, not 10: this DID follows nobody, so the endpoint
// serves the cold-start *global* recent feed. On any database with
// more than a handful of recent posts (i.e. every developer
// machine that has run this suite twice) the three rows we just
// seeded fall outside a 10-row window and the assertions below
// fail for reasons that have nothing to do with the timeline.
let resp = c
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[("did", did.as_str()), ("limit", "10")])
.query(&[("did", did.as_str()), ("limit", "100")])
.send()
.await
.unwrap();
@@ -174,15 +184,28 @@ async fn timeline_returns_seeded_posts() {
.collect();
assert!(our_uris.len() >= 3, "missing our seeded posts in {posts:?}");
// Posts must be sorted with `indexed_at DESC`. We can't see
// indexed_at directly in the response, but the URI order in
// `app.twi.post/<rkey>` is rkey-random here, so we only assert
// `created_at` is non-increasing.
// Posts come back `indexed_at DESC`, and we can't see `indexed_at`
// in the response but for the three rows WE just inserted,
// insertion order == `indexed_at` order == `created_at` order, so
// their `created_at` values must be non-increasing.
//
// Two fixes over the original assertion:
// - the wire field is `created_at`, not `createdAt` (the
// `PostRow` wire type in `routes/types.rs` carries no
// `rename_all = "camelCase"`), so `p["createdAt"]` was JSON
// `null` and `.as_str().unwrap()` panicked on the first row;
// - it ran over ALL posts, including other tests' fixtures from
// the global cold-start feed, whose `created_at` values have
// no relation to their `indexed_at` order. Restricting it to
// our own DID is the only version of this claim that holds.
let mut prev: Option<String> = None;
for p in posts {
let ca = p["createdAt"].as_str().unwrap().to_string();
if let Some(p) = prev.take() {
assert!(ca <= p, "createdAt must be non-increasing: {ca} <= {p}");
for p in posts.iter().filter(|p| p["did"] == json!(did)) {
let ca = p["created_at"].as_str().unwrap().to_string();
if let Some(prev) = prev.take() {
assert!(
ca <= prev,
"created_at must be non-increasing: {ca} <= {prev}"
);
}
prev = Some(ca);
}
@@ -354,16 +377,24 @@ async fn profile_returns_posts_for_handle() {
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
// 404 for an unknown handle.
// An unknown handle is NOT a 404. `resolve_profile` deliberately
// synthesises an empty profile (empty `did`, zero counts, no
// posts) so the UI renders an empty profile page instead of an
// error toast — see the comment on the `let Some(target_did)`
// else-branch in `routes.rs`. This assertion used to expect 404
// and contradicted the endpoint it was testing.
let unknown = format!("nobody_{}", uuid::Uuid::new_v4().simple());
let resp = c
.get(format!(
"{APPVIEW_URL}/api/profile/nobody_{}",
uuid::Uuid::new_v4().simple()
))
.get(format!("{APPVIEW_URL}/api/profile/{unknown}"))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 404);
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["did"], json!(""));
assert_eq!(body["handle"], json!(unknown));
assert!(body["posts"].as_array().unwrap().is_empty());
assert_eq!(body["post_count"], json!(0));
// /api/profile?did=... must work too.
let resp = c
@@ -0,0 +1,757 @@
//! Integration tests for the Phase-5c read API additions:
//! `/api/notifications` (+ `/count`, `/seen`), `/api/followers`,
//! `/api/following` and `/api/thread`.
//!
//! Same contract as `api_integration.rs`: these run against a live
//! appview service + DB and are **fail-open**. If the service or the
//! database isn't reachable the test prints a notice and returns
//! success, so `cargo test --workspace` stays green on a machine where
//! `docker compose up` hasn't been run.
//!
//! Notification rows are written by the *indexer*, not by any HTTP
//! endpoint, so every test here seeds through `/internal/ingest-commit`
//! (the same path the PDS uses) and then reads back through the public
//! API. That's deliberate: it's the only way to catch a mismatch
//! between what the write path stores and what the read path joins.
use serde_json::{json, Value};
use std::time::Duration;
/// Base URL of the appview under test. Overridable so the suite can be
/// pointed at a throwaway instance on a scratch database instead of
/// whatever the developer happens to have running on the default port.
fn appview_url() -> String {
std::env::var("APPVIEW_TEST_URL")
.unwrap_or_else(|_| "http://127.0.0.1:2584".to_string())
}
async fn client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap()
}
async fn wait_for_appview_db() -> bool {
let base = appview_url();
let c = client().await;
for _ in 0..20 {
if let Ok(r) = c.get(format!("{base}/healthz")).send().await {
if r.status().is_success() {
return true;
}
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
false
}
async fn db_pool() -> Option<sqlx::PgPool> {
let url = std::env::var("DATABASE_URL_APPVIEW").ok()?;
match tokio::time::timeout(Duration::from_secs(2), sqlx::PgPool::connect(&url)).await {
Ok(Ok(pool)) => Some(pool),
_ => None,
}
}
/// Guard used at the top of every test. Returns `None` (→ skip) unless
/// both the HTTP service and the database are up.
async fn ready() -> Option<(reqwest::Client, sqlx::PgPool)> {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return None;
}
let Some(pool) = db_pool().await else {
eprintln!("appview DB unreachable, skipping");
return None;
};
Some((client().await, pool))
}
async fn post_ingest(c: &reqwest::Client, body: Value) -> reqwest::Response {
let base = appview_url();
c.post(format!("{base}/internal/ingest-commit"))
.json(&body)
.send()
.await
.unwrap()
}
fn did_for_test(name: &str) -> String {
format!("did:plc:ntf_{}_{}", name, uuid::Uuid::new_v4().simple())
}
fn rkey() -> String {
uuid::Uuid::new_v4().simple().to_string()
}
/// Create one post through the ingest path and return its URI.
async fn seed_post(c: &reqwest::Client, did: &str, text: &str) -> String {
let rk = rkey();
let r = post_ingest(
c,
json!({
"did": did,
"handle": "ntf-fixture.test",
"collection": "app.twi.post",
"action": "create",
"rkey": rk,
"cid": "bafyreicid",
"record": { "text": text, "createdAt": "2026-07-01T12:00:00Z" }
}),
)
.await;
assert_eq!(r.status().as_u16(), 200);
format!("at://{did}/app.twi.post/{rk}")
}
/// Create a reply to `parent_uri` and return the reply's URI.
async fn seed_reply(
c: &reqwest::Client,
did: &str,
parent_uri: &str,
root_uri: &str,
text: &str,
) -> String {
let rk = rkey();
let r = post_ingest(
c,
json!({
"did": did,
"handle": "ntf-fixture.test",
"collection": "app.twi.post",
"action": "create",
"rkey": rk,
"cid": "bafyreicid",
"record": {
"text": text,
"createdAt": "2026-07-01T12:05:00Z",
"reply": {
"parent": { "uri": parent_uri, "cid": "bafyparent" },
"root": { "uri": root_uri, "cid": "bafyroot" }
}
}
}),
)
.await;
assert_eq!(r.status().as_u16(), 200);
format!("at://{did}/app.twi.post/{rk}")
}
async fn seed_like(c: &reqwest::Client, did: &str, post_uri: &str) {
let r = post_ingest(
c,
json!({
"did": did,
"collection": "app.bsky.feed.like",
"action": "create",
"rkey": rkey(),
"cid": "bafylike",
"record": {
"subject": { "uri": post_uri, "cid": "bafyreicid" },
"createdAt": "2026-07-01T12:01:00Z"
}
}),
)
.await;
assert_eq!(r.status().as_u16(), 200);
}
async fn seed_follow(c: &reqwest::Client, follower: &str, subject: &str) {
let r = post_ingest(
c,
json!({
"did": follower,
"collection": "app.bsky.graph.follow",
"action": "create",
"rkey": rkey(),
"subject_did": subject,
"record": { "subject": subject, "createdAt": "2026-01-01T00:00:00Z" }
}),
)
.await;
assert_eq!(r.status().as_u16(), 200);
}
// -- notifications ----------------------------------------------------------
/// A like, a repost-free reply and a follow from three different
/// people must show up as three hydrated notification rows, and the
/// unread count must agree with the list.
#[tokio::test]
async fn notifications_list_count_and_seen() {
let base = appview_url();
let Some((c, _pool)) = ready().await else {
return;
};
let alice = did_for_test("alice");
let bob = did_for_test("bob");
let carol = did_for_test("carol");
// Alice posts; bob likes it and carol replies; bob also follows her.
let post_uri = seed_post(&c, &alice, "alice's original").await;
seed_post(&c, &bob, "bob exists").await;
seed_post(&c, &carol, "carol exists").await;
seed_like(&c, &bob, &post_uri).await;
let reply_uri = seed_reply(&c, &carol, &post_uri, &post_uri, "carol's reply").await;
seed_follow(&c, &bob, &alice).await;
let resp = c
.get(format!("{base}/api/notifications"))
.query(&[("did", alice.as_str()), ("limit", "50")])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
let items = body["notifications"].as_array().expect("notifications array");
assert_eq!(items.len(), 3, "expected like + reply + follow, got {items:?}");
// Newest first: `indexed_at` must be non-increasing down the list.
let mut prev: Option<String> = None;
for n in items {
let at = n["indexed_at"].as_str().unwrap().to_string();
if let Some(p) = prev.take() {
assert!(at <= p, "indexed_at must be non-increasing: {at} <= {p}");
}
prev = Some(at);
}
let by_kind = |k: &str| -> Value {
items
.iter()
.find(|n| n["kind"] == json!(k))
.unwrap_or_else(|| panic!("missing {k} notification in {items:?}"))
.clone()
};
// The like points at alice's own post and previews its text.
let like = by_kind("like");
assert_eq!(like["author_did"], json!(bob));
assert_eq!(like["subject_uri"], json!(post_uri));
assert_eq!(like["subject_text"], json!("alice's original"));
assert!(like["read_at"].is_null(), "new notifications start unread");
// The reply points at the REPLY (not the parent), so the preview
// shows what carol wrote.
let reply = by_kind("reply");
assert_eq!(reply["author_did"], json!(carol));
assert_eq!(reply["subject_uri"], json!(reply_uri));
assert_eq!(reply["subject_text"], json!("carol's reply"));
// A follow has no subject at all.
let follow = by_kind("follow");
assert_eq!(follow["author_did"], json!(bob));
assert!(follow["subject_uri"].is_null());
// `author_handle` is never empty and never carries a leading '@'
// (the UI renders `@{handle}` itself).
for n in items {
let h = n["author_handle"].as_str().expect("author_handle is a string");
assert!(!h.is_empty(), "author_handle must never be empty: {n:?}");
assert!(!h.starts_with('@'), "author_handle must not carry a sigil: {h}");
}
// The unread count agrees with the list.
let resp = c
.get(format!("{base}/api/notifications/count"))
.query(&[("did", alice.as_str())])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["count"], json!(3));
// Mark everything seen.
let resp = c
.post(format!("{base}/api/notifications/seen"))
.json(&json!({ "did": alice }))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["ok"], json!(true));
assert_eq!(body["updated"], json!(3));
// Idempotent: a second call updates nothing and still succeeds.
let resp = c
.post(format!("{base}/api/notifications/seen"))
.json(&json!({ "did": alice }))
.send()
.await
.unwrap();
let body: Value = resp.json().await.unwrap();
assert_eq!(body["updated"], json!(0));
// Count is now zero and the rows carry a read_at.
let resp = c
.get(format!("{base}/api/notifications/count"))
.query(&[("did", alice.as_str())])
.send()
.await
.unwrap();
let body: Value = resp.json().await.unwrap();
assert_eq!(body["count"], json!(0));
let resp = c
.get(format!("{base}/api/notifications"))
.query(&[("did", alice.as_str())])
.send()
.await
.unwrap();
let body: Value = resp.json().await.unwrap();
for n in body["notifications"].as_array().unwrap() {
assert!(!n["read_at"].is_null(), "row should be read now: {n:?}");
}
// `did` is mandatory on all three.
for path in [
"/api/notifications",
"/api/notifications/count",
] {
let resp = c
.get(format!("{base}{path}"))
.query(&[("did", "")])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 400, "{path} must reject an empty did");
}
}
/// Self-interactions produce nothing: alice liking and replying to her
/// own post leaves her notification list empty.
#[tokio::test]
async fn notifications_skip_self_interactions() {
let base = appview_url();
let Some((c, _pool)) = ready().await else {
return;
};
let alice = did_for_test("solo");
let post_uri = seed_post(&c, &alice, "talking to myself").await;
seed_like(&c, &alice, &post_uri).await;
seed_reply(&c, &alice, &post_uri, &post_uri, "and replying too").await;
seed_follow(&c, &alice, &alice).await;
let resp = c
.get(format!("{base}/api/notifications"))
.query(&[("did", alice.as_str())])
.send()
.await
.unwrap();
let body: Value = resp.json().await.unwrap();
assert_eq!(
body["notifications"].as_array().unwrap().len(),
0,
"self-interactions must not notify: {body:?}"
);
}
/// Cursor pagination over the notification list: pages must be
/// disjoint, exactly `limit` long while more remain, and the cursor
/// must go null at the end.
#[tokio::test]
async fn notifications_paginate_with_cursor() {
let base = appview_url();
let Some((c, _pool)) = ready().await else {
return;
};
let alice = did_for_test("popular");
let post_uri = seed_post(&c, &alice, "the post everyone likes").await;
// 12 distinct likers → 12 notifications. (Distinct DIDs matter:
// the dedupe index is per (recipient, author, kind, subject).)
for i in 0..12 {
let liker = did_for_test(&format!("fan{i}"));
seed_like(&c, &liker, &post_uri).await;
}
let page = |cursor: Option<String>| {
let c = c.clone();
let alice = alice.clone();
let base = base.clone();
async move {
let mut req = c
.get(format!("{base}/api/notifications"))
.query(&[("did", alice.as_str()), ("limit", "5")]);
if let Some(cur) = cursor {
req = req.query(&[("cursor", cur.as_str())]);
}
let resp = req.send().await.unwrap();
assert_eq!(resp.status().as_u16(), 200);
resp.json::<Value>().await.unwrap()
}
};
let p1 = page(None).await;
assert_eq!(p1["notifications"].as_array().unwrap().len(), 5);
let c1 = p1["cursor"].as_str().expect("page1 cursor").to_string();
let p2 = page(Some(c1)).await;
assert_eq!(p2["notifications"].as_array().unwrap().len(), 5);
let c2 = p2["cursor"].as_str().expect("page2 cursor").to_string();
let p3 = page(Some(c2)).await;
assert_eq!(p3["notifications"].as_array().unwrap().len(), 2);
assert!(
p3["cursor"].is_null(),
"cursor must be null on the last page: {p3:?}"
);
// No id may appear on two pages.
let ids = |p: &Value| -> Vec<i64> {
p["notifications"]
.as_array()
.unwrap()
.iter()
.map(|n| n["id"].as_i64().unwrap())
.collect()
};
let mut all: Vec<i64> = ids(&p1);
all.extend(ids(&p2));
all.extend(ids(&p3));
let unique: std::collections::HashSet<i64> = all.iter().copied().collect();
assert_eq!(unique.len(), all.len(), "pages overlap: {all:?}");
assert_eq!(all.len(), 12);
// A mangled cursor is a 400, not a silent restart at page 1.
let resp = c
.get(format!("{base}/api/notifications"))
.query(&[("did", alice.as_str()), ("cursor", "!!!garbage!!!")])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 400);
}
/// `seenAt` is a watermark: only rows indexed at or before it flip to
/// read. Accepted in both camelCase and snake_case.
#[tokio::test]
async fn notifications_seen_respects_watermark() {
let base = appview_url();
let Some((c, _pool)) = ready().await else {
return;
};
let alice = did_for_test("watermark");
let post_uri = seed_post(&c, &alice, "watermark subject").await;
let first = did_for_test("early");
seed_like(&c, &first, &post_uri).await;
// Read back the first notification's indexed_at — that's the
// watermark a client would echo after rendering page 1.
let body: Value = c
.get(format!("{base}/api/notifications"))
.query(&[("did", alice.as_str())])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let watermark = body["notifications"][0]["indexed_at"]
.as_str()
.unwrap()
.to_string();
// A second interaction lands *after* the watermark.
tokio::time::sleep(Duration::from_millis(20)).await;
let second = did_for_test("late");
seed_like(&c, &second, &post_uri).await;
let resp = c
.post(format!("{base}/api/notifications/seen"))
.json(&json!({ "did": alice, "seenAt": watermark }))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
assert_eq!(
body["updated"], json!(1),
"only the row at/before the watermark may flip to read"
);
// The later one is still unread.
let body: Value = c
.get(format!("{base}/api/notifications/count"))
.query(&[("did", alice.as_str())])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(body["count"], json!(1));
// snake_case spelling must work identically.
let resp = c
.post(format!("{base}/api/notifications/seen"))
.json(&json!({ "did": alice, "seen_at": null }))
.send()
.await
.unwrap();
let body: Value = resp.json().await.unwrap();
assert_eq!(body["updated"], json!(1));
}
// -- follower / following lists ---------------------------------------------
#[tokio::test]
async fn followers_and_following_lists() {
let base = appview_url();
let Some((c, _pool)) = ready().await else {
return;
};
let hub = did_for_test("hub");
seed_post(&c, &hub, "hub post").await;
// Three people follow the hub; the hub follows one of them back.
let mut fans = Vec::new();
for i in 0..3 {
let fan = did_for_test(&format!("fan{i}"));
seed_post(&c, &fan, "fan post").await;
seed_follow(&c, &fan, &hub).await;
fans.push(fan);
}
seed_follow(&c, &hub, &fans[0]).await;
// Followers.
let body: Value = c
.get(format!("{base}/api/followers"))
.query(&[("did", hub.as_str()), ("limit", "50")])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let got: std::collections::HashSet<String> = body["profiles"]
.as_array()
.expect("profiles array")
.iter()
.map(|p| p["did"].as_str().unwrap().to_string())
.collect();
for fan in &fans {
assert!(got.contains(fan), "follower {fan} missing from {body:?}");
}
assert!(
!got.contains(&hub),
"the hub must not appear in its own follower list"
);
// Following — exactly one edge.
let body: Value = c
.get(format!("{base}/api/following"))
.query(&[("did", hub.as_str()), ("limit", "50")])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let following: Vec<String> = body["profiles"]
.as_array()
.unwrap()
.iter()
.map(|p| p["did"].as_str().unwrap().to_string())
.collect();
assert_eq!(following, vec![fans[0].clone()]);
// Never an empty or '@'-prefixed handle.
for p in body["profiles"].as_array().unwrap() {
let h = p["handle"].as_str().expect("handle is a string");
assert!(!h.is_empty());
assert!(!h.starts_with('@'));
}
// Pagination: limit=1 must page through all three followers
// without repeats.
let mut seen: Vec<String> = Vec::new();
let mut cursor: Option<String> = None;
for _ in 0..5 {
let mut req = c
.get(format!("{base}/api/followers"))
.query(&[("did", hub.as_str()), ("limit", "1")]);
if let Some(cur) = cursor.as_deref() {
req = req.query(&[("cursor", cur)]);
}
let body: Value = req.send().await.unwrap().json().await.unwrap();
for p in body["profiles"].as_array().unwrap() {
seen.push(p["did"].as_str().unwrap().to_string());
}
match body["cursor"].as_str() {
Some(c) => cursor = Some(c.to_string()),
None => break,
}
}
let unique: std::collections::HashSet<&String> = seen.iter().collect();
assert_eq!(unique.len(), seen.len(), "paged followers repeat: {seen:?}");
assert_eq!(seen.len(), 3, "paging lost a follower: {seen:?}");
// `did` is mandatory.
for path in ["/api/followers", "/api/following"] {
let resp = c
.get(format!("{base}{path}"))
.query(&[("did", "")])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 400);
}
}
// -- thread -----------------------------------------------------------------
/// `/api/thread` returns the ancestor chain above a post and its
/// direct replies, in both the query-param and the path spelling.
#[tokio::test]
async fn thread_returns_parents_and_replies() {
let base = appview_url();
let Some((c, _pool)) = ready().await else {
return;
};
let a = did_for_test("root");
let b = did_for_test("mid");
let d = did_for_test("leaf");
let root = seed_post(&c, &a, "root post").await;
let mid = seed_reply(&c, &b, &root, &root, "middle reply").await;
let leaf = seed_reply(&c, &d, &mid, &root, "leaf reply").await;
for url in [
format!("{base}/api/thread?uri={}", urlencoding(&mid)),
format!("{base}/api/thread/{mid}"),
] {
let resp = c.get(&url).send().await.unwrap();
assert_eq!(resp.status().as_u16(), 200, "GET {url}");
let body: Value = resp.json().await.unwrap();
assert_eq!(body["post"]["uri"], json!(mid), "GET {url}");
// One ancestor, and it's the root.
let parents = body["parents"].as_array().unwrap();
assert_eq!(parents.len(), 1, "GET {url}: {parents:?}");
assert_eq!(parents[0]["uri"], json!(root));
assert_eq!(body["root"]["uri"], json!(root));
// One direct reply: the leaf.
let replies = body["replies"].as_array().unwrap();
assert_eq!(replies.len(), 1, "GET {url}: {replies:?}");
assert_eq!(replies[0]["uri"], json!(leaf));
assert_eq!(body["like_count"], json!(0));
}
// The root's thread has no parents and one reply (the middle).
let body: Value = c
.get(format!("{base}/api/thread/{root}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(body["parents"].as_array().unwrap().len(), 0);
assert!(body["root"].is_null(), "a top-level post has no root ref");
let replies = body["replies"].as_array().unwrap();
assert_eq!(replies.len(), 1);
assert_eq!(replies[0]["uri"], json!(mid));
// An unknown URI is a 200 with a null post, not a 404 — the UI
// renders "not in index" from one field check.
let body: Value = c
.get(format!(
"{base}/api/thread/at://did:plc:nobody/app.twi.post/{}",
rkey()
))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert!(body["post"].is_null());
assert_eq!(body["parents"].as_array().unwrap().len(), 0);
assert_eq!(body["replies"].as_array().unwrap().len(), 0);
// A missing `uri` is a 400.
let resp = c
.get(format!("{base}/api/thread"))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 400);
}
/// `/api/post/{uri}` must keep its historical shape after the thread
/// refactor — the Tauri client reads `thread.parent` / `thread.root`
/// and has no `parents` / `replies` fields.
#[tokio::test]
async fn post_by_uri_stays_backwards_compatible() {
let base = appview_url();
let Some((c, _pool)) = ready().await else {
return;
};
let a = did_for_test("compat_a");
let b = did_for_test("compat_b");
let root = seed_post(&c, &a, "compat root").await;
let mid = seed_reply(&c, &b, &root, &root, "compat reply").await;
seed_like(&c, &a, &mid).await;
let body: Value = c
.get(format!("{base}/api/post/{mid}"))
.query(&[("viewer_did", a.as_str())])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(body["post"]["uri"], json!(mid));
// The legacy nested shape: immediate parent + root, both hydrated.
assert_eq!(body["thread"]["parent"]["uri"], json!(root));
assert_eq!(body["thread"]["root"]["uri"], json!(root));
assert_eq!(body["like_count"], json!(1));
assert_eq!(body["repost_count"], json!(0));
assert_eq!(body["viewer_liked"], json!(true));
assert_eq!(body["viewer_reposted"], json!(false));
// The endpoint must NOT have grown the thread route's fields.
assert!(body.get("replies").is_none(), "unexpected `replies`: {body:?}");
assert!(body.get("parents").is_none(), "unexpected `parents`: {body:?}");
// And the two endpoints must agree about the parent / root.
let thread: Value = c
.get(format!("{base}/api/thread/{mid}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(
thread["parents"].as_array().unwrap().last().unwrap()["uri"],
body["thread"]["parent"]["uri"],
"/api/thread and /api/post disagree about the parent"
);
assert_eq!(thread["root"]["uri"], body["thread"]["root"]["uri"]);
}
/// Minimal percent-encoder for the `?uri=` form. Only the characters
/// an `at://did:plc:…/app.twi.post/<rkey>` URI can contain that a query
/// string would otherwise eat.
fn urlencoding(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 2);
for ch in s.chars() {
match ch {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => out.push(ch),
other => {
let mut buf = [0u8; 4];
for b in other.encode_utf8(&mut buf).as_bytes() {
out.push_str(&format!("%{b:02X}"));
}
}
}
}
out
}
+108 -9
View File
@@ -1,7 +1,10 @@
use anyhow::Result;
use async_trait::async_trait;
use at_crypto::cid::{cid_for_raw, sha256};
use bytes::Bytes;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tokio::sync::RwLock;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlobInfo {
@@ -19,20 +22,116 @@ pub trait BlobStore: Send + Sync {
async fn public_url(&self, key: &str) -> Result<String>;
}
pub struct InMemoryBlobStore;
/// In-process blob store backed by a `HashMap` guarded by a
/// [`tokio::sync::RwLock`]. Used in tests and any dev/single-node
/// deployment that doesn't need a real object store.
///
/// Behaves like [`crate::s3::S3BlobStore`] with respect to `BlobInfo`
/// (same CID computation over the raw bytes, same field semantics) so
/// tests can swap one for the other transparently. Data does not
/// survive process restarts.
#[derive(Default)]
pub struct InMemoryBlobStore {
blobs: RwLock<HashMap<String, (Bytes, String /* mime */)>>,
}
impl InMemoryBlobStore {
pub fn new() -> Self {
Self::default()
}
}
#[async_trait]
impl BlobStore for InMemoryBlobStore {
async fn put(&self, _key: &str, _data: Bytes, _mime: &str) -> Result<BlobInfo> {
unimplemented!("in-memory blob store placeholder")
async fn put(&self, key: &str, data: Bytes, mime: &str) -> Result<BlobInfo> {
let hash = sha256(&data);
let cid = cid_for_raw(0x55, hash)?;
let size = data.len() as u64;
self.blobs
.write()
.await
.insert(key.to_string(), (data, mime.to_string()));
Ok(BlobInfo {
cid: cid.to_string(),
mime_type: mime.to_string(),
size,
storage_key: key.to_string(),
})
}
async fn get(&self, _key: &str) -> Result<Option<Bytes>> {
unimplemented!()
async fn get(&self, key: &str) -> Result<Option<Bytes>> {
Ok(self
.blobs
.read()
.await
.get(key)
.map(|(data, _mime)| data.clone()))
}
async fn delete(&self, _key: &str) -> Result<()> {
unimplemented!()
async fn delete(&self, key: &str) -> Result<()> {
self.blobs.write().await.remove(key);
Ok(())
}
async fn public_url(&self, _key: &str) -> Result<String> {
unimplemented!()
async fn public_url(&self, key: &str) -> Result<String> {
Ok(format!("/blob/{key}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn put_get_roundtrip() {
let store = InMemoryBlobStore::new();
let data = Bytes::from_static(b"hello world");
let info = store.put("k1", data.clone(), "text/plain").await.unwrap();
assert_eq!(info.storage_key, "k1");
assert_eq!(info.mime_type, "text/plain");
assert_eq!(info.size, data.len() as u64);
let got = store.get("k1").await.unwrap();
assert_eq!(got, Some(data));
}
#[tokio::test]
async fn get_missing_key_returns_none() {
let store = InMemoryBlobStore::new();
let got = store.get("does-not-exist").await.unwrap();
assert_eq!(got, None);
}
#[tokio::test]
async fn delete_then_get_returns_none() {
let store = InMemoryBlobStore::new();
store
.put("k2", Bytes::from_static(b"data"), "application/octet-stream")
.await
.unwrap();
store.delete("k2").await.unwrap();
let got = store.get("k2").await.unwrap();
assert_eq!(got, None);
}
#[tokio::test]
async fn delete_missing_key_is_ok() {
let store = InMemoryBlobStore::new();
store.delete("never-existed").await.unwrap();
}
#[tokio::test]
async fn same_bytes_produce_same_cid() {
let store = InMemoryBlobStore::new();
let data = Bytes::from_static(b"identical payload");
let info_a = store
.put("key-a", data.clone(), "application/octet-stream")
.await
.unwrap();
let info_b = store
.put("key-b", data.clone(), "application/octet-stream")
.await
.unwrap();
assert_eq!(info_a.cid, info_b.cid);
}
}
+1
View File
@@ -15,6 +15,7 @@ path = "src/main.rs"
[dependencies]
tokio = { workspace = true }
dotenvy = { workspace = true }
axum = { workspace = true }
tower = { workspace = true }
tower-http = { workspace = true }
+6
View File
@@ -17,6 +17,12 @@ use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Load `.env` from the working directory (and upwards) if present.
// Nothing else in the process reads it, so without this
// `cp .env.example .env && cargo run` fails with `missing env:
// PDS_HOST`. Real environment variables always win over the file.
let _ = dotenvy::dotenv();
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.init();
+25 -2
View File
@@ -758,8 +758,14 @@ async fn sync_list_repos_includes_recent_user() {
return;
}
let (c, did, _jwt, _cids) = fresh_user_with_records().await;
// Page through listRepos with a small limit until we see our DID.
let mut cursor: Option<String> = None;
// Page through listRepos until we see our DID. Start the cursor
// immediately *below* the target rather than at the beginning of
// the table: `repos` grows without bound on a long-lived dev
// instance (a few thousand rows already), and scanning from the
// top made this test fail purely because the DID sorted past the
// iteration cap. Anchoring at the DID keeps the cursor round trip
// under test while staying independent of table size.
let mut cursor: Option<String> = Some(did_cursor_just_before(&did));
let mut found = false;
for _ in 0..50 {
let url = match &cursor {
@@ -1022,6 +1028,23 @@ fn urlencode(s: &str) -> String {
.collect()
}
/// The immediate keyset predecessor of `did`: the same string with
/// its last byte decremented. `listRepos` filters with `did > cursor`,
/// so paging from here puts `did` on the first page regardless of how
/// many repos precede it in the table. Unlike [`did_cursor_lt`] — which
/// decrements the *first* byte and therefore lands before every
/// `did:...` — this stays adjacent to the target.
///
/// DIDs are ASCII (`did:plc:` + base32), so byte surgery is safe here.
fn did_cursor_just_before(did: &str) -> String {
let mut bytes = did.as_bytes().to_vec();
match bytes.last_mut() {
Some(b) if *b > 0 => *b -= 1,
_ => return did.to_string(),
}
String::from_utf8(bytes).unwrap_or_else(|_| did.to_string())
}
fn did_cursor_lt(did: &str) -> String {
let bytes = did.as_bytes();
let mut prefix = Vec::with_capacity(bytes.len());
@@ -77,6 +77,104 @@ pub struct ThreadView {
pub root: Option<PostDto>,
}
/// `GET /api/thread?uri=…` response — the *full* thread shape (the
/// whole ancestor chain plus the direct replies), as opposed to the
/// narrower `{ post, thread: { parent, root } }` of
/// [`ThreadResponse`]. Both come from the same server-side thread
/// walk, so they can never disagree about who the parent is.
///
/// `parents` is ordered root-first, `replies` oldest-first. The
/// `viewer_*` flags are only populated when a `viewer_did` was passed;
/// `None` means "unknown", not "false".
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThreadFullResponse {
pub post: Option<PostDto>,
#[serde(default)]
pub parents: Vec<PostDto>,
#[serde(default)]
pub root: Option<PostDto>,
#[serde(default)]
pub replies: Vec<PostDto>,
#[serde(default)]
pub like_count: Option<i64>,
#[serde(default)]
pub repost_count: Option<i64>,
#[serde(default)]
pub viewer_liked: Option<bool>,
#[serde(default)]
pub viewer_reposted: Option<bool>,
}
/// One hydrated row of `GET /api/notifications`.
///
/// `kind` is `"like" | "repost" | "follow" | "reply"` — kept as a
/// `String` rather than an enum so an unknown kind added server-side
/// deserialises instead of failing the whole page; the frontend has
/// the same fallback.
///
/// `read_at` is `None` while the notification is unread — that's the
/// state the tray badge keys off.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationDto {
pub id: i64,
pub kind: String,
pub author_did: String,
pub author_handle: String,
#[serde(default)]
pub author_display_name: Option<String>,
#[serde(default)]
pub author_avatar_cid: Option<String>,
/// `null` for `"follow"`; the recipient's own post for
/// `"like"`/`"repost"`; the reply itself for `"reply"`.
#[serde(default)]
pub subject_uri: Option<String>,
#[serde(default)]
pub subject_text: Option<String>,
pub created_at: String,
/// The list's sort key, and the value the client echoes back as
/// `seen_at` when marking the page read.
pub indexed_at: String,
#[serde(default)]
pub read_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationsResponse {
pub notifications: Vec<NotificationDto>,
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationCountResponse {
pub count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationSeenResponse {
pub ok: bool,
pub updated: i64,
}
/// A minimal profile card as returned by `GET /api/followers` and
/// `GET /api/following`. Deliberately not [`ProfileResponse`] — a page
/// of 30 followers would otherwise be 30 post queries server-side.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActorProfileDto {
pub did: String,
/// Without a leading `@` (the UI renders `@{handle}`).
pub handle: String,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub avatar_cid: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActorListResponse {
pub profiles: Vec<ActorProfileDto>,
pub cursor: Option<String>,
}
#[derive(Clone)]
pub struct AppViewClient {
pub base_url: String,
@@ -238,6 +336,196 @@ impl AppViewClient {
.await
.context("appview: post JSON parse")
}
/// `GET /api/thread?uri=…&viewer_did=…` — the full thread: the
/// whole ancestor chain (root first) plus the direct replies
/// (oldest first).
///
/// We use the query-param spelling rather than
/// `/api/thread/<uri>` because the `at://` URI survives a
/// query-string round trip without the manual percent-encoding
/// [`Self::fetch_post`] needs — `reqwest`'s `.query()` does the
/// escaping itself.
pub async fn fetch_thread(
&self,
uri: &str,
viewer_did: Option<&str>,
) -> Result<ThreadFullResponse> {
let mut req = self
.client
.get(format!("{}/api/thread", self.base_url))
.query(&[("uri", uri)]);
if let Some(v) = viewer_did {
req = req.query(&[("viewer_did", v)]);
}
let resp = req
.send()
.await
.context("appview: failed to send thread request")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!(
"appview: thread returned {}: {}",
status,
body
));
}
resp
.json::<ThreadFullResponse>()
.await
.context("appview: thread JSON parse")
}
/// `GET /api/notifications?did=&limit=&cursor=` — newest first,
/// same opaque-cursor pagination contract as the timeline.
pub async fn fetch_notifications(
&self,
did: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<NotificationsResponse> {
let mut req = self
.client
.get(format!("{}/api/notifications", self.base_url))
.query(&[("did", did), ("limit", &limit.to_string())]);
if let Some(c) = cursor {
req = req.query(&[("cursor", c)]);
}
let resp = req
.send()
.await
.context("appview: failed to send notifications request")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!(
"appview: notifications returned {}: {}",
status,
body
));
}
resp
.json::<NotificationsResponse>()
.await
.context("appview: notifications JSON parse")
}
/// `GET /api/notifications/count?did=` — unread count for the
/// NavRail badge. Cheap enough to poll (partial index on the
/// server side).
pub async fn notification_count(&self, did: &str) -> Result<NotificationCountResponse> {
let resp = self
.client
.get(format!("{}/api/notifications/count", self.base_url))
.query(&[("did", did)])
.send()
.await
.context("appview: failed to send notification-count request")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!(
"appview: notification count returned {}: {}",
status,
body
));
}
resp
.json::<NotificationCountResponse>()
.await
.context("appview: notification count JSON parse")
}
/// `POST /api/notifications/seen` — mark everything indexed at or
/// before `seen_at` as read. Passing `None` marks *all* currently
/// unread rows. Idempotent; a second call reports `updated: 0`.
///
/// The server accepts both `seenAt` and `seen_at`; we send the
/// camelCase spelling because that's what the wire contract
/// documents.
pub async fn mark_notifications_seen(
&self,
did: &str,
seen_at: Option<&str>,
) -> Result<NotificationSeenResponse> {
let mut body = serde_json::json!({ "did": did });
if let Some(ts) = seen_at {
body["seenAt"] = Value::String(ts.to_string());
}
let resp = self
.client
.post(format!("{}/api/notifications/seen", self.base_url))
.json(&body)
.send()
.await
.context("appview: failed to send notifications-seen request")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!(
"appview: notifications seen returned {}: {}",
status,
body
));
}
resp
.json::<NotificationSeenResponse>()
.await
.context("appview: notifications seen JSON parse")
}
/// `GET /api/followers?did=&limit=&cursor=`
pub async fn fetch_followers(
&self,
did: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<ActorListResponse> {
self.actor_list("followers", did, cursor, limit).await
}
/// `GET /api/following?did=&limit=&cursor=`
pub async fn fetch_following(
&self,
did: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<ActorListResponse> {
self.actor_list("following", did, cursor, limit).await
}
/// Shared body of [`Self::fetch_followers`] and
/// [`Self::fetch_following`] — the two endpoints have an identical
/// request and response shape and differ only in the path segment.
async fn actor_list(
&self,
path: &str,
did: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<ActorListResponse> {
let mut req = self
.client
.get(format!("{}/api/{}", self.base_url, path))
.query(&[("did", did), ("limit", &limit.to_string())]);
if let Some(c) = cursor {
req = req.query(&[("cursor", c)]);
}
let resp = req
.send()
.await
.with_context(|| format!("appview: failed to send {path} request"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!("appview: {} returned {}: {}", path, status, body));
}
resp
.json::<ActorListResponse>()
.await
.with_context(|| format!("appview: {path} JSON parse"))
}
}
/// Percent-encode every byte of `s` for use as a URL path segment.
+132
View File
@@ -381,6 +381,29 @@ async fn profile_get(
.map_err(|e| e.to_string())
}
/// `profile_get_by_did(did)` — resolve a profile by DID rather than
/// by handle (`GET /api/profile?did=…`).
///
/// This is the *only* correct lookup for an actor that came out of a
/// notification or a follower list: the AppView synthesises a
/// placeholder `handle` (a truncated DID, see `short_did_bare`) for
/// actors it has neither a `profiles` row nor an indexed post for, and
/// feeding that placeholder back into the handle lookup matches
/// nothing — `resolve_profile` then answers with a synthetic empty
/// profile. The DID in the notification / actor DTO is real, so we
/// navigate by that instead.
#[tauri::command]
async fn profile_get_by_did(
state: tauri::State<'_, AppState>,
did: String,
) -> Result<appview_client::ProfileResponse, String> {
state
.appview
.fetch_profile_by_did(&did)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn search(
state: tauri::State<'_, AppState>,
@@ -407,6 +430,108 @@ async fn post_get(
.map_err(|e| e.to_string())
}
/// `fetch_thread(uri, viewer_did?)` — the full thread around `uri`:
/// the whole ancestor chain (root first) plus the direct replies
/// (oldest first). `post_get` stays the narrower two-hop shape the
/// timeline's inline thread modal uses; this is what a dedicated
/// thread view wants.
#[tauri::command]
async fn fetch_thread(
state: tauri::State<'_, AppState>,
uri: String,
viewer_did: Option<String>,
) -> Result<appview_client::ThreadFullResponse, String> {
state
.appview
.fetch_thread(&uri, viewer_did.as_deref())
.await
.map_err(|e| e.to_string())
}
/// `fetch_notifications(did, cursor?, limit?)` — one page of the
/// recipient's notifications, newest first. Same limit clamp as
/// `timeline_home` (the server clamps too, but doing it here means a
/// bad `limit` never costs a round trip).
#[tauri::command]
async fn fetch_notifications(
state: tauri::State<'_, AppState>,
did: String,
cursor: Option<String>,
limit: Option<u32>,
) -> Result<appview_client::NotificationsResponse, String> {
let lim = limit.unwrap_or(30).clamp(1, 100);
state
.appview
.fetch_notifications(&did, cursor.as_deref(), lim)
.await
.map_err(|e| e.to_string())
}
/// `notification_count(did)` — unread count for the NavRail badge.
#[tauri::command]
async fn notification_count(
state: tauri::State<'_, AppState>,
did: String,
) -> Result<appview_client::NotificationCountResponse, String> {
state
.appview
.notification_count(&did)
.await
.map_err(|e| e.to_string())
}
/// `mark_notifications_seen(did, seen_at?)` — mark every notification
/// indexed at or before `seen_at` as read. The frontend passes the
/// `indexed_at` of the topmost row it actually rendered, so a
/// notification that lands mid-scroll is never silently swallowed.
/// Omitting `seen_at` marks everything currently unread.
#[tauri::command]
async fn mark_notifications_seen(
state: tauri::State<'_, AppState>,
did: String,
seen_at: Option<String>,
) -> Result<appview_client::NotificationSeenResponse, String> {
state
.appview
.mark_notifications_seen(&did, seen_at.as_deref())
.await
.map_err(|e| e.to_string())
}
/// `fetch_followers(did, cursor?, limit?)` — one page of the actors
/// who follow `did`.
#[tauri::command]
async fn fetch_followers(
state: tauri::State<'_, AppState>,
did: String,
cursor: Option<String>,
limit: Option<u32>,
) -> Result<appview_client::ActorListResponse, String> {
let lim = limit.unwrap_or(30).clamp(1, 100);
state
.appview
.fetch_followers(&did, cursor.as_deref(), lim)
.await
.map_err(|e| e.to_string())
}
/// `fetch_following(did, cursor?, limit?)` — one page of the actors
/// `did` follows.
#[tauri::command]
async fn fetch_following(
state: tauri::State<'_, AppState>,
did: String,
cursor: Option<String>,
limit: Option<u32>,
) -> Result<appview_client::ActorListResponse, String> {
let lim = limit.unwrap_or(30).clamp(1, 100);
state
.appview
.fetch_following(&did, cursor.as_deref(), lim)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn status_pds(state: tauri::State<'_, AppState>) -> Result<serde_json::Value, String> {
let sess = state.store.load();
@@ -791,8 +916,15 @@ pub fn run() {
resolve_handle,
timeline_home,
profile_get,
profile_get_by_did,
search,
post_get,
fetch_thread,
fetch_notifications,
notification_count,
mark_notifications_seen,
fetch_followers,
fetch_following,
like_post,
unlike_post,
repost_post,
+1 -1
View File
@@ -40,7 +40,7 @@
"https://releases.maarcadetweet.local/{{target}}/{{arch}}/{{current_version}}"
],
"pubkey": "",
"_comment": "Auto-update is disabled for dev. To enable for releases: (1) stand up a release-artifacts server that serves update.json, (2) run `tauri signer generate` and paste the pubkey here, (3) flip active+dialog to true. Capabilities already include `updater:default` so the frontend can request update checks via the plugin once enabled."
"_comment": "Auto-update is inert in dev: nothing in the app calls the updater's check(), and `pubkey` is empty. Note that `active` and `dialog` above are Tauri v1 leftovers — the v2 updater plugin ignores unknown keys, so they do NOT switch anything on or off. The full production path (signer keys, pubkey, endpoints, bundle.createUpdaterArtifacts, latest.json format, per-platform build + artifact paths, and the still-missing check() call) is documented in docs/tauri-release.md. Enable it via a release config overlay passed to `tauri build --config`, so this dev config stays as is. Capabilities already include `updater:default` (check/download/install)."
}
},
"bundle": {
+140 -22
View File
@@ -6,12 +6,14 @@
fetchTimeline,
fetchSearch,
fetchPost,
notificationCount,
openExternalUrl,
showError,
type Session,
type Post,
} from "./lib/api/client";
import NavRail from "./lib/components/NavRail.svelte";
import NotificationsView from "./lib/components/NotificationsView.svelte";
import StatusBar from "./lib/components/StatusBar.svelte";
import PostCard from "./lib/components/PostCard.svelte";
import ComposeBox from "./lib/components/ComposeBox.svelte";
@@ -21,7 +23,14 @@
import Skeleton from "./lib/components/Skeleton.svelte";
import Sidebar from "./lib/components/Sidebar.svelte";
type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
type View =
| "home"
| "notifications"
| "compose"
| "profile"
| "user"
| "search"
| "settings";
let view: View = $state("home");
// Handle for the "user" view (i.e. someone else's profile). The
@@ -29,6 +38,13 @@
// goes there). Selecting a handle (via the PostCard avatar link or
// a future deep-link) navigates to "user" with `selectedHandle` set.
let selectedHandle: string = $state("");
// DID for the "user" view, when the navigation had one. Set by
// `openActor` (notification rows, follower/following lists, the
// PostCards inside a profile feed) and cleared by
// `openUserProfile` (timeline / search, where only a handle is
// available). `<ProfileView>` prefers it over the handle — see the
// comment on `openActor`.
let selectedDid: string | null = $state(null);
let currentUser: Session | null = $state(null);
let status: { did?: string; handle?: string; authenticated: boolean } = $state({ authenticated: false });
@@ -40,6 +56,14 @@
let seenUris: Set<string> = new Set();
let _statusTimer: number | undefined;
// Unread-notification badge on the NavRail. Polled on the same 5s
// cadence as the timeline refresh (`startPoll`) — the count query is
// backed by a partial index server-side, so it's cheap enough to sit
// next to the timeline poll rather than needing its own slower
// timer. The poll shares `_pollTimer`, so `stopPoll` (logout /
// unmount) tears both down in one place.
let unreadCount: number = $state(0);
// Home tab strip — "for you" is a placeholder (no real algo yet),
// "following" is the live behavior. Mirrors the X-style "For you /
// Following" tabs.
@@ -116,11 +140,44 @@
}
/// Navigate to the "user" profile view for `handle`. Called from
/// `<PostCard on_handle_click>` and the avatar/handle buttons in
/// the post header. The actual profile fetch happens inside
/// `<ProfileView>` on mount.
/// `<PostCard on_handle_click>` on the timeline / search results and
/// the avatar/handle buttons in the post header. The actual profile
/// fetch happens inside `<ProfileView>`.
///
/// Handle-only path: those handles come from `posts.handle`, which
/// the AppView resolves from the record itself. Where a DID is
/// available, prefer [`openActor`].
function openUserProfile(handle: string) {
selectedHandle = handle;
selectedDid = null;
view = "user";
threadRoot = null;
threadParent = null;
}
/// Navigate to a profile by DID, with `handle` carried along only as
/// a label for the header while the fetch is in flight.
///
/// This is the path every actor coming out of a notification or a
/// follower/following list must take. The AppView synthesises a
/// placeholder `handle` for actors it has neither a `profiles` row
/// nor an indexed post for — a truncated DID like `"did:plc:f5…"`
/// (`short_did_bare` in the AppView's `routes.rs`). Feeding that
/// back into the handle lookup matches nothing, and the server
/// answers with a synthetic empty profile instead of an error, so
/// the click reads as a dead end rather than as a failure. The DID
/// on the DTO is always real.
///
/// We deliberately do NOT sniff the handle for the `…` placeholder
/// marker: that would bake the server's current display format into
/// the client. Passing the DID explicitly keeps the path correct
/// whatever the placeholder ends up looking like.
function openActor(did: string, handle: string) {
selectedHandle = handle;
// An empty DID would resolve to the "did is required" 400; fall
// back to the handle lookup in that case rather than guaranteeing
// an error.
selectedDid = did || null;
view = "user";
threadRoot = null;
threadParent = null;
@@ -302,14 +359,42 @@
let _pollTimer: number | undefined;
function startPoll() {
if (_pollTimer != null) return;
// Prime the badge immediately — otherwise a freshly logged-in
// user stares at a blank rail for a full interval.
void refreshUnreadCount();
_pollTimer = window.setInterval(() => {
if (view === "home") void refreshTimeline(false);
void refreshUnreadCount();
}, 5000);
}
function stopPoll() {
if (_pollTimer == null) return;
window.clearInterval(_pollTimer);
_pollTimer = undefined;
unreadCount = 0;
}
/// Pull the unread count for the NavRail badge. Swallows errors:
/// the badge is ambient information, and a transient AppView hiccup
/// shouldn't produce a toast every 5 seconds.
async function refreshUnreadCount() {
if (!currentUser) return;
// While the notifications view is open the user is by definition
// reading them; the view marks the page seen itself and calls
// back into `onNotificationsSeen`. Polling on top of that would
// race the ack and flicker the badge back on.
if (view === "notifications") return;
try {
unreadCount = await notificationCount(currentUser.did);
} catch {
/* ignore — keep the last known count */
}
}
/// Called by `<NotificationsView on_seen>` once it has acked the
/// first page. Zeroes the badge without waiting for the next poll.
function onNotificationsSeen() {
unreadCount = 0;
}
async function refreshTimeline(reset: boolean) {
@@ -457,6 +542,35 @@
}
</script>
<!--
Thread overlay. Defined once as a snippet because two views open a
thread through the same `openThread` helper — the timeline (a
PostCard's thread button) and the notifications list (a row with a
`subject_uri`). Rendering it twice from one definition keeps the
close button, the loading skeleton and the parent/root layout from
drifting apart.
-->
{#snippet threadModal()}
{#if threadRoot || threadLoading || threadError}
<div class="thread-modal">
<header class="thread-modal__head">
<span class="crumb">// thread</span>
<button class="btn--ghost" onclick={closeThread}>close</button>
</header>
{#if threadLoading}
<Skeleton rows={2} />
{:else if threadError}
<div class="toast toast--err">err: {threadError}</div>
{:else if threadRoot}
{#if threadParent && threadParent.uri !== threadRoot.uri}
<div class="thread-parent"><PostCard post={threadParent} on_handle_click={openUserProfile} on_reply={onReply} /></div>
{/if}
<PostCard post={threadRoot} on_handle_click={openUserProfile} on_reply={onReply} />
{/if}
</div>
{/if}
{/snippet}
{#if !currentUser}
<div class="login-wrap">
<LoginScreen
@@ -470,6 +584,7 @@
<div class="shell">
<NavRail
{view}
unread={unreadCount}
on_select={(v) => setView(v)}
/>
<div class="main">
@@ -504,24 +619,7 @@
{:else if userPosts.length === 0}
<div class="empty">// timeline is empty. compose your first post →</div>
{:else}
{#if threadRoot}
<div class="thread-modal">
<header class="thread-modal__head">
<span class="crumb">// thread</span>
<button class="btn--ghost" onclick={closeThread}>close</button>
</header>
{#if threadLoading}
<Skeleton rows={2} />
{:else if threadError}
<div class="toast toast--err">err: {threadError}</div>
{:else if threadRoot}
{#if threadParent && threadParent.uri !== threadRoot.uri}
<div class="thread-parent"><PostCard post={threadParent} on_handle_click={openUserProfile} on_reply={onReply} /></div>
{/if}
<PostCard post={threadRoot} on_handle_click={openUserProfile} on_reply={onReply} />
{/if}
</div>
{/if}
{@render threadModal()}
{#each userPosts as p (p.uri)}
<PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} on_reply={onReply} />
{/each}
@@ -533,6 +631,22 @@
</div>
{/if}
{/if}
{:else if view === "notifications"}
<div class="head">
<span class="prompt">$</span>
<span class="title">// benachrichtigungen —</span>
<span class="as">@{currentUser.handle}</span>
<span class="meta">
{unreadCount > 0 ? `${unreadCount} ungelesen` : "alles gelesen"}
</span>
</div>
{@render threadModal()}
<NotificationsView
did={currentUser.did}
on_thread_click={openThread}
on_actor_click={openActor}
on_seen={onNotificationsSeen}
/>
{:else if view === "compose"}
<div class="head">
<span class="prompt">$</span>
@@ -553,7 +667,9 @@
</div>
<ProfileView
handle={selectedHandle}
did={selectedDid}
on_thread_click={openThread}
on_actor_click={openActor}
current_user_did={currentUser?.did ?? null}
/>
{:else if view === "profile"}
@@ -565,7 +681,9 @@
</div>
<ProfileView
handle={currentUser.handle}
did={currentUser.did}
on_thread_click={openThread}
on_actor_click={openActor}
current_user_did={currentUser.did}
/>
{/if}
+201
View File
@@ -370,6 +370,24 @@ export async function fetchProfile(handle: string): Promise<ProfileResponse> {
return await safeInvoke<ProfileResponse>("profile_get", { handle });
}
/// Resolve a profile by DID (`GET /api/profile?did=…`) instead of by
/// handle.
///
/// Use this — not [`fetchProfile`] — whenever the actor came out of a
/// notification or a follower/following list. The AppView synthesises
/// a placeholder `handle` for actors it has neither a profile row nor
/// an indexed post for: a truncated DID with an ellipsis, e.g.
/// `"did:plc:f5…"`. That string matches nothing on the way back in, and
/// the server answers with a synthetic empty profile (`did: ""`, zero
/// posts) rather than an error — so a handle-based navigation from
/// those lists is a silent dead end. The DID in the DTO is always
/// real, so navigate by that.
export async function fetchProfileByDid(
did: string,
): Promise<ProfileResponse> {
return await safeInvoke<ProfileResponse>("profile_get_by_did", { did });
}
export async function fetchSearch(
q: string,
limit: number = 30,
@@ -381,6 +399,189 @@ export async function fetchPost(uri: string): Promise<ThreadResponse> {
return await safeInvoke<ThreadResponse>("post_get", { uri });
}
/// `GET /api/thread?uri=…` — the *full* thread around a post: the
/// entire ancestor chain plus the direct replies, in one round trip.
///
/// `parents` is ordered root-first (so `parents[parents.length - 1]`
/// is the immediate parent) and `replies` oldest-first. A `parents[0]`
/// whose own `parent_uri` is non-null means "the chain continues
/// above but wasn't loaded" — the walk is depth-limited server-side.
///
/// `post` is `null` when the URI isn't in the index; the counters are
/// then absent too, so a single field check renders "post not found".
/// `viewer_liked` / `viewer_reposted` are `undefined` when no
/// `viewerDid` was passed — that means "unknown", not "false".
export type ThreadFullResponse = {
post: Post | null;
parents: Post[];
root: Post | null;
replies: Post[];
like_count?: number;
repost_count?: number;
viewer_liked?: boolean;
viewer_reposted?: boolean;
};
export async function fetchThread(
uri: string,
viewerDid: string | null = null,
): Promise<ThreadFullResponse> {
return await safeInvoke<ThreadFullResponse>("fetch_thread", {
uri,
viewerDid,
});
}
/// One row of `GET /api/notifications`, hydrated server-side with the
/// author's profile and the subject post's text so the list renders
/// without any follow-up fetch.
///
/// `kind` is typed as the four known values plus `string` so an
/// unknown kind added upstream still type-checks here; the UI's
/// `notificationText` falls back to a generic line.
export type NotificationKind = "like" | "repost" | "follow" | "reply";
export type Notification = {
id: number;
kind: NotificationKind | string;
author_did: string;
/// Without a leading `@` (the UI renders `@{handle}`). Never null —
/// the AppView falls back to a truncated DID.
author_handle: string;
author_display_name?: string | null;
author_avatar_cid?: string | null;
/// The post this is about. `null` for `"follow"`. For
/// `"like"`/`"repost"` it's the recipient's own post; for `"reply"`
/// it's the reply itself.
subject_uri: string | null;
subject_text?: string | null;
created_at: string;
/// When the AppView indexed it — the list's sort key and the value
/// to echo back as `seenAt`.
indexed_at: string;
/// `null` while unread.
read_at: string | null;
};
export type NotificationsResponse = {
notifications: Notification[];
cursor: string | null;
};
/// German UI copy for a notification row. Kept next to the type (and
/// exported) so the mapping is unit-testable without mounting a
/// component. An unrecognised `kind` gets a neutral fallback rather
/// than an empty line.
export function notificationText(kind: string): string {
switch (kind) {
case "like":
return "hat deinen Post geliked";
case "repost":
return "hat repostet";
case "follow":
return "folgt dir jetzt";
case "reply":
return "hat geantwortet";
default:
return "hat interagiert";
}
}
/// Single-glyph icon for a notification row, same switch as
/// [`notificationText`]. Monospace glyphs rather than SVGs so the
/// list keeps the terminal look of the rest of the UI.
export function notificationIcon(kind: string): string {
switch (kind) {
case "like":
return "♥";
case "repost":
return "⇄";
case "follow":
return "+";
case "reply":
return "↩";
default:
return "•";
}
}
/// One page of notifications, newest first. Same opaque-cursor
/// contract as [`fetchTimeline`]: pass the previous response's
/// `cursor` to page down, and `cursor === null` means end of list.
export async function fetchNotifications(
did: string,
cursor: string | null = null,
limit: number = 30,
): Promise<NotificationsResponse> {
return await safeInvoke<NotificationsResponse>("fetch_notifications", {
did,
cursor,
limit,
});
}
/// Unread-notification count for the NavRail badge. Cheap enough to
/// poll on the same cadence as the timeline refresh.
export async function notificationCount(did: string): Promise<number> {
const r = await safeInvoke<{ count: number }>("notification_count", { did });
return r.count;
}
/// Mark every notification indexed at or before `seenAt` as read.
/// Pass the `indexed_at` of the topmost row the user actually sees, so
/// a notification arriving mid-scroll isn't swallowed. `null` marks
/// everything currently unread. Idempotent — a repeat call reports
/// `updated: 0`.
export async function markNotificationsSeen(
did: string,
seenAt: string | null = null,
): Promise<{ ok: boolean; updated: number }> {
return await safeInvoke<{ ok: boolean; updated: number }>(
"mark_notifications_seen",
{ did, seenAt },
);
}
/// A minimal profile card from `GET /api/followers` / `/api/following`.
/// Deliberately not a full `ProfileResponse` — a page of 30 followers
/// would otherwise be 30 post queries server-side. Click a row and the
/// UI navigates to the full profile by `handle`.
export type ActorProfile = {
did: string;
handle: string;
display_name?: string | null;
avatar_cid?: string | null;
};
export type ActorListResponse = {
profiles: ActorProfile[];
cursor: string | null;
};
export async function fetchFollowers(
did: string,
cursor: string | null = null,
limit: number = 30,
): Promise<ActorListResponse> {
return await safeInvoke<ActorListResponse>("fetch_followers", {
did,
cursor,
limit,
});
}
export async function fetchFollowing(
did: string,
cursor: string | null = null,
limit: number = 30,
): Promise<ActorListResponse> {
return await safeInvoke<ActorListResponse>("fetch_following", {
did,
cursor,
limit,
});
}
/// `app.bsky.feed.like.create` — Tauri command. Builds the
/// flat-shape like body on the Rust side, signs a commit, pushes
/// to the AppView. Returns the new like's `uri` and `cid`.
@@ -0,0 +1,368 @@
// Unit tests for the notification / actor-list half of `client.ts`.
//
// Same setup as `client.test.ts`: `@tauri-apps/api/core` is mocked so
// no Tauri shell is needed, and every assertion is about the exact
// command name + argument bag we hand the Rust IPC layer. That
// argument bag is the contract — Tauri's `invoke` serialises camelCase
// JS keys to the snake_case Rust command parameters, so a typo here
// surfaces at runtime as "command not found" or a null argument, not
// at compile time.
//
// Covered:
// * `fetchNotifications` — command name, args, wire→object mapping
// (snake_case fields pass through verbatim), cursor pagination;
// * `notificationCount` — unwraps `{ count }` to a number;
// * `markNotificationsSeen` — sends the `seenAt` watermark;
// * `notificationText` / `notificationIcon` — the kind→copy map,
// including the fallback for an unknown kind;
// * `fetchFollowers` / `fetchFollowing` — distinct commands, same
// shape.
//
// Run with:
// npx vitest run src/lib/api/notifications.test.ts
import { beforeEach, describe, expect, it, vi } from "vitest";
const invokeMock = vi.fn();
vi.mock("@tauri-apps/api/core", () => ({
invoke: (...args: unknown[]) => invokeMock(...args),
isTauri: () => true,
}));
beforeEach(() => {
invokeMock.mockReset();
});
/// A full-fat wire row, exactly as `NotificationItem` serialises it
/// server-side (snake_case, `read_at` present even when null).
function wireNotification(over: Record<string, unknown> = {}) {
return {
id: 1,
kind: "like",
author_did: "did:plc:alice",
author_handle: "alice.test",
author_display_name: "Alice",
author_avatar_cid: "bafyavatar",
subject_uri: "at://did:plc:me/app.twi.post/3k2",
subject_text: "hello world",
created_at: "2026-09-09T10:00:00Z",
indexed_at: "2026-09-09T10:00:01Z",
read_at: null,
...over,
};
}
describe("fetchNotifications", () => {
it("invokes fetch_notifications and maps the wire rows verbatim", async () => {
const { fetchNotifications } = await import("./client");
invokeMock.mockResolvedValueOnce({
notifications: [wireNotification()],
cursor: "cur1",
});
const r = await fetchNotifications("did:plc:me");
expect(invokeMock).toHaveBeenCalledTimes(1);
expect(invokeMock).toHaveBeenCalledWith("fetch_notifications", {
did: "did:plc:me",
cursor: null,
limit: 30,
});
expect(r.cursor).toBe("cur1");
expect(r.notifications).toHaveLength(1);
const n = r.notifications[0];
expect(n.id).toBe(1);
expect(n.kind).toBe("like");
expect(n.author_handle).toBe("alice.test");
expect(n.author_display_name).toBe("Alice");
expect(n.author_avatar_cid).toBe("bafyavatar");
expect(n.subject_uri).toBe("at://did:plc:me/app.twi.post/3k2");
expect(n.subject_text).toBe("hello world");
expect(n.indexed_at).toBe("2026-09-09T10:00:01Z");
// `read_at: null` is the unread marker the badge keys off — it
// must survive as null, not become undefined.
expect(n.read_at).toBeNull();
});
it("forwards the cursor and limit for a follow-up page", async () => {
const { fetchNotifications } = await import("./client");
invokeMock.mockResolvedValueOnce({ notifications: [], cursor: null });
const r = await fetchNotifications("did:plc:me", "cur1", 50);
expect(invokeMock).toHaveBeenCalledWith("fetch_notifications", {
did: "did:plc:me",
cursor: "cur1",
limit: 50,
});
// A null cursor is the documented end-of-list sentinel.
expect(r.cursor).toBeNull();
});
it("handles a follow row (no subject) without inventing fields", async () => {
const { fetchNotifications } = await import("./client");
invokeMock.mockResolvedValueOnce({
notifications: [
{
id: 7,
kind: "follow",
author_did: "did:plc:bob",
author_handle: "bob.test",
subject_uri: null,
created_at: "2026-09-09T09:00:00Z",
indexed_at: "2026-09-09T09:00:01Z",
read_at: "2026-09-09T09:30:00Z",
},
],
cursor: null,
});
const [n] = (await fetchNotifications("did:plc:me")).notifications;
expect(n.kind).toBe("follow");
expect(n.subject_uri).toBeNull();
expect(n.subject_text).toBeUndefined();
expect(n.author_display_name).toBeUndefined();
expect(n.read_at).toBe("2026-09-09T09:30:00Z");
});
it("propagates a Tauri-side error verbatim", async () => {
const { fetchNotifications } = await import("./client");
invokeMock.mockRejectedValueOnce(
new Error("appview: notifications returned 400: did is required"),
);
await expect(fetchNotifications("")).rejects.toThrow(
/notifications returned 400/,
);
});
});
describe("notificationCount", () => {
it("unwraps the {count} envelope to a plain number", async () => {
const { notificationCount } = await import("./client");
invokeMock.mockResolvedValueOnce({ count: 12 });
const n = await notificationCount("did:plc:me");
expect(n).toBe(12);
expect(invokeMock).toHaveBeenCalledWith("notification_count", {
did: "did:plc:me",
});
});
it("returns 0 for a fully-read inbox", async () => {
const { notificationCount } = await import("./client");
invokeMock.mockResolvedValueOnce({ count: 0 });
await expect(notificationCount("did:plc:me")).resolves.toBe(0);
});
});
describe("markNotificationsSeen", () => {
it("sends the seenAt watermark", async () => {
const { markNotificationsSeen } = await import("./client");
invokeMock.mockResolvedValueOnce({ ok: true, updated: 4 });
const r = await markNotificationsSeen(
"did:plc:me",
"2026-09-09T10:00:01Z",
);
expect(r).toEqual({ ok: true, updated: 4 });
expect(invokeMock).toHaveBeenCalledWith("mark_notifications_seen", {
did: "did:plc:me",
seenAt: "2026-09-09T10:00:01Z",
});
});
it("omitting the watermark sends null (mark everything read)", async () => {
const { markNotificationsSeen } = await import("./client");
invokeMock.mockResolvedValueOnce({ ok: true, updated: 0 });
await markNotificationsSeen("did:plc:me");
expect(invokeMock).toHaveBeenCalledWith("mark_notifications_seen", {
did: "did:plc:me",
seenAt: null,
});
});
});
describe("notificationText / notificationIcon", () => {
it("maps each kind to its German line", async () => {
const { notificationText } = await import("./client");
expect(notificationText("like")).toBe("hat deinen Post geliked");
expect(notificationText("repost")).toBe("hat repostet");
expect(notificationText("follow")).toBe("folgt dir jetzt");
expect(notificationText("reply")).toBe("hat geantwortet");
});
it("falls back rather than rendering an empty line for an unknown kind", async () => {
const { notificationText } = await import("./client");
expect(notificationText("quote")).toBe("hat interagiert");
expect(notificationText("")).toBe("hat interagiert");
});
it("gives every known kind its own icon glyph", async () => {
const { notificationIcon } = await import("./client");
const icons = ["like", "repost", "follow", "reply"].map(notificationIcon);
expect(new Set(icons).size).toBe(4);
expect(icons.every((i) => i.length > 0)).toBe(true);
expect(notificationIcon("quote")).toBe("•");
});
});
describe("fetchFollowers / fetchFollowing", () => {
const wireProfile = {
did: "did:plc:carol",
handle: "carol.test",
display_name: "Carol",
avatar_cid: "bafycarol",
};
it("fetchFollowers hits the followers command", async () => {
const { fetchFollowers } = await import("./client");
invokeMock.mockResolvedValueOnce({
profiles: [wireProfile],
cursor: null,
});
const r = await fetchFollowers("did:plc:me");
expect(invokeMock).toHaveBeenCalledWith("fetch_followers", {
did: "did:plc:me",
cursor: null,
limit: 30,
});
expect(r.profiles[0].handle).toBe("carol.test");
expect(r.profiles[0].display_name).toBe("Carol");
expect(r.cursor).toBeNull();
});
it("fetchFollowing hits the following command and pages", async () => {
const { fetchFollowing } = await import("./client");
invokeMock.mockResolvedValueOnce({
profiles: [wireProfile],
cursor: "cur2",
});
const r = await fetchFollowing("did:plc:me", "cur1", 10);
expect(invokeMock).toHaveBeenCalledWith("fetch_following", {
did: "did:plc:me",
cursor: "cur1",
limit: 10,
});
expect(r.cursor).toBe("cur2");
});
});
describe("fetchProfileByDid", () => {
it("invokes profile_get_by_did with the DID", async () => {
const { fetchProfileByDid } = await import("./client");
invokeMock.mockResolvedValueOnce({
did: "did:plc:f5abcdefghijklmnop",
handle: "alice.test",
posts: [],
followers: 2,
following: 3,
post_count: 0,
});
const p = await fetchProfileByDid("did:plc:f5abcdefghijklmnop");
expect(invokeMock).toHaveBeenCalledWith("profile_get_by_did", {
did: "did:plc:f5abcdefghijklmnop",
});
expect(p.did).toBe("did:plc:f5abcdefghijklmnop");
expect(p.followers).toBe(2);
});
it("resolves an actor whose only known handle is a truncated-DID placeholder", async () => {
// The dead end this endpoint exists to avoid: the AppView hands
// us `handle: "did:plc:f5a…"` (from `short_did_bare`) for an actor
// it has no profile row or indexed post for. Feeding that back
// into the *handle* lookup matches nothing and the server answers
// 200 with a synthetic empty profile — `did: ""`, zero counts —
// so the UI shows a blank page instead of an error. The DID
// lookup resolves the real row.
const { fetchProfile, fetchProfileByDid } = await import("./client");
const placeholderHandle = "did:plc:f5a…";
const realDid = "did:plc:f5abcdefghijklmnop";
// 1. What the handle path actually gets back today.
invokeMock.mockResolvedValueOnce({
did: "",
handle: placeholderHandle,
posts: [],
followers: 0,
following: 0,
post_count: 0,
});
const viaHandle = await fetchProfile(placeholderHandle);
expect(viaHandle.did).toBe("");
expect(viaHandle.post_count).toBe(0);
// 2. What the DID path gets back — the real profile.
invokeMock.mockResolvedValueOnce({
did: realDid,
handle: "alice.test",
posts: [],
followers: 5,
following: 1,
post_count: 12,
});
const viaDid = await fetchProfileByDid(realDid);
expect(viaDid.did).toBe(realDid);
expect(viaDid.handle).toBe("alice.test");
expect(viaDid.post_count).toBe(12);
// The two calls must be distinct commands — a shared one would
// reintroduce the ambiguity.
expect(invokeMock.mock.calls.map((c) => c[0])).toEqual([
"profile_get",
"profile_get_by_did",
]);
});
});
describe("fetchThread", () => {
it("invokes fetch_thread with the viewer DID", async () => {
const { fetchThread } = await import("./client");
invokeMock.mockResolvedValueOnce({
post: null,
parents: [],
root: null,
replies: [],
});
const r = await fetchThread("at://did:plc:me/app.twi.post/3k2", "did:plc:me");
expect(invokeMock).toHaveBeenCalledWith("fetch_thread", {
uri: "at://did:plc:me/app.twi.post/3k2",
viewerDid: "did:plc:me",
});
// `post: null` is the "not in our index" sentinel — one field
// check is enough to render "post not found".
expect(r.post).toBeNull();
expect(r.parents).toEqual([]);
expect(r.replies).toEqual([]);
});
it("defaults the viewer DID to null", async () => {
const { fetchThread } = await import("./client");
invokeMock.mockResolvedValueOnce({
post: null,
parents: [],
root: null,
replies: [],
});
await fetchThread("at://did:plc:me/app.twi.post/3k2");
expect(invokeMock).toHaveBeenCalledWith("fetch_thread", {
uri: "at://did:plc:me/app.twi.post/3k2",
viewerDid: null,
});
});
});
@@ -4,18 +4,35 @@
// `$bindable`, use a callback prop to bubble state changes up to
// the parent.
type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
type View =
| "home"
| "notifications"
| "compose"
| "profile"
| "user"
| "search"
| "settings";
let {
view = "home",
on_select,
unread = 0,
}: {
view?: View;
on_select?: (v: View) => void;
/// Unread-notification count from `/api/notifications/count`.
/// The parent polls it and hands it down; `0` (or anything
/// below) hides the badge entirely rather than rendering a "0".
unread?: number;
} = $props();
/// Badge label. Anything past 99 collapses to "99+" so a long
/// unread backlog can't widen the 88px rail.
const badge = $derived(unread > 99 ? "99+" : String(unread));
const items: Array<{ id: View; label: string; key: string; icon: string }> = [
{ id: "home", label: "home", key: "g h", icon: "home" },
{ id: "notifications", label: "notifs", key: "g n", icon: "bell" },
{ id: "compose", label: "compose", key: "c", icon: "compose" },
{ id: "profile", label: "profile", key: "p", icon: "profile" },
{ id: "search", label: "search", key: "/", icon: "search" },
@@ -37,6 +54,10 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<path d="M3 11l9-8 9 8v9a2 2 0 0 1-2 2h-3v-7H8v7H5a2 2 0 0 1-2-2z"/>
</svg>
{:else if item.icon === "bell"}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 15v-4a6 6 0 1 0-12 0v4l-2 3h16z"/><path d="M10 21h4"/>
</svg>
{:else if item.icon === "compose"}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
<text x="3" y="17" font-family="ui-monospace,monospace" font-size="14" font-weight="700" fill="currentColor" stroke="none">&gt;_</text>
@@ -55,6 +76,16 @@
<circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/>
</svg>
{/if}
{#if item.id === "notifications" && unread > 0}
<!-- Live region so a screen reader announces a badge that
appears while the rail is already on screen. -->
<span
class="badge"
data-testid="notif-badge"
aria-live="polite"
aria-label={`${unread} ungelesene Benachrichtigungen`}
>{badge}</span>
{/if}
</span>
<span class="label">{item.label}</span>
</button>
@@ -107,6 +138,26 @@
height: 24px;
display: grid;
place-items: center;
/* Anchor for the unread badge, which overhangs the glyph's
top-right corner the way a tray badge does. */
position: relative;
}
.badge {
position: absolute;
top: -6px;
right: -10px;
min-width: 16px;
padding: 0 4px;
border-radius: var(--r-pill);
background: var(--orange);
color: var(--bg);
font-family: var(--font-mono);
font-size: 10px;
line-height: 16px;
font-weight: 700;
text-align: center;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
.icon :global(svg) {
width: 22px;
@@ -5,6 +5,10 @@
//
// Pattern: `let view = $state("home")` in the harness, then the
// `on_select` callback updates it (same shape as App.svelte).
//
// The rail order is: home, notifications, compose, profile, search,
// settings. The indices below follow that order — if you reorder the
// `items` array in NavRail.svelte, update them here too.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mount, unmount, tick } from "svelte";
@@ -23,16 +27,16 @@ afterEach(() => {
target.remove();
});
async function mountHarness() {
app = mount(NavRailHarness, { target });
async function mountHarness(props: { unread?: number } = {}) {
app = mount(NavRailHarness, { target, props });
await tick();
}
describe("NavRail (callback-prop pattern)", () => {
it("renders 5 buttons with home active", async () => {
it("renders 6 buttons with home active", async () => {
await mountHarness();
const btns = target.querySelectorAll("button.rail__btn");
expect(btns.length).toBe(5);
expect(btns.length).toBe(6);
expect(btns[0].classList.contains("active")).toBe(true);
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("home");
});
@@ -41,25 +45,68 @@ describe("NavRail (callback-prop pattern)", () => {
await mountHarness();
const btns = target.querySelectorAll("button.rail__btn");
btns[3].dispatchEvent(new MouseEvent("click", { bubbles: true }));
btns[4].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("search");
expect(btns[3].classList.contains("active")).toBe(true);
expect(btns[4].classList.contains("active")).toBe(true);
expect(btns[0].classList.contains("active")).toBe(false);
btns[1].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("compose");
expect(btns[1].classList.contains("active")).toBe(true);
btns[2].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("profile");
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("compose");
expect(btns[2].classList.contains("active")).toBe(true);
btns[4].dispatchEvent(new MouseEvent("click", { bubbles: true }));
btns[3].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("profile");
expect(btns[3].classList.contains("active")).toBe(true);
btns[5].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("settings");
expect(btns[4].classList.contains("active")).toBe(true);
expect(btns[5].classList.contains("active")).toBe(true);
});
it("routes to the notifications view", async () => {
await mountHarness();
const btns = target.querySelectorAll("button.rail__btn");
btns[1].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe(
"notifications",
);
expect(btns[1].classList.contains("active")).toBe(true);
});
});
describe("NavRail unread badge", () => {
it("renders no badge at zero unread", async () => {
await mountHarness({ unread: 0 });
expect(target.querySelector('[data-testid="notif-badge"]')).toBeNull();
});
it("renders the count on the notifications button", async () => {
await mountHarness({ unread: 3 });
const badge = target.querySelector('[data-testid="notif-badge"]');
expect(badge).not.toBeNull();
expect(badge?.textContent?.trim()).toBe("3");
// The badge must sit on the notifications entry, not on some
// other rail button.
const btns = target.querySelectorAll("button.rail__btn");
expect(btns[1].contains(badge!)).toBe(true);
});
it("caps a large backlog at 99+ so the 88px rail can't widen", async () => {
await mountHarness({ unread: 1234 });
const badge = target.querySelector('[data-testid="notif-badge"]');
expect(badge?.textContent?.trim()).toBe("99+");
});
it("keeps 99 unabbreviated (the cap is exclusive)", async () => {
await mountHarness({ unread: 99 });
expect(
target.querySelector('[data-testid="notif-badge"]')?.textContent?.trim(),
).toBe("99");
});
});
@@ -5,12 +5,26 @@
import NavRail from "./NavRail.svelte";
type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
type View =
| "home"
| "notifications"
| "compose"
| "profile"
| "user"
| "search"
| "settings";
// `unread` is a prop so the badge test can drive it without going
// through the polling path in App.svelte. Defaults to 0 (no badge),
// which is what every non-badge test expects.
let { unread = 0 }: { unread?: number } = $props();
let view: View = $state("home");
</script>
<NavRail
{view}
{unread}
on_select={(v) => {
view = v;
}}
@@ -0,0 +1,366 @@
<script lang="ts">
import Avatar from "./Avatar.svelte";
import Skeleton from "./Skeleton.svelte";
import {
fetchNotifications,
markNotificationsSeen,
notificationIcon,
notificationText,
type Notification,
} from "../api/client";
type Props = {
/// Recipient DID — whose notifications to list.
did: string;
/// Opens the thread for a notification's `subject_uri`. Wired to
/// App.svelte's existing `openThread` helper, so a notification
/// and a PostCard's thread button land in the same modal.
on_thread_click?: (uri: string) => void;
/// Navigates to the author's profile. Takes the DID *and* the
/// handle, and the DID is what the navigation resolves by: the
/// AppView hands us a placeholder handle (a truncated DID, see
/// `short_did_bare`) for any author it has neither a profile row
/// nor an indexed post for, and that placeholder resolves to a
/// synthetic empty profile on the way back in. `author_did` is
/// always real, so it's the one field worth navigating on. The
/// handle still travels along for the header label while the
/// profile loads.
on_actor_click?: (did: string, handle: string) => void;
/// Fired once the first page is marked read, so the parent can
/// zero the NavRail badge without waiting for the next poll.
on_seen?: () => void;
};
let { did, on_thread_click, on_actor_click, on_seen }: Props = $props();
let items: Notification[] = $state([]);
let cursor: string | null = $state(null);
let loading: boolean = $state(false);
let error: string | null = $state(null);
/// Guards `load()` against a re-entrant `$effect` run: the effect
/// tracks `did`, but `load` writes `$state` the effect would
/// otherwise see as a self-write (the same depth-guard problem
/// ProfileView documents around `untrack`).
let loadedDid: string | null = null;
async function load() {
loading = true;
error = null;
try {
const r = await fetchNotifications(did, null, 30);
items = r.notifications;
cursor = r.cursor;
// Watermark the read marker at the newest row we actually
// rendered — never `null` — so a notification that lands while
// the user is scrolling isn't silently marked as seen.
const top = r.notifications[0]?.indexed_at ?? null;
if (top) {
try {
await markNotificationsSeen(did, top);
// Reflect it locally too: the rows are already on screen,
// and re-fetching just to flip `read_at` would be a wasted
// round trip.
items = items.map((n) =>
n.read_at ? n : { ...n, read_at: top },
);
on_seen?.();
} catch (e) {
// A failed ack is not a failed load — the list is usable,
// the badge just stays until the next attempt.
console.warn("mark_notifications_seen failed", e);
}
}
} catch (e) {
error = String(e);
} finally {
loading = false;
}
}
async function loadMore() {
if (!cursor || loading) return;
loading = true;
try {
const r = await fetchNotifications(did, cursor, 30);
// De-dupe on `id`: the keyset cursor is stable, but a row
// indexed between two page fetches can otherwise shift a row
// across the page boundary.
const seen = new Set(items.map((n) => n.id));
items = [...items, ...r.notifications.filter((n) => !seen.has(n.id))];
cursor = r.cursor;
} catch (e) {
error = String(e);
} finally {
loading = false;
}
}
$effect(() => {
if (!did || loadedDid === did) return;
loadedDid = did;
void load();
});
/// Display label for the author — the profile's display name when
/// the AppView has one cached, else the handle (which the server
/// guarantees is non-empty, falling back to a truncated DID).
function authorName(n: Notification): string {
return n.author_display_name || n.author_handle;
}
/// Same relative-time rendering as PostCard's `timeAgo`, on
/// `indexed_at` (the list's sort key) rather than `created_at`.
function timeAgo(iso: string): string {
const timestamp = new Date(iso).getTime();
if (!Number.isFinite(timestamp)) return iso;
const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
return `${Math.floor(seconds / 86400)}d`;
}
function openSubject(n: Notification) {
if (!n.subject_uri) return;
on_thread_click?.(n.subject_uri);
}
</script>
<section class="notifs">
{#if error}
<div class="notifs__err">err: {error}</div>
{/if}
{#if loading && items.length === 0}
<Skeleton rows={3} />
{:else if items.length === 0}
<div class="notifs__empty">// keine Benachrichtigungen.</div>
{:else}
<ul class="notifs__list">
{#each items as n (n.id)}
<li class="notifs__item" class:notifs__item--unread={!n.read_at}>
<!--
The whole row is the click target when there's a subject
to open; for a "follow" (no subject) the row is inert and
only the handle button navigates. `type="button"` +
`disabled` keeps the keyboard order honest instead of
faking it with a div.
-->
<button
class="notifs__row"
type="button"
disabled={!n.subject_uri}
aria-label={`${authorName(n)} ${notificationText(n.kind)}`}
onclick={() => openSubject(n)}
>
<span class="notifs__icon" data-kind={n.kind} aria-hidden="true">
{notificationIcon(n.kind)}
</span>
<span class="notifs__avatar">
<Avatar
did={n.author_did}
cid={n.author_avatar_cid ?? null}
name={authorName(n)}
size={32}
/>
</span>
<span class="notifs__body">
<span class="notifs__line">
<span class="notifs__name">{authorName(n)}</span>
<span class="notifs__text">{notificationText(n.kind)}</span>
<span class="notifs__age">{timeAgo(n.indexed_at)}</span>
</span>
{#if n.subject_text}
<span class="notifs__subject">{n.subject_text}</span>
{/if}
</span>
</button>
<button
class="notifs__handle"
type="button"
title={n.author_did}
onclick={() => on_actor_click?.(n.author_did, n.author_handle)}
>@{n.author_handle}</button>
</li>
{/each}
</ul>
{#if cursor}
<div class="notifs__loadmore">
<button
class="notifs__btn"
type="button"
disabled={loading}
onclick={() => void loadMore()}
>
{loading ? "loading…" : "load more"}
</button>
</div>
{/if}
{/if}
</section>
<style>
.notifs {
padding: 0 0 var(--s-6);
}
.notifs__err {
font-family: var(--font-mono);
font-size: var(--fs-50);
padding: var(--s-2) var(--s-3);
margin: 0 var(--s-5) var(--s-3);
border-left: 3px solid var(--red);
background: var(--orange-3);
color: var(--red);
border-radius: 0 var(--r-sm) var(--r-sm) 0;
}
.notifs__empty {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
padding: var(--s-4) var(--s-5);
font-style: italic;
}
.notifs__list {
list-style: none;
margin: 0;
padding: 0;
}
/* One row per notification: the clickable body plus the handle
button, which is a separate target so "open the thread" and "go
to the author" don't fight over the same click. */
.notifs__item {
display: flex;
align-items: center;
gap: var(--s-2);
padding: 0 var(--s-4) 0 0;
border-bottom: 1px solid var(--line);
}
/* Unread rows get the orange left rail + tint the rest of the app
uses for "needs attention" (same tokens as the settings hover
state), so the read/unread split is visible without a legend. */
.notifs__item--unread {
background: var(--orange-8);
box-shadow: inset 3px 0 0 var(--orange);
}
.notifs__row {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: var(--s-3);
padding: var(--s-3) var(--s-4);
background: transparent;
border: 0;
text-align: left;
cursor: pointer;
color: var(--text);
font-family: var(--font-mono);
transition: background-color var(--dur) var(--ease);
}
.notifs__row:hover:not(:disabled) {
background: var(--orange-3);
}
.notifs__row:disabled {
cursor: default;
}
.notifs__icon {
flex: 0 0 auto;
width: 1.25rem;
text-align: center;
font-size: var(--fs-100);
color: var(--text-dim);
}
/* Per-kind accent: likes red, reposts green, follows/replies cyan
— all straight from the token palette, no new hex values. */
.notifs__icon[data-kind="like"] { color: var(--red); }
.notifs__icon[data-kind="repost"] { color: var(--green); }
.notifs__icon[data-kind="follow"] { color: var(--cyan); }
.notifs__icon[data-kind="reply"] { color: var(--orange); }
.notifs__avatar {
flex: 0 0 auto;
line-height: 0;
}
.notifs__body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.notifs__line {
display: flex;
align-items: baseline;
gap: var(--s-2);
min-width: 0;
}
.notifs__name {
color: var(--text);
font-weight: 700;
font-size: var(--fs-50);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.notifs__text {
color: var(--text-dim);
font-size: var(--fs-50);
white-space: nowrap;
}
.notifs__age {
color: var(--text-dim);
font-size: var(--fs-50);
font-variant-numeric: tabular-nums;
margin-left: auto;
flex: 0 0 auto;
}
/* Subject preview — one line, clipped. The full text is a click
away in the thread, so wrapping here would only push rows apart. */
.notifs__subject {
color: var(--text-dim);
font-size: var(--fs-50);
font-family: var(--font-sans);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.notifs__handle {
flex: 0 0 auto;
background: transparent;
border: 0;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
cursor: pointer;
padding: var(--s-1) var(--s-2);
border-radius: var(--r-sm);
transition: color var(--dur) var(--ease);
}
.notifs__handle:hover {
color: var(--orange);
}
.notifs__loadmore {
display: flex;
justify-content: center;
padding: var(--s-4) var(--s-5);
}
.notifs__btn {
font-family: var(--font-mono);
font-size: var(--fs-50);
padding: 0.4rem 0.8rem;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
background: transparent;
color: var(--text-dim);
cursor: pointer;
transition:
color var(--dur) var(--ease),
border-color var(--dur) var(--ease);
}
.notifs__btn:hover:not(:disabled) {
color: var(--orange);
border-color: var(--orange);
}
.notifs__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,219 @@
// Regression guard for the actor-navigation path out of the
// notifications list.
//
// The AppView guarantees `author_handle` is non-empty, but it does NOT
// guarantee it's a real handle: for an author it has neither a
// `profiles` row nor an indexed post for, it synthesises a placeholder
// from the DID (`short_did_bare` in the AppView's `routes.rs`) — e.g.
// `"did:plc:f5…"`, a truncated DID with an ellipsis. Navigating with
// that string resolves to nothing, and `/api/profile/<handle>` answers
// with a *synthetic empty profile* rather than an error, so the click
// silently dead-ends on a blank page.
//
// The fix is to hand the navigation callback the real `author_did`
// alongside the handle. These tests pin that: the callback must
// receive the DID from the DTO, for both a real-handle author and a
// placeholder-handle one.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mount, unmount, tick } from "svelte";
const fetchNotificationsMock = vi.fn();
const markNotificationsSeenMock = vi.fn();
vi.mock("../api/client", async () => {
// Keep the real `notificationText` / `notificationIcon` — the row
// copy is part of what we're rendering — and stub only the two
// functions that would otherwise need a Tauri runtime.
const actual =
await vi.importActual<typeof import("../api/client")>("../api/client");
return {
...actual,
fetchNotifications: (...args: unknown[]) => fetchNotificationsMock(...args),
markNotificationsSeen: (...args: unknown[]) =>
markNotificationsSeenMock(...args),
// Avatar resolves blobs through this; every fixture below has a
// null avatar CID so it never fires, but stub it so a stray call
// can't reach for the Tauri shell.
fetchBlob: () => Promise.reject(new Error("no blob in test")),
};
});
import NotificationsView from "./NotificationsView.svelte";
let target: HTMLDivElement;
let app: ReturnType<typeof mount> | null = null;
beforeEach(() => {
target = document.createElement("div");
document.body.appendChild(target);
fetchNotificationsMock.mockReset();
markNotificationsSeenMock.mockReset();
markNotificationsSeenMock.mockResolvedValue({ ok: true, updated: 1 });
});
afterEach(() => {
if (app) unmount(app);
app = null;
target.remove();
});
/// Let the component's load effect and its awaited promises settle.
/// The mocked fetch resolves immediately, so a handful of microtask
/// turns plus a Svelte flush is enough.
async function flush(turns = 6) {
for (let i = 0; i < turns; i++) {
await Promise.resolve();
await tick();
}
}
function row(over: Record<string, unknown> = {}) {
return {
id: 1,
kind: "like",
author_did: "did:plc:realauthor",
author_handle: "alice.test",
author_avatar_cid: null,
subject_uri: "at://did:plc:me/app.twi.post/3k2",
subject_text: "hello",
created_at: "2026-09-09T10:00:00Z",
indexed_at: "2026-09-09T10:00:01Z",
read_at: null,
...over,
};
}
describe("NotificationsView actor navigation", () => {
it("hands the callback the author DID, not just the handle", async () => {
fetchNotificationsMock.mockResolvedValue({
notifications: [row()],
cursor: null,
});
const onActorClick = vi.fn();
app = mount(NotificationsView, {
target,
props: { did: "did:plc:me", on_actor_click: onActorClick },
});
await flush();
const handleBtn = target.querySelector("button.notifs__handle");
expect(handleBtn).not.toBeNull();
handleBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(onActorClick).toHaveBeenCalledTimes(1);
expect(onActorClick).toHaveBeenCalledWith(
"did:plc:realauthor",
"alice.test",
);
});
it("resolves an author whose handle is only a truncated-DID placeholder", async () => {
// Exactly what the AppView returns for an author it has never
// seen post and has no profile row for: `handle` is
// `short_did_bare(author_did)`, which matches nothing on the way
// back in. The DID beside it is real.
fetchNotificationsMock.mockResolvedValue({
notifications: [
row({
id: 2,
kind: "follow",
author_did: "did:plc:f5abcdefghijklmnop",
author_handle: "did:plc:f5a…",
subject_uri: null,
subject_text: undefined,
}),
],
cursor: null,
});
const onActorClick = vi.fn();
app = mount(NotificationsView, {
target,
props: { did: "did:plc:me", on_actor_click: onActorClick },
});
await flush();
target
.querySelector("button.notifs__handle")!
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
const [didArg, handleArg] = onActorClick.mock.calls[0];
// The DID must be the full, real one — never the truncated
// display string.
expect(didArg).toBe("did:plc:f5abcdefghijklmnop");
expect(didArg).not.toContain("…");
// The placeholder still travels as the label, which is fine — it
// is not what the lookup keys off.
expect(handleArg).toBe("did:plc:f5a…");
});
it("renders the kind copy and marks the first page seen at the top row", async () => {
fetchNotificationsMock.mockResolvedValue({
notifications: [row({ kind: "follow", subject_uri: null })],
cursor: null,
});
const onSeen = vi.fn();
app = mount(NotificationsView, {
target,
props: { did: "did:plc:me", on_seen: onSeen },
});
await flush();
expect(target.textContent).toContain("folgt dir jetzt");
// The read watermark is the newest rendered row's `indexed_at` —
// never null, so a notification landing mid-scroll survives.
expect(markNotificationsSeenMock).toHaveBeenCalledWith(
"did:plc:me",
"2026-09-09T10:00:01Z",
);
expect(onSeen).toHaveBeenCalledTimes(1);
});
it("a follow row has no thread target, so its row button is inert", async () => {
fetchNotificationsMock.mockResolvedValue({
notifications: [row({ kind: "follow", subject_uri: null })],
cursor: null,
});
const onThreadClick = vi.fn();
app = mount(NotificationsView, {
target,
props: { did: "did:plc:me", on_thread_click: onThreadClick },
});
await flush();
const rowBtn = target.querySelector<HTMLButtonElement>("button.notifs__row");
expect(rowBtn?.disabled).toBe(true);
rowBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(onThreadClick).not.toHaveBeenCalled();
});
it("opens the thread for a row that has a subject", async () => {
fetchNotificationsMock.mockResolvedValue({
notifications: [row()],
cursor: null,
});
const onThreadClick = vi.fn();
app = mount(NotificationsView, {
target,
props: { did: "did:plc:me", on_thread_click: onThreadClick },
});
await flush();
target
.querySelector("button.notifs__row")!
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(onThreadClick).toHaveBeenCalledWith(
"at://did:plc:me/app.twi.post/3k2",
);
});
});
@@ -9,22 +9,49 @@
getAppviewUrl,
followUser,
unfollowUser,
fetchFollowers,
fetchFollowing,
fetchProfileByDid,
showInfo,
showError,
type ActorProfile,
} from "../api/client";
import { localStorageKey } from "../utils/localstorage";
import { onDestroy, onMount, untrack } from "svelte";
import { onDestroy, untrack } from "svelte";
type Props = {
handle: string;
/// DID of the profile to show, when the caller already knows it.
/// Takes precedence over `handle`: the AppView synthesises a
/// placeholder handle (a truncated DID — `short_did_bare`) for
/// actors it has neither a `profiles` row nor an indexed post for,
/// and feeding that back into the handle lookup resolves to a
/// synthetic empty profile. Navigation out of a notification or a
/// follower list therefore passes the DID; `handle` is still
/// supplied so the header has a label while the fetch is in
/// flight. `null`/absent keeps the handle lookup (PostCard header,
/// search, the current user's own profile).
did?: string | null;
on_thread_click?: (uri: string) => void;
/// DID of the authenticated user. When this matches the
/// profile's DID, the "edit profile" button is shown; the
/// /user-profile/<handle> route is then the user's own
/// profile (and the avatar / bio are editable).
current_user_did?: string | null;
/// Navigate to another profile. Receives `(did, handle)` and the
/// caller resolves by DID — see the `did` prop above for why the
/// handle alone is not a reliable key. Used by the follower /
/// following list rows and by the PostCards in the feed (which
/// carry a real `post.did`).
on_actor_click?: (did: string, handle: string) => void;
};
let { handle, on_thread_click, current_user_did }: Props = $props();
let {
handle,
did = null,
on_thread_click,
current_user_did,
on_actor_click,
}: Props = $props();
type State =
| { kind: "loading" }
@@ -91,6 +118,15 @@
async function load() {
viewModel = { kind: "loading" };
try {
// DID lookup wins when the caller supplied one — see the `did`
// prop. `/api/profile?did=…` is an exact match on the `posts` /
// `profiles` DID column, so it works for an actor whose only
// known "handle" is the server's truncated-DID placeholder.
if (did) {
const data = (await fetchProfileByDid(did)) as AppViewProfile;
viewModel = { kind: "ready", data };
return;
}
// Absolute URL because the Tauri webview's origin is the Vite
// dev server (port 1430), not the AppView (port 2584) — a
// relative `/api/profile/…` would resolve against Vite, hit a
@@ -145,8 +181,25 @@
};
});
onMount(() => {
void load();
/// Identity of the profile currently loaded, as an opaque key. A
/// plain (non-`$state`) variable on purpose: the effect below reads
/// it, and making it reactive would turn "remember what we loaded"
/// into a self-write that trips Svelte 5's depth guard — the same
/// pattern the banner effect documents around `untrack`.
let loadedKey: string | null = null;
// Reload whenever the profile *identity* changes, not just on
// mount. App.svelte keeps this component mounted across a
// profile→profile navigation (the view stays `"user"`, only the
// props change), so an `onMount`-only load would leave the previous
// user's posts and counts on screen under the new name — which is
// exactly the path a follower-list or notification click takes.
$effect(() => {
const key = did ? `did:${did}` : `handle:${handle}`;
if (!did && !handle) return;
if (loadedKey === key) return;
loadedKey = key;
untrack(() => void load());
});
onDestroy(() => {
@@ -299,6 +352,93 @@
type Tab = "posts" | "replies" | "likes";
let activeTab: Tab = $state("posts");
// ─── follower / following list ──────────────────────────────────
//
// Clicking a count opens an inline list under the counts row rather
// than navigating away — the user came here for this profile, and a
// separate route would cost a second profile fetch on the way back.
// Clicking the same count again closes it (the counts double as the
// toggle, the way a disclosure does).
type ActorListKind = "followers" | "following";
let actorKind: ActorListKind | null = $state(null);
let actors: ActorProfile[] = $state([]);
let actorCursor: string | null = $state(null);
let actorLoading: boolean = $state(false);
let actorError: string | null = $state(null);
async function toggleActorList(kind: ActorListKind) {
if (actorKind === kind) {
closeActorList();
return;
}
if (viewModel.kind !== "ready") return;
const did = viewModel.data.did;
actorKind = kind;
actors = [];
actorCursor = null;
actorError = null;
actorLoading = true;
try {
const r =
kind === "followers"
? await fetchFollowers(did, null, 30)
: await fetchFollowing(did, null, 30);
// Guard against a fast double-click on the other count: only
// commit if we're still the list the user asked for.
if (actorKind !== kind) return;
actors = r.profiles;
actorCursor = r.cursor;
} catch (e) {
if (actorKind === kind) actorError = String(e);
} finally {
if (actorKind === kind) actorLoading = false;
}
}
async function loadMoreActors() {
if (viewModel.kind !== "ready") return;
const kind = actorKind;
if (!kind || !actorCursor || actorLoading) return;
const did = viewModel.data.did;
actorLoading = true;
try {
const r =
kind === "followers"
? await fetchFollowers(did, actorCursor, 30)
: await fetchFollowing(did, actorCursor, 30);
if (actorKind !== kind) return;
// De-dupe on DID — a follow edge indexed between two page
// fetches can otherwise repeat a row across the boundary.
const seen = new Set(actors.map((a) => a.did));
actors = [...actors, ...r.profiles.filter((a) => !seen.has(a.did))];
actorCursor = r.cursor;
} catch (e) {
if (actorKind === kind) actorError = String(e);
} finally {
if (actorKind === kind) actorLoading = false;
}
}
function closeActorList() {
actorKind = null;
actors = [];
actorCursor = null;
actorError = null;
actorLoading = false;
}
// Switching to another profile must not leave the previous user's
// follower list on screen. Keyed on the same `(did, handle)`
// identity the load effect uses, so the two can't disagree about
// when "the profile changed".
let actorListKey: string | null = null;
$effect(() => {
const key = did ? `did:${did}` : `handle:${handle}`;
if (actorListKey === key) return;
actorListKey = key;
untrack(() => closeActorList());
});
</script>
<section class="profile">
@@ -426,17 +566,105 @@
<dt>posts</dt>
<dd>{viewModel.data.post_count}</dd>
</div>
<!--
The follower / following counts are buttons: they open the
matching actor list inline (and close it on a second click).
The post count stays inert — there's no endpoint behind it
that the feed below doesn't already show.
The button lives *inside* the `<dd>` rather than wrapping the
`dt`/`dd` pair, because a `<dl>` may only contain `dt`/`dd`
(via an optional `div`) — a button in between would be invalid
markup. `aria-label` puts the term back on the control so a
screen reader still hears "followers", not a bare number.
-->
<div>
<dt>followers</dt>
<dd>{viewModel.data.followers}</dd>
<dd>
<button
class="profile__count-btn"
class:profile__count-btn--open={actorKind === "followers"}
type="button"
aria-expanded={actorKind === "followers"}
aria-label={`${viewModel.data.followers} followers anzeigen`}
onclick={() => void toggleActorList("followers")}
>{viewModel.data.followers}</button>
</dd>
</div>
<div>
<dt>following</dt>
<dd>{viewModel.data.following}</dd>
<dd>
<button
class="profile__count-btn"
class:profile__count-btn--open={actorKind === "following"}
type="button"
aria-expanded={actorKind === "following"}
aria-label={`${viewModel.data.following} following anzeigen`}
onclick={() => void toggleActorList("following")}
>{viewModel.data.following}</button>
</dd>
</div>
</dl>
{/if}
<!-- ─── follower / following list ─────────────────────────────── -->
{#if actorKind}
<div class="actors">
<header class="actors__head">
<span class="actors__title">// {actorKind}</span>
<button
class="actors__close"
type="button"
onclick={closeActorList}
>close</button>
</header>
{#if actorError}
<div class="actors__err">err: {actorError}</div>
{/if}
{#if actorLoading && actors.length === 0}
<div class="actors__empty">// loading…</div>
{:else if actors.length === 0}
<div class="actors__empty">// niemand hier.</div>
{:else}
<ul class="actors__list">
{#each actors as a (a.did)}
<li>
<button
class="actors__row"
type="button"
title={a.did}
onclick={() => on_actor_click?.(a.did, a.handle)}
>
<Avatar
did={a.did}
cid={a.avatar_cid ?? null}
name={a.display_name ?? a.handle}
size={32}
/>
<span class="actors__names">
<span class="actors__name">{a.display_name ?? a.handle}</span>
<span class="actors__handle">@{a.handle}</span>
</span>
</button>
</li>
{/each}
</ul>
{#if actorCursor}
<div class="actors__loadmore">
<button
class="actors__more"
type="button"
disabled={actorLoading}
onclick={() => void loadMoreActors()}
>
{actorLoading ? "loading…" : "load more"}
</button>
</div>
{/if}
{/if}
</div>
{/if}
<!-- ─── tabs ──────────────────────────────────────────────────── -->
<nav class="profile__tabs" aria-label="Profile sections">
<button
@@ -465,7 +693,17 @@
<div class="profile__feed">
{#if viewModel.kind === "ready"}
{#each viewModel.data.posts as p (p.uri)}
<PostCard post={p} on_thread_click={on_thread_click} />
<!--
The feed's PostCards carry a real `post.did`, so we route
their header clicks through the same DID-first path as the
actor list rather than through the (possibly decorated)
`post.handle`.
-->
<PostCard
post={p}
on_thread_click={on_thread_click}
on_handle_click={(h) => on_actor_click?.(p.did, h)}
/>
{/each}
{#if viewModel.data.posts.length === 0}
<div class="profile__empty">// no posts yet.</div>
@@ -705,6 +943,157 @@
font-variant-numeric: tabular-nums;
}
/* The count button inherits the `dd` typography so the clickable
followers/following numbers read identically to the inert post
count — only the hover/open colour marks them as interactive. */
.profile__count-btn {
background: none;
border: 0;
padding: 0;
margin: 0;
color: inherit;
font: inherit;
font-variant-numeric: tabular-nums;
cursor: pointer;
border-bottom: 2px solid transparent;
transition:
color var(--dur) var(--ease),
border-color var(--dur) var(--ease);
}
.profile__count-btn:hover {
color: var(--orange);
}
.profile__count-btn--open {
color: var(--orange);
border-bottom-color: var(--orange);
}
/* ─── follower / following list ─────────────────────────── */
.actors {
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
background: var(--bg-elev);
}
.actors__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-3);
padding: var(--s-2) var(--s-4);
border-bottom: 1px solid var(--line);
}
.actors__title {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--orange);
letter-spacing: var(--tracking-label);
font-weight: 700;
}
.actors__close {
background: transparent;
border: 1px solid var(--line-2);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
padding: 0.2rem 0.6rem;
border-radius: var(--r-sm);
cursor: pointer;
transition:
color var(--dur) var(--ease),
border-color var(--dur) var(--ease);
}
.actors__close:hover {
color: var(--orange);
border-color: var(--orange);
}
.actors__err {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--red);
padding: var(--s-2) var(--s-4);
}
.actors__empty {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
font-style: italic;
padding: var(--s-3) var(--s-4);
}
.actors__list {
list-style: none;
margin: 0;
padding: 0;
/* Cap the inline list so it never pushes the feed off screen —
"load more" keeps the rest reachable. */
max-height: 320px;
overflow-y: auto;
}
.actors__row {
width: 100%;
display: flex;
align-items: center;
gap: var(--s-3);
padding: var(--s-2) var(--s-4);
background: transparent;
border: 0;
border-bottom: 1px solid var(--line);
text-align: left;
cursor: pointer;
color: var(--text);
transition: background-color var(--dur) var(--ease);
}
.actors__row:hover {
background: var(--orange-8);
}
.actors__names {
display: flex;
flex-direction: column;
min-width: 0;
}
.actors__name {
font-family: var(--font-mono);
font-size: var(--fs-50);
font-weight: 700;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.actors__handle {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.actors__loadmore {
display: flex;
justify-content: center;
padding: var(--s-3);
}
.actors__more {
font-family: var(--font-mono);
font-size: var(--fs-50);
padding: 0.4rem 0.8rem;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
background: transparent;
color: var(--text-dim);
cursor: pointer;
transition:
color var(--dur) var(--ease),
border-color var(--dur) var(--ease);
}
.actors__more:hover:not(:disabled) {
color: var(--orange);
border-color: var(--orange);
}
.actors__more:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ─── tabs ──────────────────────────────────────────────── */
.profile__tabs {
display: flex;
+247
View File
@@ -0,0 +1,247 @@
# Architektur
Vertiefung zum Überblick im README: wer schreibt was, wo liegen die Daten, und
auf welchem Weg kommt ein Post vom Client bis in die Timeline zurück.
## Prozesse und Datenflüsse
```
┌───────────────────────────────────┐
│ Tauri-Client (Desktop) │
│ Svelte 5 (Webview) │
│ │ invoke() │
│ src-tauri (Rust-IPC) │
│ pds_client.rs appview_client.rs│
└───────┬───────────────────┬───────┘
Schreiben │ │ Lesen
XRPC/HTTPS │ │ REST/HTTP
▼ ▼
┌──────────────────────────────────┐ ┌──────────────────────────────┐
│ pds-server (axum, :2583) │ │ appview (axum, :2584) │
│ │ │ │
│ /xrpc/com.atproto.* │ │ GET /api/timeline/home │
│ /xrpc/app.bsky.actor.profile.* │ │ GET /api/profile[/:handle] │
│ /blob/:cid │ │ GET /api/search │
│ /healthz │ │ GET /api/post|thread/*uri │
│ │ │ GET /api/notifications… │
│ │ │ GET /api/followers|following│
│ │ │ GET /healthz │
│ at-lexicon Validierung (160) │ │ │
│ at-repo/at-mst MST + Commit │ │ indexer.rs Upserts │
│ at-crypto Signatur, CID, JWT │ │ handle_sync.rs Worker │
│ at-identity PLC / did:web │ │ ingest.rs interner Writer │
│ at-blob MinIO │ │ │
└───┬────────────┬───────────┬─────┘ └──────┬──────────────┬────────┘
│ │ │ │ ▲
│ │ │ POST /internal/ingest-commit │
│ │ └───────────────────────────────┤
│ │ │ │
▼ ▼ ▼ │
┌───────────┐ ┌────────┐ ┌─────────────────┐ │
│ Postgres │ │ MinIO │ │ Postgres │ │
│ pds :5434 │ │ :9100 │ │ appview :5435 │ │
└───────────┘ └────────┘ └─────────────────┘ │
│ │
│ (heute: kein eigener Firehose-Ausgang) │
▼ │
┌──────────────────────────────────────────┐ │
│ Jetstream-Relay (extern, WebSocket) │──────────────────┘
│ JETSTREAM_URL │ at-firehose
└──────────────────────────────────────────┘ JetstreamConsumer
```
Zwei Wege führen in die AppView, und das ist Absicht:
1. **Direkter Push (schnell, lokal).** Jeder erfolgreiche Commit auf der PDS
wird per `POST /internal/ingest-commit` an die AppView geschoben
(`crates/pds-server/src/appview_push.rs`). Best effort, 5 s Timeout, blockiert
den Record-Write nie. Damit sieht der Nutzer seinen eigenen Post sofort.
2. **Jetstream (global, verzögert).** `at-firehose::JetstreamConsumer` hängt an
einem externen Jetstream-Relay und liefert alles, was in den konfigurierten
Collections weltweit passiert.
Wichtig für das Verständnis der Topologie: **die eigene PDS speist den
Jetstream nicht.** Es gibt keinen `com.atproto.sync.subscribeRepos`-Endpoint im
PDS-Router. Der Firehose-Weg ist ein reiner Konsum-Pfad für fremde Repos; die
eigenen Records erreichen die AppView ausschließlich über den Push aus
Punkt 1 (noch offen).
## Crates
| Crate | Typ | Aufgabe |
|---|---|---|
| `at-lexicon` | lib | Lexicon-Schemas laden (`Lex::from_json`) und Records validieren. `LexRegistry` in der PDS kennt `app.twi.post` (160 Zeichen), `app.bsky.feed.like`, `app.bsky.feed.repost`, `app.bsky.actor.profile` — alle vier per `include_str!` einkompiliert |
| `at-crypto` | lib | secp256k1/P-256-Keypairs, DAG-CBOR-CIDs, multibase/base58btc, JWT (`issue_jwt` / `verify_jwt`), PLC-Operationen inkl. `did_plc_from_op` |
| `at-identity` | lib | Handle- und DID-Auflösung. Drei Resolver hinter dem Trait `DidHandleResolver`: `PlcClient` (PLC-Directory), `WebResolver` (`.well-known/did.json`), `PdsHandleResolver` (fragt die lokale PDS) |
| `at-mst` | lib | Merkle-Search-Tree: Knoten, `split_around`, `wrap_with_split`, spec-konformes `encode_key` |
| `at-repo` | lib | Repo-Abstraktion über einem `Blockstore`, Commit-Erzeugung + Signatur, TID-Revisionen (`rev.rs`) |
| `at-blob` | lib | `S3BlobStore` gegen MinIO (unsignierte PUT/GET/DELETE) und `InMemoryBlobStore` (gleiche CID-Semantik, für Tests/Single-Node ohne Objektspeicher), MIME-Sniffing (`infer`), `ping()` für den Startup-Check |
| `at-firehose` | lib | `JetstreamConsumer`: WebSocket, Collection-Filter, Cursor-Resume, exponentielles Reconnect-Backoff, `connected`-Flag für `/healthz` |
| `at-shared` | lib | `AppConfig::from_env()`, `AtError` mit HTTP-Status-Mapping, DID- und Zeit-Helfer |
| `pds-server` | bin | axum-HTTP-PDS. Routen, Auth/JWT, Repo-Writes, Blob-Upload, AppView-Push |
| `appview` | bin | axum-REST-API + Jetstream-Indexer + Handle-Sync-Worker + interner Ingest-Endpoint |
| `tauri-app` | bin (eigener Workspace) | Desktop-Client. Svelte-5-Frontend, Rust-IPC-Layer mit HTTP-Clients für PDS und AppView |
`crates/tauri-app/src-tauri/Cargo.toml` deklariert ein eigenes `[workspace]`,
das Crate ist also **nicht** Teil des Root-Workspaces. `cargo build --workspace`
im Repo-Root baut den Client nicht mit, und sein Build-Output landet in
`crates/tauri-app/src-tauri/target/`.
## Schreibpfad: ein Post entsteht
`ComposeBox``invoke("post_create")``src-tauri/src/lib.rs`
`PdsHttpClient::create_record``POST /xrpc/com.atproto.repo.createRecord`.
Serverseitig (`crates/pds-server/src/routes/repo.rs` +
`routes/helpers.rs::apply_repo_write`):
1. `Authorization: Bearer <access_jwt>` prüfen — verifiziert gegen den aus
`PDS_JWT_SECRET` abgeleiteten P-256-Serverschlüssel. `claims.sub` muss der
`repo`-DID entsprechen.
2. Lexicon-Validierung über `state.lex` (abschaltbar mit `validate: false` im
Request-Body). Hier greift das 160-Zeichen-Limit von `app.twi.post`.
3. Transaktion öffnen und `SELECT … FROM repos WHERE did = $1 FOR UPDATE`
der Row-Lock serialisiert konkurrierende Writes desselben Repos.
4. Record als DAG-CBOR kodieren, CID bilden, Block in den Blockstore legen,
MST aktualisieren.
5. `repo.commit()` signiert den neuen Commit mit dem User-Signing-Key; `rev`
ist eine TID.
6. Blocks + neuer Head landen in `repo_blocks` / `repos`, Commit der
Transaktion.
7. Antwort mit `uri` (`at://did/collection/rkey`), `cid` und dem Commit-Objekt.
8. Danach — außerhalb des kritischen Pfads — der Push an die AppView.
Der Blockstore in `AppState` ist ein `MemoryBlockstore`; Persistenz kommt aus
`repo_blocks`. Nach einem Neustart wird der Speicher pro Write aus der DB
rehydriert.
## Lesepfad: die Timeline
`GET /api/timeline/home?did=…&limit=…&cursor=…`
(`crates/appview/src/routes.rs`):
1. `SELECT subject_did FROM follows WHERE follower_did = $1`.
2. Zielmenge = Followees + eigene DID, dedupliziert, auf
`MAX_FOLLOWED_DIDS = 1000` gedeckelt (sortiert, damit die Kürzung stabil
ist; die eigene DID bleibt garantiert drin).
3. Ist die Menge leer bzw. nur die eigene DID, fällt die Abfrage auf den
globalen Recent-Feed zurück — Cold-Start für neue Accounts.
4. Posts aus `posts` mit
`collection IN ('app.twi.post','app.bsky.feed.post')`.
5. Paginierung über einen opaken Cursor `base64url(micros):uri`
(`routes/cursor.rs`), passend zu den Indizes aus
`0002_pagination_indexes.sql`.
6. `decorate_handles` ersetzt leere Handles anzeigeseitig durch
`@<erste-12-Zeichen-der-DID>…` — die DB-Zeile bleibt unangetastet, das
Nachfüllen erledigt der Handle-Sync-Worker.
Limits: Default 30, Maximum 100.
Die vollständige, aktuelle Routenliste steht in
`crates/appview/src/routes.rs::router` — sie wächst gerade (Thread-Kontext,
Notifications, Follower-Listen) und wird hier bewusst nicht dupliziert.
## Indexer
`crates/appview/src/firehose.rs` ist der Event-Dispatcher, `indexer.rs` macht
die Schreibarbeit. Behandelte Event-Arten:
* `commit``apply_commit`, dispatcht auf die Collection:
`app.twi.post` / `app.bsky.feed.post`, `app.bsky.feed.like`,
`app.bsky.feed.repost`, `app.bsky.graph.follow`, `app.bsky.actor.profile`.
Alles andere wird übersprungen (der Cursor darf trotzdem weiterlaufen).
* `identity``backfill_handle`: schreibt den neuen Handle in alle
`posts`-Zeilen der DID.
* `account``handle_account`.
Alle Schreibpfade sind Upserts, das Replay nach einem Reconnect ist damit
unschädlich. Fehlerhafte Events rücken den Cursor **nicht** vor.
`ingest.rs` bedient denselben Indexer über HTTP, mit den Aktionen
`create` / `delete`; für `app.bsky.graph.follow`-Deletes braucht der Aufrufer
`subject_did` im Body, weil der Record-Wert bei Deletes nicht garantiert
mitkommt.
## Datenbanken und Tabellen
### PDS-DB (`DATABASE_URL_PDS`, Compose-Port 5434)
| Tabelle | Inhalt |
|---|---|
| `users` | `did` (PK), `handle` (unique), `email`, `password_hash` (argon2id), `signing_key`, `rotation_key` |
| `repos` | Ein Head pro DID: `rev` (TID), `head_cid`, `head_commit` (CBOR), `prev_commit` |
| `repo_blocks` | `(did, cid)` → CBOR-Block. MST-Knoten **und** Records. `0002_blob_mime.sql` ergänzt einen `cid`-Index |
| `blobs` | `cid` (PK), `did`, `mime_type`, `size`, `storage_key` (S3-Key) |
| `sessions` | Access-/Refresh-JWT mit Ablaufzeiten und `revoked_at` |
| `plc_ops` | Audit-Log der signierten PLC-Operationen, `submitted`-Flag |
Trigger `users_touch` pflegt `users.updated_at`.
### AppView-DB (`DATABASE_URL_APPVIEW`, Compose-Port 5435)
Stand der Migrationen zum Zeitpunkt dieses Dokuments; maßgeblich bleibt
`migrations/appview/`.
| Tabelle | Inhalt |
|---|---|
| `posts` | `uri` (PK), `did`, `handle`, `rkey`, `collection`, `text`, `cid`, `parent_uri`, `root_uri`, `langs`, `created_at`, `indexed_at`. Später ergänzt: `embed` (JSONB), `reply_parent_handle`, `reply_root_handle`, `reply_root_uri` (0003), `like_count`, `repost_count` (0004), `avatar_cid` (0005), `handle_sync_attempted_at` (0006) |
| `likes` / `reposts` | `uri` (PK), `did`, `post_uri`, `post_cid`. `0004_like_repost_counters.sql` ergänzt je einen Unique-Index auf `(did, post_uri)` gegen Doppel-Likes und dedupliziert vorhandene Zeilen. Die Zähler auf `posts` pflegt der Indexer selbst (`UPDATE posts SET like_count = like_count + 1 …`, beim Entfernen mit `GREATEST(…, 0)`) — es gibt keine DB-Trigger dafür |
| `follows` | `(follower_did, subject_did)` als PK — die Basis des Timeline-Filters |
| `profiles` | `did` (PK), `handle`, `display_name`, `description`, `avatar_cid`, `banner_cid`, drei Counter. Angelegt in `0005_profiles.sql` inkl. Backfill aus `posts` |
| `jetstream_cursor` | Genau eine Zeile (`id = 1`), `cursor` = Mikrosekunden seit Epoch |
| `timeline_cache` | In `0001_init.sql` angelegt, aber von keinem Code-Pfad gelesen oder geschrieben — Platzhalter für materialisierte Timelines |
Suche läuft über den Trigram-GIN-Index `posts_text_trgm_idx` (`pg_trgm`), nicht
über Full-Text-Search.
### MinIO
Zwei Buckets aus dem Compose-Init: `maarcadetweet-pds` und
`maarcadetweet-appview`. Genutzt wird faktisch nur der erste — `S3_BUCKET_APPVIEW`
ist zwar Pflichtvariable in `AppConfig`, wird aber von keinem Code-Pfad gelesen.
`at-blob` spricht MinIO ohne Signature V4, die Buckets brauchen deshalb
`anonymous download`.
Blobs sind doppelt referenziert: der Objekt-Key liegt in `blobs.storage_key`,
zusätzlich hält die PDS den Block im lokalen Blockstore. Fällt MinIO aus, kann
`uploadBlob` weiterlaufen und `GET /blob/:cid` aus dem lokalen Bestand bedienen
(die Startup-Warnung sagt genau das).
## Identität
* `createAccount` erzeugt zwei secp256k1-Keypairs (Signing + Rotation), baut
daraus eine `PlcOperation`, signiert sie und leitet die DID **deterministisch
aus der CID der signierten Op** ab (`did_plc_from_op`).
* Das Submit ans PLC-Directory ist best effort. Schlägt es fehl, bleibt die DID
lokal gültig — sie ist dann nur global nicht auflösbar
(`plc submit failed (dev ok)` im Log).
* `com.atproto.identity.resolveHandle` ist polymorph: beginnt der Wert mit
`did:`, wird per Primärschlüssel in `users` gesucht, sonst über den Handle
(mit Suffix-Matching gegen `PDS_HANDLE_DNS_ZONE`). Genau diesen Pfad nutzt
der `PdsHandleResolver` der AppView, damit lokale Nutzer — auch
`did:key:`-Accounts — ohne Umweg über plc.directory einen Handle bekommen.
* Reihenfolge im Handle-Sync-Worker: lokale PDS → PLC → `did:web`. Andere
DID-Methoden werden übersprungen.
## Client
Der Webview ruft nie direkt HTTP: die CSP erlaubt `connect-src 'self' ipc:
http://ipc.localhost`, alle Netzwerkaufrufe laufen über `invoke()` in den
Rust-Layer (`pds_client.rs` für Schreiben/Auth, `appview_client.rs` für Lesen).
`store.rs` hält die Session.
Die Backend-URLs kommen aus `MAARCADETWEET_PDS_URL` und
`MAARCADETWEET_APPVIEW_URL` mit den Defaults `http://127.0.0.1:2583` und
`http://127.0.0.1:2584`; sie werden beim Start in `AppState` eingefroren und
sind zur Laufzeit nicht umschaltbar.
Registrierte Tauri-Plugins: `notification`, `dialog`, `shell`, `updater`,
`window-state`. Die Capability `default` (`capabilities/default.json`) gibt dem
`main`-Window unter anderem `updater:default` frei — der Updater ist damit
vorbereitet, aber in `tauri.conf.json` deaktiviert (siehe
[tauri-release.md](tauri-release.md)).
## Lesehinweise für den Betrieb
Ports, Umgebungsvariablen, Health-Checks und das Neustart-Verhalten des
Jetstream-Cursors stehen in [deployment.md](deployment.md).
+528
View File
@@ -0,0 +1,528 @@
# Deployment
Wie der Stack real betrieben wird: zwei Rust-Binaries (`pds-server`, `appview`),
zwei Postgres-Datenbanken und ein MinIO-Bucket-Paar. Der Tauri-Client wird
separat gebaut (siehe [tauri-release.md](tauri-release.md)), der Aufbau der
Komponenten steht in [architecture.md](architecture.md).
## Voraussetzungen
| Komponente | Version | Woher belegt |
|---|---|---|
| Rust-Toolchain | ≥ 1.80 | `rust-version` in `Cargo.toml` |
| Docker + Compose | aktuell | `docker-compose.yml` |
| PostgreSQL | 16 | `postgres:16-alpine` im Compose-File |
| MinIO | latest | `minio/minio` im Compose-File |
| Node + npm | Node 20, npm 10.8.2 | `packageManager` in `crates/tauri-app/package.json` — nur für den Desktop-Client nötig |
Ein C-Compiler/Linker wird für `rusqlite` (Feature `bundled`) und die
Crypto-Crates gebraucht.
## 1. Infrastruktur starten
```bash
docker compose up -d
```
Das Compose-File startet vier Container:
| Container | Dienst | Host-Port | Zugangsdaten (Default) |
|---|---|---|---|
| `maarcadetweet-pds-db` | Postgres für die PDS | `5434` → 5432 | `pds` / `pds`, DB `pds` |
| `maarcadetweet-appview-db` | Postgres für die AppView | `5435` → 5432 | `appview` / `appview`, DB `appview` |
| `maarcadetweet-minio` | S3-kompatibler Blob-Store | `9100` (API), `9101` (Konsole) | `minioadmin` / `minioadmin` |
| `maarcadetweet-minio-init` | Einmal-Job | — | legt die Buckets an und beendet sich |
`minio-init` legt `maarcadetweet-pds` und `maarcadetweet-appview` an und setzt
beide auf `anonymous download`. Das ist kein Zufall: `at-blob` liest Blobs über
ein einfaches `GET ${endpoint}/${bucket}/${key}` ohne Signatur.
Alle drei Langläufer haben Healthchecks (`pg_isready` bzw.
`/minio/health/live`), Zustand prüfen mit `docker compose ps`.
**Für Produktion ändern:** Die Passwörter im Compose-File sind Dev-Defaults und
stehen im Klartext. Wer den Stack öffentlich betreibt, ersetzt sie (und die
korrespondierenden Werte in `.env`) oder betreibt Postgres/MinIO ganz außerhalb
von Compose. Die Ports 5434/5435/9100/9101 sind auf allen Interfaces gemappt —
in Produktion auf `127.0.0.1:` binden.
## 2. Migrationen
**Migrationen laufen automatisch beim Start der Binaries.** Es gibt keinen
separaten Migrationsschritt und kein Skript in `scripts/` (das Verzeichnis ist
leer).
* `crates/pds-server/src/main.rs`: `sqlx::migrate!("../../migrations/pds").run(&db)`
* `crates/appview/src/main.rs`: `sqlx::migrate!("../../migrations/appview").run(&db)`
`sqlx::migrate!` ist ein Makro: die `.sql`-Dateien werden **zur Compile-Zeit in
das Binary eingebettet**. Konsequenzen für den Betrieb:
* Eine neue Migration wird erst nach einem Rebuild wirksam — das Binary allein
neu zu starten reicht nicht.
* Der Migrationsstand liegt in der Tabelle `_sqlx_migrations` der jeweiligen DB.
Wird eine bereits angewendete Datei nachträglich geändert, bricht der Start
mit einem Checksum-Fehler ab.
* Die Datenbanken müssen die Extensions `uuid-ossp`, `pgcrypto` (PDS) bzw.
`uuid-ossp`, `pg_trgm` (AppView) anlegen dürfen (`CREATE EXTENSION` in
`0001_init.sql`). Beim offiziellen Postgres-Image ist der Compose-User
Superuser; bei managed Postgres müssen die Extensions vorab freigeschaltet
sein.
* `migrations/appview/0001_init.sql` legt die Cursor-Zeile an
(`INSERT INTO jetstream_cursor (id, cursor) VALUES (1, 0)`) — ohne sie fällt
die AppView auf Cursor `0` zurück und startet ohne Resume.
Maßgeblich ist immer der Inhalt von `migrations/pds/` bzw.
`migrations/appview/` — beim Schreiben dieses Dokuments `0001_init` und
`0002_blob_mime` auf der PDS-Seite, `0001_init` bis `0008_notifications` auf
der AppView-Seite.
Die Dateinamen folgen dem `<version>_<name>.sql`-Schema von sqlx, ein manueller
Lauf mit `sqlx-cli` (`sqlx migrate run --source migrations/appview`) ist damit
prinzipiell kompatibel. `sqlx-cli` ist aber **nicht** Teil des Workspaces und
wird nirgends im Code oder in CI verwendet — der dokumentierte Weg bleibt der
automatische Lauf beim Start.
Im Code gibt es keine `sqlx::query!`-Makros (nur die Laufzeit-Varianten
`sqlx::query` / `query_as` / `query_scalar`). Für den Build wird also **keine**
erreichbare Datenbank und kein `.sqlx`-Offline-Cache gebraucht.
## 3. Umgebungsvariablen
`cp .env.example .env` und anpassen — das reicht für den Dev-Betrieb. Beide
Binaries rufen als erstes in `main()` `dotenvy::dotenv()` auf (`dotenvy` ist
Workspace-Dependency und in `crates/pds-server` wie `crates/appview`
eingebunden) und laden damit eine `.env` aus dem Arbeitsverzeichnis bzw. den
übergeordneten Verzeichnissen. **Echte Umgebungsvariablen gewinnen** über die
Werte aus der Datei; ist keine `.env` vorhanden, passiert schlicht nichts.
Im Serverbetrieb ist die Datei damit optional: systemd (`EnvironmentFile=`)
oder die Container-Runtime setzen die Variablen direkt, und sie überschreiben
eine versehentlich mitdeployte `.env`.
### Pflicht (fehlt eine, bricht `AppConfig::from_env()` mit `missing env: X` ab)
Quelle: `crates/at-shared/src/config.rs`. Beide Binaries lesen dieselbe
`AppConfig`, also braucht **auch die AppView die PDS-Variablen** und umgekehrt.
| Variable | Bedeutung |
|---|---|
| `PDS_HOST` | Listen-Adresse der PDS (`format!("{host}:{port}")``TcpListener::bind`) |
| `PDS_PORT` | Listen-Port der PDS, `u16` |
| `PDS_PUBLIC_URL` | Öffentliche Basis-URL der PDS. Geht in den DID-Doc-`serviceEndpoint` (`routes/auth.rs`), in den JWT-`iss` (`jwt_issuer.rs`), als `public_base` in den Blob-Store und als Fallback für `PDS_INTERNAL_URL` |
| `PDS_HANDLE_DNS_ZONE` | Handle-Suffix inkl. führendem Punkt, z. B. `.maarcadetweet.local`. Wird in `com.atproto.identity.resolveHandle` beim Suffix-Matching benutzt und (ohne Punkt) als `availableUserDomains` in `describeServer` gemeldet |
| `PDS_JWT_SECRET` | **Hex-String** (≥ 32 Bytes, gültiger P-256-Skalar), aus dem der Serverschlüssel für die Access-/Refresh-JWTs abgeleitet wird — siehe Fallstricke unten |
| `APPVIEW_HOST` | Listen-Adresse der AppView |
| `APPVIEW_PORT` | Listen-Port der AppView, `u16` |
| `APPVIEW_PUBLIC_URL` | Öffentliche Basis-URL der AppView; die PDS benutzt sie als Fallback-Ziel für den Ingest-Push |
| `JETSTREAM_URL` | WebSocket-URL des Jetstream-Relays |
| `JETSTREAM_COLLECTIONS` | Komma-separierte Liste der Collections im `subscribe`-Filter. Leere Einträge werden verworfen |
| `DATABASE_URL_PDS` | Postgres-DSN der PDS-DB |
| `DATABASE_URL_APPVIEW` | Postgres-DSN der AppView-DB |
| `S3_ENDPOINT` | Basis-URL des Blob-Stores (MinIO) |
| `S3_REGION` | Region-String; wird im aktuellen `at-blob` nur mitgeführt, nicht signiert |
| `S3_ACCESS_KEY` | MinIO-Access-Key |
| `S3_SECRET_KEY` | MinIO-Secret-Key |
| `S3_BUCKET_PDS` | Bucket, in den die PDS Blobs schreibt |
| `S3_BUCKET_APPVIEW` | Bucket für die AppView — Pflichtfeld in der Config, wird aber von keinem Code-Pfad gelesen |
| `PLC_DIRECTORY_URL` | PLC-Directory. Die PDS submittet dort neue `did:plc:`-Ops (best effort), die AppView löst darüber Handles auf |
### Optional
| Variable | Default | Bedeutung |
|---|---|---|
| `PDS_INTERNAL_URL` | `PDS_PUBLIC_URL` | Cluster-interne PDS-URL, die die AppView für ihren `PdsHandleResolver` nutzt |
| `APPVIEW_INTERNAL_URL` | `APPVIEW_PUBLIC_URL` | Cluster-interne AppView-URL für den Commit-Push der PDS (`crates/pds-server/src/state.rs`) |
| `APPVIEW_INGEST_SECRET` | unset | Shared Secret für `POST /internal/ingest-commit`. Unset = Dev-Modus, der Endpoint akzeptiert anonyme Requests. Gesetzt = Header `X-Ingest-Secret` muss (konstantzeitverglichen) passen. Muss auf **beiden** Seiten identisch gesetzt sein |
| `APPVIEW_HANDLE_SYNC_INTERVAL_SECS` | `300` | Intervall des Handle-Sync-Workers. Nicht parsbare Werte fallen still auf den Default zurück |
| `RUST_LOG` | `info` | `tracing_subscriber::EnvFilter::try_from_default_env()` in beiden Binaries und im Tauri-Client |
### Nur für den Desktop-Client
Gelesen in `crates/tauri-app/src-tauri/src/lib.rs`:
| Variable | Default |
|---|---|
| `MAARCADETWEET_PDS_URL` | `http://127.0.0.1:2583` |
| `MAARCADETWEET_APPVIEW_URL` | `http://127.0.0.1:2584` |
### Nur für Tests
Die Integrationstests in `crates/appview/tests/` und
`crates/pds-server/tests/` lesen `DATABASE_URL_APPVIEW` bzw. `DATABASE_URL_PDS`
direkt und überspringen sich selbst, wenn die Variable fehlt.
### Fallstricke
`.env.example` deckt inzwischen alle Variablen ab, die der Code liest — auch
die optionalen (`PDS_INTERNAL_URL`, `APPVIEW_INTERNAL_URL`,
`APPVIEW_HANDLE_SYNC_INTERVAL_SECS`, `APPVIEW_INGEST_SECRET`,
`MAARCADETWEET_PDS_URL`, `MAARCADETWEET_APPVIEW_URL`), die dort auskommentiert
mit ihrem Default stehen. Zwei Punkte bleiben trotzdem erklärungsbedürftig:
1. **`PDS_JWT_SECRET` ist kein beliebiger String, sondern ein P-256-Skalar.**
`jwt_issuer.rs::server_p256_keypair` macht
`hex::decode(secret.trim_start_matches("0x"))`, verlangt ≥ 32 Bytes und
gibt die ersten 32 Bytes an `p256::SecretKey::from_bytes()`. Der Wert muss
also **Hex sein und ein gültiger Skalar der P-256-Kurve** — lauter Nullen
(oder ein Wert ≥ Gruppenordnung) wird abgelehnt.
Der Fehler zeigt sich spät und breit: Der Prozess **startet normal**, weil
der Schlüssel erst beim ersten Zugriff abgeleitet wird. Danach antwortet
aber alles mit `500`, was `server_p256_public_multibase` bzw.
`server_p256_keypair` benutzt — nicht nur
`createAccount` / `createSession` / `refreshSession` (`routes/auth.rs`),
sondern auch jeder Record-Write, weil dort der Access-Token gegen genau
diesen Serverschlüssel verifiziert wird: `routes/repo.rs`,
`routes/feed.rs`, `routes/blob.rs`, `routes/profile.rs`.
`.env.example` enthält einen gültigen Zufallswert als Beispiel. Für jede
echte Instanz einen eigenen erzeugen:
```bash
openssl rand -hex 32
```
2. **`S3_BUCKET_APPVIEW` ist Pflicht, aber ungenutzt.** `AppConfig` verlangt
die Variable, kein Code-Pfad greift auf `cfg.s3_bucket_appview` zu. Der
Wert muss trotzdem gesetzt sein, sonst startet keins der Binaries.
(In `.env.example` ist das entsprechend vermerkt.)
Beachtenswert bleibt außerdem `JETSTREAM_COLLECTIONS`: Die Liste in
`.env.example` enthält jetzt alle Collections, die der Indexer kennt —
`app.twi.post` (das eigene 160-Zeichen-Lexicon, `POST_COLLECTIONS` in
`crates/appview/src/indexer.rs`), `app.bsky.feed.post`, `app.bsky.feed.like`,
`app.bsky.feed.repost`, `app.bsky.graph.follow` und
`app.bsky.actor.profile`. Wird `app.twi.post` daraus entfernt, tauchen eigene
Posts nur noch über den direkten PDS→AppView-Push auf, nicht über den
Firehose-Weg.
## 4. Release-Build
```bash
cargo build --release -p pds-server -p appview
```
Ergebnis: `target/release/pds-server` und `target/release/appview`. Das
Release-Profil im Workspace-`Cargo.toml` setzt `lto = "thin"`,
`codegen-units = 1`, `opt-level = 3`, `strip = true` — die Binaries sind ohne
Debug-Symbole, für Crash-Analysen also vorher separat sichern.
Da die Migrationen einkompiliert werden, gehören `migrations/` und die
`lexicons/*.json` (via `include_str!` in `crates/pds-server/src/state.rs`) zum
Build-Input, nicht zum Laufzeit-Deployment. Auf dem Zielhost muss nur das
Binary liegen.
## 5. systemd
Beispiel für einen Host, auf dem die Binaries unter `/opt/maarcadetweet/bin`
liegen und `/etc/maarcadetweet/env` die Variablen aus Abschnitt 3 enthält
(Modus `0600`, wegen `PDS_JWT_SECRET` und `APPVIEW_INGEST_SECRET`).
`/etc/systemd/system/maarcadetweet-pds.service`:
```ini
[Unit]
Description=maarcadetweet PDS
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=maarcadetweet
Group=maarcadetweet
EnvironmentFile=/etc/maarcadetweet/env
ExecStart=/opt/maarcadetweet/bin/pds-server
Restart=on-failure
RestartSec=5
# Logs gehen über tracing auf stdout/stderr ins Journal
StandardOutput=journal
StandardError=journal
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
[Install]
WantedBy=multi-user.target
```
`/etc/systemd/system/maarcadetweet-appview.service`:
```ini
[Unit]
Description=maarcadetweet AppView
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=maarcadetweet
Group=maarcadetweet
EnvironmentFile=/etc/maarcadetweet/env
ExecStart=/opt/maarcadetweet/bin/appview
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
[Install]
WantedBy=multi-user.target
```
Hinweise:
* Beide Units brauchen dieselbe `EnvironmentFile` — beide Binaries bauen die
vollständige `AppConfig`.
* `dotenvy::dotenv()` sucht die `.env` ab dem Arbeitsverzeichnis aufwärts.
Ohne `WorkingDirectory=` ist das unter systemd `/`, es wird also keine
gefunden — die `EnvironmentFile` ist die einzige Quelle. Läge doch eine
`.env` im Arbeitsverzeichnis, hätten die systemd-Variablen trotzdem Vorrang.
* `Type=simple` ist korrekt: keins der Binaries forkt oder meldet Readiness.
Eine `Type=notify`-Integration existiert nicht.
* Es gibt **keinen** Signal-Handler für graceful Shutdown. `systemctl stop`
beendet den Prozess hart; bei der AppView bedeutet das, dass der letzte
Cursor-Flush nur passiert, wenn der Kanal regulär geschlossen wird —
praktisch also mit bis zu 100 Events Verlust (siehe Abschnitt 8). Das ist
unkritisch, weil der Cursor beim Resume ohnehin leicht in die Vergangenheit
zeigt und Events idempotent verarbeitet werden.
* Eine Abhängigkeit `After=` auf Postgres/MinIO ist nur nötig, wenn diese auf
demselben Host laufen. Die AppView bricht beim Start ab, wenn ihre DB nicht
erreichbar ist (`PgPool::connect` ohne Retry); die PDS nutzt
`PgPoolOptions` mit `acquire_timeout(10s)`, bricht aber ebenfalls ab, wenn
der erste Connect scheitert. `Restart=on-failure` fängt das ab.
## 6. Reverse-Proxy
### PDS
Die PDS muss öffentlich erreichbar sein, damit andere AT-Protocol-Dienste und
der Client sie ansprechen können. Routen aus `crates/pds-server/src/main.rs`:
```
GET /
GET /healthz
GET /xrpc/com.atproto.server.describeServer
POST /xrpc/com.atproto.server.createAccount
POST /xrpc/com.atproto.server.createSession
POST /xrpc/com.atproto.server.refreshSession
POST /xrpc/com.atproto.identity.resolveHandle
POST /xrpc/com.atproto.repo.createRecord
POST /xrpc/com.atproto.repo.deleteRecord
POST /xrpc/com.atproto.feed.like.create
POST /xrpc/com.atproto.uploadBlob
GET /xrpc/com.atproto.sync.getRepo
GET /xrpc/com.atproto.sync.getBlocks
GET /xrpc/com.atproto.sync.getLatestCommit
GET /xrpc/com.atproto.sync.getRecord
GET /xrpc/com.atproto.sync.listRepos
GET /xrpc/com.atproto.sync.getBlob
GET /xrpc/app.bsky.actor.profile.get
POST /xrpc/app.bsky.actor.profile.set
GET /blob/:cid
```
nginx-Skizze:
```nginx
server {
listen 443 ssl http2;
server_name pds.example.org;
# TLS-Konfiguration hier
location / {
proxy_pass http://127.0.0.1:2583;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# uploadBlob: MAX_BLOB_SIZE in routes/blob.rs ist 1 MiB.
# Der Proxy darf nicht enger sein, sonst kommt statt der
# XRPC-Fehlerantwort ein nginx-413 beim Client an.
client_max_body_size 1m;
}
}
```
`PDS_PUBLIC_URL` muss auf genau diese öffentliche URL zeigen — der Wert landet
im DID-Doc-`serviceEndpoint`, im JWT-`iss` und als Basis der Blob-URLs.
**Noch offen:** Die PDS liefert selbst **kein** `/.well-known/did.json` und
kein `/.well-known/atproto-did` aus (im Router nicht vorhanden). `at-identity`
kann solche Dokumente *auflösen*, aber wer `did:web`-Handles auf dieser PDS
betreiben will, muss die Dateien vorerst statisch über den Proxy ausliefern.
Ebenfalls offen: `describeServer` gibt die DID hart als
`did:web:pds.maarcadetweet.local` zurück, unabhängig von `PDS_PUBLIC_URL`.
### AppView
Die AppView setzt ihr CORS selbst — `crates/appview/src/routes.rs`:
```rust
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
```
Also `Access-Control-Allow-Origin: *` für alle Routen, inklusive
`/internal/ingest-commit`. Der Grund steht im Code: der Tauri-Webview ruft die
AppView von einem anderen Origin aus auf (`http://127.0.0.1:1430` im Dev,
`tauri://` / `asset://` im Bundle), und die Read-Endpoints tragen keine
Auth-Cookies.
Für ein öffentliches Deployment heißt das:
* `POST /internal/ingest-commit` gehört **nicht** ins Internet. Entweder im
Proxy blocken (`location /internal/ { deny all; }`) oder die AppView nur auf
dem internen Interface lauschen lassen und den Public-Vhost nur auf `/api/`
und `/healthz` mappen.
* `APPVIEW_INGEST_SECRET` setzen, sobald die AppView irgendwie erreichbar ist —
ohne das Secret ist der Ingest-Endpoint unauthentifiziert.
* Wer den Wildcard-Origin einschränken will, muss den Header im Proxy
überschreiben, nicht nur ergänzen — sonst stehen zwei Werte drin:
```nginx
location /api/ {
proxy_pass http://127.0.0.1:2584;
proxy_hide_header Access-Control-Allow-Origin;
add_header Access-Control-Allow-Origin "tauri://localhost" always;
}
location /internal/ { deny all; }
```
Eine Konfigurierbarkeit der erlaubten Origins über eine Umgebungsvariable gibt
es im Code **nicht** — das ist noch offen.
### CSP im Client
`tauri.conf.json` setzt `connect-src 'self' ipc: http://ipc.localhost`. Die
HTTP-Aufrufe an PDS/AppView laufen über den Rust-IPC-Layer
(`src-tauri/src/pds_client.rs`, `appview_client.rs`), nicht aus dem Webview —
die CSP muss also für neue Backend-URLs nicht angefasst werden.
## 7. Health-Checks und Logs
### PDS
```
GET /healthz → {"ok": true}
GET / → {"name":"maarcadetweet-pds","version":"0.1.0"}
```
`/healthz` ist ein reiner Liveness-Probe ohne DB-Zugriff — ein `200` bedeutet
nicht, dass Postgres erreichbar ist.
Beim Start pingt die PDS zusätzlich MinIO (`S3BlobStore::ping`, ein `HEAD` auf
`${S3_ENDPOINT}/${S3_BUCKET_PDS}`; `2xx` und `404` gelten als erreichbar). Ein
Fehlschlag ist **kein** Startabbruch, sondern eine Warnung:
```
s3 ping failed at startup; uploadBlob will serve from local blockstore only
```
### AppView
```
GET /healthz → {"ok":true,"lag_ms":…,"events_processed":…,"jetstream_connected":…}
GET / → {"name":"maarcadetweet-appview","version":"0.1.0"}
```
Diese drei Felder sind der eigentliche Monitoring-Hook:
* `jetstream_connected``false` heißt: WebSocket ist weg, der Consumer
reconnectet mit Backoff (1 s, verdoppelnd bis `max_backoff_secs = 30`).
* `events_processed` — monoton steigender Zähler. Stagniert er bei
`jetstream_connected: true`, kommt nichts an.
* `lag_ms` — Differenz zwischen lokaler Uhr und `time_us` des letzten Events,
auf `0` geklemmt, wenn negativ. `0` heißt also auch „noch kein Event gesehen".
### Logs
Beide Binaries loggen über `tracing_subscriber::fmt()` mit
`EnvFilter::try_from_default_env()` auf stdout/stderr, Fallback-Filter `info`.
Unter systemd landet alles im Journal:
```bash
journalctl -u maarcadetweet-pds -f
journalctl -u maarcadetweet-appview -f
```
Empfehlung für den Betrieb (aus `.env.example` abgeleitet, `sqlx` sonst sehr
gesprächig):
```
RUST_LOG=info,sqlx=warn
```
Log-Zeilen, auf die es sich lohnt zu achten:
| Meldung | Bedeutung |
|---|---|
| `jetstream error: …` / `reconnecting in Ns` | Firehose-Verbindung verloren |
| `apply_commit failed; NOT advancing cursor` | Indexer-Fehler, Event wird beim nächsten Replay erneut versucht |
| `cursor flush failed` | Cursor-Schreibvorgang auf die DB schlug fehl |
| `s3 ping failed at startup` | MinIO beim PDS-Start nicht erreichbar |
| `plc submit failed (dev ok)` | PLC-Directory nicht erreichbar; die DID bleibt lokal gültig, ist aber global nicht registriert |
## 8. Neustart-Verhalten
**PDS.** Zustandslos bis auf Postgres und MinIO. Der In-Memory-Blockstore
(`MemoryBlockstore` in `state.rs`) wird beim Start neu aufgebaut; persistent
sind `repo_blocks`, `repos`, `users`, `blobs`, `sessions`, `plc_ops` in der
PDS-DB. Migrationen laufen bei jedem Start (idempotent über
`_sqlx_migrations`).
**AppView — Jetstream-Cursor.** Der Fortschritt liegt in der Tabelle
`jetstream_cursor` (genau eine Zeile, `id = 1`, Spalte `cursor` =
Mikrosekunden seit Epoch). Der Ablauf:
1. Beim Start liest `indexer::cursor_get` die Zeile. Ist der Wert `> 0`, wird
der Consumer mit `with_cursor(...)` gebaut und schickt `cursor` im
`subscribe`-Options-Frame an Jetstream — die Verbindung setzt dort fort
statt am Live-Ende. Bei `0` startet er ohne Cursor.
2. Der Handler zählt jedes Event. Nur jedes 100. Event schickt er einen Tick
in einen `mpsc`-Kanal (Kapazität 32, `try_send` — bei vollem Kanal wird der
Tick verworfen).
3. Ein Hintergrund-Task sammelt die Ticks und schreibt alle 500 ms das Maximum
per `UPDATE jetstream_cursor SET cursor = GREATEST(cursor, $1)`. Der Cursor
kann dadurch nie zurückspringen.
4. Schlägt `apply_commit` fehl, wird der Cursor für dieses Event **nicht**
vorgerückt — der Reconnect liefert es erneut.
Praktische Folge: Nach einem Neustart werden bis zu ~100 Events (plus die
letzten 500 ms) erneut verarbeitet. Alle Indexer-Schreibpfade sind als Upserts
gebaut, das Replay ist also unschädlich. Umgekehrt gilt: War die AppView länger
weg als Jetstreams Backfill-Fenster, sind die Events dazwischen verloren; ein
Backfill-Werkzeug dafür existiert nicht (noch offen).
**Eigene Posts sind vom Cursor unabhängig.** Die PDS pusht jeden lokalen Commit
direkt an `POST /internal/ingest-commit`. Der Push ist best effort mit 5 s
Timeout und blockiert den Record-Write nie — fällt er aus, kommt der Record
später über Jetstream (sofern die Collection in `JETSTREAM_COLLECTIONS` steht,
siehe Abschnitt 3).
**Handle-Sync.** Läuft als eigener Task alle
`APPVIEW_HANDLE_SYNC_INTERVAL_SECS` Sekunden und füllt leere `posts.handle`
nach — zuerst über die lokale PDS (`PdsHandleResolver`, 2 s Timeout), dann PLC
bzw. `did:web`. Nach einem Neustart holt der erste Durchlauf das nach; der
Zustand ist reine Anzeigekosmetik.
## 9. Was noch offen ist
* Kein Compose-Service für `pds-server` / `appview` — das Compose-File deckt nur
Postgres und MinIO ab. Es gibt kein Dockerfile im Repo.
* `at-blob` spricht ausschließlich MinIO ohne Signature V4 (siehe Modul-Doku in
`crates/at-blob/src/s3.rs`); echtes AWS S3 funktioniert damit nicht.
* Kein Graceful-Shutdown, keine Readiness- (im Unterschied zur Liveness-)Probe.
* Keine konfigurierbare CORS-Allowlist in der AppView.
* Kein `.well-known`-Handling in der PDS, `describeServer` liefert eine
hartkodierte DID.
* Kein Backfill-Werkzeug für Jetstream-Lücken.
+311
View File
@@ -0,0 +1,311 @@
# Tauri-Release und Auto-Update
Der Desktop-Client ist auf Auto-Update vorbereitet, aber nicht scharf
geschaltet. Dieses Dokument beschreibt den vollständigen Weg von den
Signing-Keys bis zum ausgelieferten `latest.json` — und was dafür im Repo noch
fehlt.
Alle Pfade sind relativ zu `crates/tauri-app/`, sofern nicht anders angegeben.
## Ist-Zustand
| Baustein | Stand |
|---|---|
| Rust-Plugin | `tauri_plugin_updater::Builder::new().build()` ist in `src-tauri/src/lib.rs` registriert |
| Capability | `src-tauri/capabilities/default.json` enthält `updater:default``allow-check`, `allow-download`, `allow-install`, `allow-download-and-install` für das `main`-Window |
| Config | `src-tauri/tauri.conf.json`, Block `plugins.updater`: leerer `pubkey`, Platzhalter-Endpoint `https://releases.maarcadetweet.local/{{target}}/{{arch}}/{{current_version}}` |
| Updater-Artefakte | `bundle.createUpdaterArtifacts` ist **nicht** gesetzt (Default: aus) — der Build erzeugt keine `.sig`-Dateien und kein `.app.tar.gz` |
| Aufruf im Client | **Fehlt.** Weder das npm-Paket `@tauri-apps/plugin-updater` noch ein `check()`-Aufruf existieren (weder in `src/` noch in `src-tauri/src/`) |
| CLI | `@tauri-apps/cli` ist als devDependency installiert, lokal geprüft: `npx tauri --version``tauri-cli 2.11.4` |
| Plugin-Version | `tauri-plugin-updater` 2.10.1 laut `src-tauri/Cargo.lock` |
**Wichtig:** Die Felder `active` und `dialog` in `plugins.updater` stammen aus
Tauri v1. Der v2-Updater kennt sie nicht — seine `Config` besteht aus
`endpoints`, `pubkey`, `windows`, `dangerousInsecureTransportProtocol`,
`dangerousAcceptInvalidCerts`, `dangerousAcceptInvalidHostnames`. Unbekannte
Schlüssel werden beim Deserialisieren still ignoriert. `"active": false`
schaltet den Updater also **nicht** ab; dass heute nichts passiert, liegt
allein daran, dass niemand `check()` aufruft. Wer den Updater wirklich
deaktivieren will, entfernt den `plugins.updater`-Block und die
`tauri_plugin_updater`-Registrierung.
## 1. Signing-Keys erzeugen
Der Updater akzeptiert nur signierte Artefakte. Das Schlüsselpaar wird einmal
erzeugt und danach nie wieder gewechselt — ein neuer Public Key macht alle
installierten Clients update-unfähig.
```bash
cd crates/tauri-app
npx tauri signer generate -w ~/.tauri/maarcadetweet.key
```
Optionen laut `npx tauri signer generate --help`:
| Flag | Bedeutung |
|---|---|
| `-w, --write-keys <PFAD>` | Privaten Schlüssel in eine Datei schreiben (sonst nur stdout) |
| `-p, --password <PW>` | Passwort für den privaten Schlüssel setzen |
| `-f, --force` | Vorhandene Datei überschreiben |
| `--ci` | Keine interaktiven Rückfragen |
Ergebnis: `~/.tauri/maarcadetweet.key` (privat, **niemals** ins Repo) und
`~/.tauri/maarcadetweet.key.pub` (öffentlich, base64). Der Inhalt der
`.pub`-Datei ist genau das, was in `pubkey` gehört.
Für den Build-Rechner bzw. die CI:
```bash
export TAURI_SIGNING_PRIVATE_KEY="$(cat ~/.tauri/maarcadetweet.key)"
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="…"
```
`TAURI_SIGNING_PRIVATE_KEY` akzeptiert den Schlüsselinhalt oder einen Pfad.
Fehlt die Variable beim Build mit aktivierten Updater-Artefakten, bricht das
Bundling ab.
## 2. Konfiguration — ohne den Dev-Zustand zu brechen
Die laufende `src-tauri/tauri.conf.json` soll unangetastet bleiben (leerer
`pubkey`, keine Updater-Artefakte). Die CLI kann Konfigurationen zur Build-Zeit
zusammenführen:
```
-c, --config <CONFIG> JSON strings or paths to JSON, JSON5 or TOML files to
merge with the default configuration file
```
Der saubere Weg ist deshalb eine **Release-Overlay-Datei**, die nur die
Release-Unterschiede enthält. Anlegen als
`crates/tauri-app/src-tauri/tauri.release.conf.json` (existiert noch nicht):
```json
{
"bundle": {
"createUpdaterArtifacts": true
},
"plugins": {
"updater": {
"endpoints": [
"https://releases.example.org/maarcadetweet/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6…HIER_DER_INHALT_VON_.key.pub…",
"windows": {
"installMode": "passive"
}
}
}
}
```
Build damit:
```bash
cd crates/tauri-app
npx tauri build --config src-tauri/tauri.release.conf.json
```
Feldbedeutungen (geprüft an `tauri-plugin-updater` 2.11 / `tauri-utils` 2.9):
| Feld | Bedeutung |
|---|---|
| `bundle.createUpdaterArtifacts` | `true` erzeugt die Updater-Artefakte (`.sig`, unter macOS zusätzlich das `.app.tar.gz`). Alternativ `"v1Compatible"` für v1-Clients |
| `plugins.updater.pubkey` | Public Key aus Schritt 1. Pflichtfeld — fehlt es ganz, scheitert das Deserialisieren der Plugin-Config |
| `plugins.updater.endpoints` | Liste von URLs, der Reihe nach probiert. Müssen `https` sein: in Release-Builds bricht ein `http`-Endpoint mit `InsecureTransportProtocol` ab (im Debug-Build nur eine Warnung), es sei denn `dangerousInsecureTransportProtocol` ist gesetzt |
| `plugins.updater.windows.installMode` | `passive` (Default), `basicUi` oder `quiet` |
### Endpoint-Platzhalter
In der Endpoint-URL werden ersetzt: `{{current_version}}`, `{{target}}`,
`{{arch}}` und `{{bundle_type}}`. Achtung auf einen Fallstrick — der bereits in
der Config stehende Platzhalter-Endpoint benutzt genau diese Form:
* `{{target}}` wird in der **URL** nur durch das Betriebssystem ersetzt
(`linux`, `darwin`, `windows`) — nicht durch `darwin-aarch64`.
* `{{arch}}` liefert die Architektur (`x86_64`, `aarch64`, `i686`, `armv7`,
`riscv64`).
* `{{bundle_type}}` liefert den laufenden Bundle-Typ: `app`, `msi`, `nsis`,
`appimage`, `deb` oder `rpm` — bzw. `unknown`, wenn er nicht bestimmbar ist.
Ein statisches `latest.json` (eine Datei für alle Plattformen) ist der
einfachere Weg und kommt ohne Platzhalter aus.
## 3. `latest.json`
Der Updater versteht zwei Formate. Für einen einfachen Static-File-Server ist
das **statische Format** richtig:
```json
{
"version": "0.2.0",
"notes": "Thread-Ansicht, schnellere Timeline",
"pub_date": "2026-01-15T10:00:00Z",
"platforms": {
"darwin-aarch64": {
"signature": "<Inhalt von maarcadetweet.app.tar.gz.sig>",
"url": "https://releases.example.org/maarcadetweet/0.2.0/maarcadetweet_aarch64.app.tar.gz"
},
"darwin-x86_64": {
"signature": "…",
"url": "https://releases.example.org/maarcadetweet/0.2.0/maarcadetweet_x64.app.tar.gz"
},
"linux-x86_64": {
"signature": "…",
"url": "https://releases.example.org/maarcadetweet/0.2.0/maarcadetweet_0.2.0_amd64.AppImage"
},
"windows-x86_64": {
"signature": "…",
"url": "https://releases.example.org/maarcadetweet/0.2.0/maarcadetweet_0.2.0_x64-setup.exe"
}
}
}
```
Regeln, die der Plugin-Code vorgibt:
* **Schlüssel der `platforms`-Map:** Der Updater sucht in dieser Reihenfolge
nach `{os}-{arch}-{installer}` und danach nach `{os}-{arch}`.
`os``linux` | `darwin` | `windows`, `arch``i686` | `x86_64` | `armv7` |
`aarch64` | `riscv64`, `installer``appimage` | `deb` | `rpm` | `app` |
`msi` | `nsis`. Die Kurzform `darwin-aarch64` reicht also; die lange Form
`windows-x86_64-nsis` ist nur nötig, wenn man MSI und NSIS gleichzeitig
ausliefert.
* **`version`** wird als Semver geparst. Ein Update wird nur angeboten, wenn
`release.version > current_version` — die Version kommt aus dem Feld
`version` in `tauri.conf.json` (heute `0.1.0`). Ein Release ohne
Versionsbump wird stillschweigend ignoriert.
* **`signature`** ist der **Inhalt** der zugehörigen `.sig`-Datei, nicht deren
URL.
* **`notes`** und **`pub_date`** sind optional; `pub_date` als RFC 3339.
* Ein dynamischer Endpoint darf mit **`204 No Content`** antworten, wenn es
kein Update gibt. Dann liefert er statt der `platforms`-Map ein flaches
Objekt mit `version`, `url` und `signature` für genau die anfragende
Plattform.
## 4. Build pro Plattform
`tauri build` ist ein Cross-Build nur eingeschränkt — jede Plattform wird auf
ihrem eigenen Host (oder Runner) gebaut.
```bash
cd crates/tauri-app
# macOS (Apple Silicon)
npx tauri build --config src-tauri/tauri.release.conf.json
# macOS Universal (beide Rust-Targets müssen installiert sein)
npx tauri build --target universal-apple-darwin \
--config src-tauri/tauri.release.conf.json
# Windows / Linux jeweils auf dem passenden Host, gleicher Aufruf
```
Ablauf laut `build.beforeBuildCommand` in `tauri.conf.json`: erst
`npm run build` (Vite → `crates/tauri-app/dist`, entspricht `frontendDist:
"../dist"`), dann der Cargo-Release-Build, dann das Bundling.
`bundle.targets` steht auf `"all"`.
Nützliche Flags (aus `npx tauri build --help`):
| Flag | Zweck |
|---|---|
| `-t, --target <TRIPLE>` | Ziel-Triple, oder `universal-apple-darwin` |
| `-b, --bundles <LISTE>` | Nur bestimmte Bundle-Typen erzeugen |
| `--no-bundle` | Nur das Binary, kein Installer |
| `-c, --config <PFAD>` | Config-Overlay (siehe oben) |
| `--ci` | Keine interaktiven Rückfragen |
## 5. Wo die Artefakte landen
`crates/tauri-app/src-tauri/Cargo.toml` deklariert ein eigenes `[workspace]`,
der Build-Output liegt deshalb **nicht** im Repo-Root-`target/`, sondern unter
`crates/tauri-app/src-tauri/target/release/bundle/`. Mit `--target <triple>`
schiebt sich das Triple dazwischen:
`src-tauri/target/<triple>/release/bundle/`.
| Plattform | Pfad unter `bundle/` | Für den Updater relevant |
|---|---|---|
| macOS | `macos/maarcadetweet.app` | `macos/maarcadetweet.app.tar.gz` + `.tar.gz.sig` |
| macOS | `dmg/maarcadetweet_<version>_<arch>.dmg` | nein (nur für die Erstinstallation) |
| Windows | `msi/maarcadetweet_<version>_<arch>_<lang>.msi` | `.msi` + `.msi.sig` |
| Windows | `nsis/maarcadetweet_<version>_<arch>-setup.exe` | `.exe` + `.exe.sig` |
| Linux | `appimage/maarcadetweet_<version>_<arch>.AppImage` | `.AppImage` + `.AppImage.sig` |
| Linux | `deb/…deb`, `rpm/…rpm` | nein |
Die `.sig`-Dateien entstehen nur, wenn `createUpdaterArtifacts` aktiv ist und
die Signing-Env-Variablen gesetzt sind. Für das `latest.json` wird der
**Dateiinhalt** der `.sig` gebraucht, das Artefakt selbst wird nicht
ausgeliefert.
## 6. Was im Client noch fehlt
Config und Signaturen allein bewirken nichts — der Client fragt nirgends nach
Updates. Es gibt zwei Wege:
### a) Vom Frontend aus
```bash
cd crates/tauri-app
npm install @tauri-apps/plugin-updater @tauri-apps/plugin-process
```
```ts
import { check } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
const update = await check();
if (update) {
await update.downloadAndInstall();
await relaunch();
}
```
Die Capability erlaubt `check`, `download`, `install` und
`downloadAndInstall` bereits. **Zusätzlich nötig:** `tauri-plugin-process` als
Rust-Dependency, `tauri_plugin_process::init()` in `lib.rs` und
`"process:default"` in `capabilities/default.json` — sonst schlägt `relaunch()`
fehl.
### b) Vom Rust-Layer aus
```rust
use tauri_plugin_updater::UpdaterExt;
if let Ok(Some(update)) = app.updater()?.check().await {
update.download_and_install(|_, _| {}, || {}).await?;
}
```
Ohne npm-Abhängigkeit, dafür ohne UI-Kontrolle im Frontend. Der Aufruf gehört
in den `setup`-Hook oder hinter ein Tauri-Command, das die Svelte-Seite
aufrufen kann.
Welcher Weg genommen wird, ist **noch offen** — im Repo existiert keiner von
beiden.
## 7. Release-Checkliste
1. `version` in `src-tauri/tauri.conf.json` erhöhen (Semver; der Vergleich
entscheidet, ob ein Update angeboten wird). `package.json` und
`src-tauri/Cargo.toml` der Konsistenz halber mitziehen.
2. Auf jedem Plattform-Host mit gesetzten `TAURI_SIGNING_PRIVATE_KEY*`-Variablen
und dem Release-Overlay bauen.
3. Installer und Updater-Artefakte einsammeln, `.sig`-Inhalte auslesen.
4. `latest.json` schreiben und zusammen mit den Artefakten unter einer
`https`-URL veröffentlichen. Die URL muss zum `endpoints`-Eintrag passen.
5. Mit einer älteren installierten Version gegenprüfen, dass `check()` das
Update findet und die Signaturprüfung durchgeht.
## 8. Offene Punkte
* Kein Release-Overlay im Repo — die Datei aus Abschnitt 2 muss angelegt
werden. Der Endpoint `https://releases.maarcadetweet.local/…` in der aktuellen
Config ist ein Platzhalter und existiert nicht.
* Kein Update-Server, kein CI-Workflow, kein Skript, das `latest.json` erzeugt
(`scripts/` ist leer).
* Keine Code-Signierung/Notarisierung für macOS und keine Authenticode-Signatur
für Windows konfiguriert (`bundle` enthält weder `macOS.signingIdentity` noch
`windows.certificateThumbprint`). Der Tauri-Updater-Schlüssel ersetzt das
nicht — ohne Notarisierung meldet Gatekeeper die App als nicht verifiziert.
* Die Felder `active` und `dialog` in `plugins.updater` sind v1-Reste ohne
Wirkung (siehe oben) und sollten beim Scharfschalten entfernt werden.
+113
View File
@@ -0,0 +1,113 @@
-- AppView database schema 0008: notifications + follow-list pagination.
--
-- Why
--
-- Phase 5 shipped the read API (timeline / profile / search / post) but
-- the client had no way to learn that *someone else* interacted with
-- the user: a like, a repost, a follow or a reply never produced a
-- durable record. The Tauri client's tray/notification path (Phase 7)
-- therefore had nothing to poll. This migration adds the table the
-- Jetstream indexer writes into and the read API serves from.
--
-- Table shape
-- id BIGSERIAL — monotonic tiebreaker for the keyset
-- cursor. The API's opaque cursor is
-- `(indexed_at, id)`, mirroring the
-- `(indexed_at, uri)` pair used by /api/timeline/home,
-- so the same `routes::cursor` encoder is reused.
-- recipient_did the user who should SEE the notification (the post
-- author for like/repost/reply, the followed user for
-- follow).
-- author_did the user who CAUSED it (the liker / reposter /
-- follower / replier).
-- kind 'like' | 'repost' | 'follow' | 'reply'. Enforced by
-- a CHECK rather than a Postgres ENUM so adding a
-- variant later is an ALTER ... DROP/ADD CONSTRAINT
-- instead of a type migration that locks every
-- dependent object.
-- subject_uri the post the notification is *about*. NULL for
-- 'follow' (there is no post). For 'like'/'repost'
-- it's the liked/reposted post (the recipient's own
-- post); for 'reply' it's the REPLY itself, because
-- the interesting text to show in the notification
-- list is what the replier wrote, not what the
-- recipient already knows they posted.
-- created_at the interaction's own `createdAt` from the AT record.
-- indexed_at when WE saw it. This is what the list is ordered by,
-- for the same reason the timeline orders by
-- `posts.indexed_at`: a client-supplied `created_at`
-- can be arbitrarily far in the past or future and
-- would break keyset pagination.
-- read_at NULL = unread. Set in bulk by
-- `POST /api/notifications/seen`.
--
-- Deliberately NO `CHECK (recipient_did <> author_did)`
-- ------------------------------------------------------
-- Self-interactions must not produce notifications, and the indexer
-- enforces that in two places (a pure `should_notify` guard in Rust
-- plus a `WHERE $1 <> $2` in the INSERT ... SELECT). A CHECK would
-- turn a future slip into a constraint violation that aborts the
-- surrounding like/repost transaction — i.e. it would lose the *like*
-- because of a notification bug. Filtering is strictly better than
-- failing here.
--
-- Idempotency
-- -----------
-- `notifications_dedupe_idx` is the unique constraint the indexer's
-- `ON CONFLICT ... DO NOTHING` infers. `subject_uri` is nullable and
-- NULLs never collide in a plain unique index, so the index is on
-- `COALESCE(subject_uri, '')` — that makes the two follow rows
-- (subject_uri IS NULL) for the same (recipient, author) pair collide
-- as intended.
--
-- Consequence worth knowing: unlike-then-relike (or unfollow-then-
-- refollow) does NOT produce a second notification, because the tuple
-- is identical. That is the desired behaviour — it makes notification
-- spam via toggling impossible — but it does mean a notification is
-- "once per (recipient, author, kind, subject)" for all time.
CREATE TABLE notifications (
id BIGSERIAL PRIMARY KEY,
recipient_did TEXT NOT NULL,
author_did TEXT NOT NULL,
kind TEXT NOT NULL
CHECK (kind IN ('like', 'repost', 'follow', 'reply')),
subject_uri TEXT,
created_at TIMESTAMPTZ NOT NULL,
indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
read_at TIMESTAMPTZ
);
-- Primary read path: `WHERE recipient_did = $1 ORDER BY indexed_at DESC,
-- id DESC`. The trailing `id DESC` makes the index cover the keyset
-- predicate `(indexed_at, id) < ($2, $3)` end-to-end, so a page fetch
-- never sorts.
CREATE INDEX notifications_recipient_indexed_at_idx
ON notifications (recipient_did, indexed_at DESC, id DESC);
-- Dedupe / ON CONFLICT target. See the "Idempotency" note above.
CREATE UNIQUE INDEX notifications_dedupe_idx
ON notifications (recipient_did, author_did, kind, COALESCE(subject_uri, ''));
-- `GET /api/notifications/count` is a hot poll from the client's tray
-- badge, so the unread slice gets its own partial index. It stays tiny
-- because rows leave it as soon as they're marked seen.
CREATE INDEX notifications_unread_idx
ON notifications (recipient_did)
WHERE read_at IS NULL;
-- =====================================================
-- follows: pagination indexes for the follower/following lists
-- =====================================================
--
-- `GET /api/followers` and `GET /api/following` page with the same
-- keyset scheme as the timeline: `(indexed_at, <other side's did>)`.
-- The pre-existing indexes cover only the equality half (the PK covers
-- `follower_did`, `follows_subject_idx` covers `subject_did`), which
-- leaves Postgres sorting the whole follower set on every page. These
-- two make both directions index-ordered.
CREATE INDEX IF NOT EXISTS follows_subject_indexed_at_idx
ON follows (subject_did, indexed_at DESC, follower_did DESC);
CREATE INDEX IF NOT EXISTS follows_follower_indexed_at_idx
ON follows (follower_did, indexed_at DESC, subject_did DESC);