Bisher erreichten eigene Records die AppView nur über den Best-Effort-Push /internal/ingest-commit. Ging der verloren (AppView kurz weg, Netzwerk- fehler), war der Post dauerhaft weg: der öffentliche Jetstream kennt diese PDS nicht, es gab also keinen zweiten Weg. Jeder Commit schreibt sein Event in derselben Transaktion nach firehose_events. Damit kann es keinen Commit ohne Event geben — und keine Sequenz ohne Commit. Die seq muss lückenfrei sein, sonst ist sie als Cursor wertlos: BIGSERIAL vergibt Nummern bei INSERT, nicht bei COMMIT, also können zwei Schreiber 5 und 6 ziehen und in umgekehrter Reihenfolge sichtbar werden — ein Leser dazwischen sieht 6, merkt sich das und erfährt von 5 nie. Ein globaler pg_advisory_xact_lock unmittelbar vor dem INSERT erzwingt Commit-Reihenfolge == seq-Reihenfolge. Er wird nach dem per-Repo-FOR-UPDATE genommen, überall in derselben Reihenfolge, also ohne Deadlock-Risiko. Preis: das Ende jeder schreibenden Transaktion ist global serialisiert; das steht im Modulkopf. Der WebSocket-Handler abonniert den Broadcast, *bevor* er die Datenbank liest, und filtert Live-Events auf seq > Wasserstand. Aus einem Rennen wird so eine Dublette, die sich filtern lässt, statt einer Lücke, die es nicht gibt. Ein zu langsamer Consumer bekommt #info/OutdatedCursor und fällt auf den DB-Replay zurück, statt getrennt zu werden — die Events sind durabel, also ist der Rückfall verlustfrei. Frame-Hülle ist konformes DAG-CBOR mit Tag-42-Links (neues Modul dag_cbor, aus car.rs herausgezogen statt dupliziert). Die Blöcke darin behalten die Konvention dieses Repos: CIDs als Strings. Ein fremder Consumer liest die Frames, scheitert aber an den Blockinhalten — das zu ändern hieße, jede CID im System zu ändern, inklusive der did:plc-Ableitung. Steht so im Modulkopf. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
90 lines
3.9 KiB
Rust
90 lines
3.9 KiB
Rust
use crate::appview_push::AppViewPushClient;
|
|
use crate::firehose::Firehose;
|
|
use at_blob::S3BlobStore;
|
|
use at_identity::plc::PlcClient;
|
|
use at_lexicon::{Lex, LexRegistry};
|
|
use at_repo::blockstore::MemoryBlockstore;
|
|
use at_shared::config::AppConfig;
|
|
use sqlx::PgPool;
|
|
use std::sync::Arc;
|
|
|
|
#[derive(Clone)]
|
|
pub struct AppState {
|
|
pub cfg: AppConfig,
|
|
pub db: PgPool,
|
|
pub blob: S3BlobStore,
|
|
pub lex: Arc<LexRegistry>,
|
|
pub blockstore: Arc<MemoryBlockstore>,
|
|
pub plc: PlcClient,
|
|
pub appview: AppViewPushClient,
|
|
/// Live fan-out for `com.atproto.sync.subscribeRepos`.
|
|
///
|
|
/// Lives on the shared state rather than in the route module because the
|
|
/// *write* paths publish into it — `routes::helpers::apply_repo_write`
|
|
/// hands every committed event over here — while the WebSocket handler
|
|
/// only subscribes. Cloning `AppState` clones the sender, which is the
|
|
/// intended way to reach it from a handler.
|
|
pub firehose: Firehose,
|
|
}
|
|
|
|
impl AppState {
|
|
pub async fn new(cfg: AppConfig, db: PgPool, blob: S3BlobStore) -> Self {
|
|
let mut lex = LexRegistry::new();
|
|
lex.lexicons.insert(
|
|
"app.twi.post".to_string(),
|
|
Lex::from_json(include_str!("../../../lexicons/app/twi/post.json")).unwrap(),
|
|
);
|
|
// AT-Protocol standard collections: only the records the user
|
|
// might legitimately create server-side (feed.like, feed.repost,
|
|
// graph.follow). The full atproto collection library is out of
|
|
// scope — for anything else, callers pass `validate: false` in the
|
|
// createRecord body.
|
|
lex.lexicons.insert(
|
|
"app.bsky.feed.like".to_string(),
|
|
Lex::from_json(include_str!("../../../lexicons/app/bsky/feed/like.json")).unwrap(),
|
|
);
|
|
lex.lexicons.insert(
|
|
"app.bsky.feed.repost".to_string(),
|
|
Lex::from_json(include_str!("../../../lexicons/app/bsky/feed/repost.json")).unwrap(),
|
|
);
|
|
// Follow record. Its absence was a real outage: the desktop
|
|
// client creates follows through `createRecord`, which validates
|
|
// by default, so every follow came back
|
|
// `unknown lexicon: app.bsky.graph.follow` — the button could
|
|
// never have worked. `subject` is a bare DID string here, not a
|
|
// strongRef like like/repost use, matching what the client sends
|
|
// and what the AppView's `follow_subject_did` reads.
|
|
lex.lexicons.insert(
|
|
"app.bsky.graph.follow".to_string(),
|
|
Lex::from_json(include_str!("../../../lexicons/app/bsky/graph/follow.json")).unwrap(),
|
|
);
|
|
// Profile record — avatar/banner/display name/description.
|
|
// Validates the createRecord body when the Tauri client calls
|
|
// its setProfile command. Other fields stay optional so a
|
|
// brand-new account with an empty profile is legal.
|
|
lex.lexicons.insert(
|
|
"app.bsky.actor.profile".to_string(),
|
|
Lex::from_json(include_str!("../../../lexicons/app/bsky/actor/profile.json")).unwrap(),
|
|
);
|
|
let plc_url = cfg.plc_directory_url.clone();
|
|
// The PDS speaks to the AppView via the cluster-internal URL —
|
|
// never the public one, because the ingest endpoint is unauth'd
|
|
// in dev mode (and uses a shared secret in prod). The base URL
|
|
// is the same as `appview_public_url` in our single-host dev
|
|
// setup, but operators can override with `APPVIEW_INTERNAL_URL`.
|
|
let appview_url = std::env::var("APPVIEW_INTERNAL_URL")
|
|
.unwrap_or_else(|_| cfg.appview_public_url.clone());
|
|
let appview = AppViewPushClient::new(appview_url, cfg.appview_ingest_secret.clone());
|
|
Self {
|
|
cfg,
|
|
db,
|
|
blob,
|
|
lex: Arc::new(lex),
|
|
blockstore: Arc::new(MemoryBlockstore::new()),
|
|
plc: PlcClient::new(plc_url),
|
|
appview,
|
|
firehose: Firehose::new(),
|
|
}
|
|
}
|
|
}
|