Gemessen gegen die Dev-Instanz (3,3 Mio. Posts): * GET /api/profile/<handle> 9,5 s → 0,04 s * Cold-Start-Timeline 7,4 s → 0,006 s * Timeline mit 2300 Follows 28 s → 0,02 s Drei unabhängige Ursachen, alle drei ein Seq-Scan über die posts-Tabelle: 1. resolve_profile sucht die DID über profiles.LOWER(handle) und, als Fallback, über posts.handle. Für beides gab es keinen Index. Auf profiles hatte Migration 0007 genau diesen Index entfernt, mit der Begründung, jeder Aufrufer leite ohnehin zuerst eine DID ab — das stimmt nicht mehr, seit resolve_profile den profiles-Cache zuerst befragt. 2. Der Cold-Start-Feed filtert `collection IN (…)` und sortiert nach indexed_at. Der vorhandene (collection, indexed_at, uri)-Index taugt dafür nicht: mit zwei führenden Werten liefert er keine indexed_at-Ordnung mehr. Ein partieller Index über genau das Prädikat schiebt den Filter in die Definition und lässt (indexed_at DESC, uri DESC) als Sortierschlüssel übrig. 3. Genau dieser neue Index wurde dann zur Falle für den Graph-Zweig: der Planer sah einen Index, der schon in indexed_at-Ordnung liefert, und nahm an, er treffe früh genug auf n passende Zeilen — bei dünn besetzten Followees hieß "früh" 2,87 Mio. verworfene Zeilen. Je nach Anzahl bisheriger Ausführungen des Prepared Statements kippte er zwischen diesem und dem guten Plan, was intermittierend aussah. Der Graph-Zweig formuliert die Absicht jetzt aus: pro Followee die neuesten Posts über ein LATERAL, dann mergen. Damit ist der globale Scan kein wählbarer Plan mehr, und jede Iteration ist ein begrenzter Range-Scan auf posts_did_indexed_at_uri_idx. Korrekt ist das, weil die globalen Top-N immer eine Teilmenge der Vereinigung der Top-N je Followee sind — deshalb wird pro Followee limit+1 geholt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
maarcadetweet
AT-Protocol-PDS in Rust + AppView + Tauri/Svelte-Desktop-Client.
Posts sind auf 160 Zeichen limitiert (oldschool Twitter), erzwungen durch eigenes Lexicon app.twi.post.
Architektur
crates/
├── at-lexicon/ Lexicon-Schemas + 160-Char-Validierung
├── at-crypto/ k256, p256, CID, multibase, JWT, PLC-Ops, Repo-Signing
├── 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) + 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, 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, notifications, profiles, jetstream_cursor)
docs/ Deployment, Architektur, Tauri-Release (siehe unten)
Setup
# 1) Datenbanken + MinIO starten
docker compose up -d
# 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
cargo test --workspace
cargo check --workspace
# 4) Tauri-Frontend (Vite dev)
cd crates/tauri-app
npm install
npm run dev
# → http://127.0.0.1:1420
# 5) PDS / AppView (eigene Terminals)
cargo run -p pds-server
cargo run -p appview
Status
| Phase | Stand |
|---|---|
| 0 Foundation, Workspace, Migrations, Lexicon, Crypto | ✅ done |
| 1 Identity (PLC-Ops vollständig signieren) | ✅ done — did:plc: deterministisch aus signed op CID |
| 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, 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 inert (Production-Weg: docs/tauri-release.md) |
| 8 Social-Graph + Benachrichtigungen | ✅ done — notifications-Tabelle, Schreibpfad im Jetstream-Indexer (idempotent, keine Selbst-Notifications), `/api/notifications[/count |
Tests
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!)
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 |
Crate-Verantwortlichkeiten, Datenfluss PDS → Jetstream → AppView → Client, Tabellenübersicht |
docs/deployment.md |
Betrieb: docker compose, Migrationen, alle Env-Variablen, Release-Build, systemd-Units, Reverse-Proxy, Health-Checks |
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.subscribeReposfehlt) — eigene Records erreichen die AppView nur über den Best-Effort-PushPOST /internal/ingest-commit. - Die AppView-Leseschnittstelle hat keine Auth und CORS
Any; bei/api/notificationssind 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 (siehedocs/tauri-release.md). - Reply-Notifications gehen verloren, wenn die Antwort vor ihrem Parent indiziert wird (kein Nachlauf) — bei Jetstream möglich.
at-blobspricht MinIO ohne Signature V4 — echtes AWS S3 funktioniert damit nicht.- Die PDS liefert kein
.well-known/did.json;describeServergibt die DID hart alsdid:web:pds.maarcadetweet.localzurück.
Design
Orange Akzent, IBM Plex Mono, schwarzer Hintergrund mit 3%-Grid, Terminal-Fenster-Component mit blinkendem Cursor. Tokens sind 1:1 von maarcade-shell/landing/assets/css/tokens.css abgeleitet, plus zwei neue Repos-Tokens (--cid-fg, --rev-fg).