# 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 `_.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.