Files
maarcadetweet/docs/deployment.md
T
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

22 KiB

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), der Aufbau der Komponenten steht in 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

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:

    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

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:

[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:

[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:

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:

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:
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_connectedfalse 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:

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.