diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 38678c923f..ea81bc354b 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -10,7528 +10,48 @@ //! - Uses `sqlx::query()` (runtime) not `sqlx::query!()` (compile-time). //! //! ## Runtime and store ownership -//! This crate intentionally keeps database runtime and Buzz domain persistence -//! together while maintaining an internal boundary between them: +//! Database runtime infrastructure and domain persistence are physically +//! separated behind this crate-root compatibility facade: //! -//! - Runtime concerns own pool construction, writer/replica routing, transaction -//! creation, session invariants, metrics, and health support. +//! - Runtime concerns own pool construction, writer/replica routing, +//! transactions, sessions, metrics, health support, and migrations. //! - Store concerns own domain-specific SQL, row mapping, locking, mutation //! rules, indexes, and focused persistence tests. //! -//! Transaction-required store operations accept [`sqlx::Transaction`] so their -//! composition requirement is visible in the type. Private connection helpers -//! are reserved for SQL primitives that are valid on any same-session -//! connection. New domains should prove this boundary incrementally instead of -//! exposing raw pools or introducing broad store traits. +//! Existing crate-root modules, records, and [`Db`] methods remain the public +//! API. The internal `runtime` and `store` namespaces are not public APIs. + +mod runtime; +mod store; -/// Explicit deployment-global admin report reads. -pub mod admin_moderation; -/// API token storage and lookup. -pub mod api_token; -/// Relay-scoped archived identity persistence (NIP-IA). -pub mod archived_identities; -/// Channel lifecycle and metadata persistence. -pub mod channel; -/// Channel membership and roster persistence. -pub mod channel_members; -/// Community lifecycle and host-map persistence. -pub mod community; -/// Durable whole-community deletion lifecycle and PostgreSQL adapter. -pub mod deletion; -/// Direct message channel persistence. -pub mod dm; /// Database error types. pub mod error; -/// Event storage and retrieval. -pub mod event; -/// Home feed queries. -pub mod feed; -/// Git repository name registry (NIP-34 kind:30617). -pub mod git_repo; -/// Embedded database migrations. -pub mod migration; -/// Community moderation: reports, bans/timeouts, audit actions. -pub mod moderation; -mod observability; -/// Monthly table partition management. -pub mod partition; -/// Buzz product-feedback sidecar persistence. -pub mod product_feedback; -/// Community-scoped push lease and durable wake-outbox persistence. -pub mod push; -/// Reaction persistence. -pub mod reaction; -pub mod relay_admin_actions; -/// Use-limited relay invite persistence (v2 opaque tokens). -pub mod relay_invite; -/// Relay-level membership persistence (NIP-43). -pub mod relay_members; -/// Deployment-global operator/moderator roster persistence. -pub mod relay_operators; -/// Replaceable-event persistence and coordinate locking. -pub mod replaceable; -/// Replica freshness fence for keyset-cursor read routing. -pub mod replica_fence; -/// Thread metadata persistence. -pub mod thread; -/// Per-community usage rollup queries for Prometheus gauges. -pub mod usage; -/// User profile persistence. -pub mod user; -/// Workflow, run, and approval persistence. -pub mod workflow; +pub use runtime::{ + insert_mentions, migration, replica_fence, Db, DbConfig, DbPoolStats, ReadSession, +}; +pub(crate) use runtime::{ + insert_mentions_in_transaction, observability, route_proof, ReadSessionInner, RouteDecision, + RoutePredicate, +}; +pub use store::{ + admin_moderation, allowlist, api_token, archived_identities, channel, channel_members, + community, deletion, dm, event, feed, git_repo, moderation, partition, product_feedback, push, + reaction, relay_admin_actions, relay_invite, relay_members, relay_operators, reminder, + replaceable, thread, usage, user, workflow, +}; + +pub use allowlist::AllowlistEntry; +pub use api_token::{ApiTokenRecord, TokenSummary}; pub use community::{ ArchivedCommunityRecord, CommunityRecord, CreateCommunityWithOwnerResult, CreatedCommunityRecord, EnsuredCommunityRecord, OwnedCommunityRecord, UnarchivedCommunityRecord, }; pub use error::{DbError, Result}; -pub use event::{EventQuery, ReactionEventInsertOutcome, DEFAULT_MAX_PAGE_LIMIT}; - -use buzz_datastore_tracing::datastore_span; -use chrono::{DateTime, Utc}; -use sqlx::postgres::{PgConnection, PgPoolOptions}; -use sqlx::{Connection, PgPool, QueryBuilder, Row}; -use std::time::Duration; -use uuid::Uuid; - -use buzz_core::{CommunityId, StoredEvent}; - -/// Extract p-tag mentions from an event and insert into the `event_mentions` table. -/// -/// This pool-owning wrapper propagates failures to its caller. Replacement writes -/// use the transaction-bound helper below so event storage and mention indexing -/// commit or roll back together. Duplicate inserts are silently skipped with -/// `INSERT ... ON CONFLICT DO NOTHING`. -pub async fn insert_mentions( - pool: &PgPool, - community_id: CommunityId, - event: &nostr::Event, - channel_id: Option, -) -> Result<()> { - let mut tx = pool.begin().await?; - insert_mentions_in_transaction(&mut tx, community_id, event, channel_id).await?; - tx.commit().await?; - Ok(()) -} - -/// Insert mention rows on the caller's transaction. Replacement writes use -/// this so the authoritative event and its discovery index commit or roll back -/// as one unit. -async fn insert_mentions_in_transaction( - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - community_id: CommunityId, - event: &nostr::Event, - channel_id: Option, -) -> Result<()> { - let p_tags: Vec<&str> = event - .tags - .iter() - .filter_map(|tag| { - let tag_vec = tag.as_slice(); - if tag_vec.len() >= 2 && tag_vec[0] == "p" { - Some(tag_vec[1].as_str()) - } else { - None - } - }) - .collect(); - - if p_tags.is_empty() { - return Ok(()); - } - - let event_id_bytes = event.id.as_bytes(); - let created_at_secs = event.created_at.as_secs() as i64; - let created_at = DateTime::from_timestamp(created_at_secs, 0) - .ok_or(crate::error::DbError::InvalidTimestamp(created_at_secs))?; - let kind = event.kind.as_u16() as u32; - - // Validate and normalize pubkeys, logging any malformed ones. - let valid_pubkeys: Vec = p_tags - .into_iter() - .filter(|pk| { - if pk.len() != 64 || !pk.chars().all(|c| c.is_ascii_hexdigit()) { - tracing::debug!( - event_id = %event.id, - invalid_ptag = pk, - "skipping malformed p-tag in insert_mentions" - ); - false - } else { - true - } - }) - .map(|pk| pk.to_ascii_lowercase()) - .collect(); - - if valid_pubkeys.is_empty() { - return Ok(()); - } - - // Multi-row INSERT ... ON CONFLICT DO NOTHING, chunked to stay under - // Postgres's 65,535 bind-parameter statement cap (6 binds per row caps a - // single statement at ~10.9k rows). Relay-signed kind 39002 rosters carry - // one p-tag per channel member and can exceed that. The caller owns the - // transaction so all chunks share its commit boundary. - const MENTION_INSERT_CHUNK_ROWS: usize = 5_000; - for chunk in valid_pubkeys.chunks(MENTION_INSERT_CHUNK_ROWS) { - let mut qb: QueryBuilder = QueryBuilder::new( - "INSERT INTO event_mentions \ - (community_id, pubkey_hex, event_id, event_created_at, channel_id, event_kind) ", - ); - - qb.push_values(chunk, |mut b, pubkey| { - b.push_bind(community_id.as_uuid()) - .push_bind(pubkey.as_str()) - .push_bind(event_id_bytes.as_slice()) - .push_bind(created_at) - .push_bind(channel_id) - .push_bind(kind as i32); - }); - - qb.push(" ON CONFLICT DO NOTHING"); - - qb.build().execute(&mut **tx).await?; - } - Ok(()) -} - -/// Database handle. Clone is cheap (Arc-backed pool). -#[derive(Clone, Debug)] -pub struct Db { - pub(crate) pool: PgPool, - /// Maximum connections configured for this pool (from [`DbConfig::max_connections`]). - pub(crate) max_connections: u32, - /// Optional read-replica pool (from [`DbConfig::read_database_url`]). - /// - /// `None` means no replica is configured and every read routes to the - /// writer pool — the pre-replica behavior. Only lag-tolerant reads may - /// route here (see [`Db::read`]); locks, transactions, and anything - /// consistency-critical stays on `pool`. - pub(crate) read_pool: Option, - /// Maximum connections configured for the read-replica pool (from - /// [`DbConfig::read_max_connections`], defaulting to the writer's - /// sizing). Kept separately from `max_connections` so - /// [`Db::read_pool_stats`] reports the reader's own ceiling — a - /// utilisation gauge derived from the writer's max would understate - /// reader saturation by exactly the ratio of the two pool sizes. - pub(crate) read_max_connections: u32, - /// Freshness fence gating cursor-page routing to the replica. - /// - /// Starts closed; a background probe ([`replica_fence::run_probe`]) - /// commits heartbeat tokens and retains proof entries. Routing proves - /// coverage per request on the serving reader session; when the ring is - /// empty or stale, every routed read stays on the writer. - pub(crate) fence: std::sync::Arc, - /// Bounded-staleness routing budget `B`: a read routed under - /// [`RoutePredicate::Bounded`] may be served from a proved replica - /// session only when the proved heartbeat entry is at most this old. - /// `None` disables the bounded arm entirely (the rollout default) — - /// bounded-stale read semantics are a product decision, not an - /// invariant, so the gate ships off. - pub(crate) replica_read_max_age: Option, - /// Whether the reader endpoint supports the Aurora PostgreSQL identity - /// function ([`replica_fence::AURORA_IDENTITY_FN`]) — probed - /// once per process on the first routed read (on a plain autocommit - /// checkout, outside any request transaction) and cached. Unset means - /// not yet probed (or the probe hit a transient error and will retry). - /// Shared across `Db` clones. - pub(crate) reader_aurora_identity: std::sync::Arc>, -} - -/// The session that served (or will serve) a routed read, so follow-up -/// queries in the same request (the channel-window aux closure) run on the -/// **same proved snapshot** — a different pooled reader session may sit at a -/// different replay position, and even the same connection advances its -/// snapshot between autocommit statements. -/// -/// `Replica` holds the request's `REPEATABLE READ, READ ONLY` transaction: -/// the heartbeat observation was its first statement, so the snapshot the -/// proof was taken against is exactly the snapshot every follow-up sees. -/// Dropping the session rolls the read-only transaction back and returns -/// the connection to the pool. -/// -/// `Writer` carries the writer pool: follow-ups there are authoritative by -/// construction and need no session pinning. -pub struct ReadSession { - inner: ReadSessionInner, -} - -enum ReadSessionInner { - /// The proved replica request transaction (snapshot-anchored), plus the - /// writer pool so a mid-request replica failure (e.g. a hot-standby - /// recovery conflict cancelling the held snapshot) degrades the session - /// to the writer instead of surfacing an error: degraded capacity, - /// never holes — and never a 500 the writer could have served. - Replica { - tx: sqlx::Transaction<'static, sqlx::Postgres>, - writer: PgPool, - }, - /// The writer pool (cheap clone; Arc-backed). - Writer(PgPool), -} - -impl ReadSession { - /// Query events on this session (see [`Db::query_events`]). - /// - /// If the proved replica transaction fails mid-request, the session - /// permanently degrades to the writer and the query is re-run there. - /// The writer is always at or ahead of any replica replay position, so - /// the degraded follow-up can only observe *more* than the proof-time - /// snapshot, never less — fresher aux rows, the same failure semantics - /// as a request that routed to the writer to begin with. - #[datastore_span(name = "read_session_query_events", system = "postgresql")] - pub async fn query_events(&mut self, q: &EventQuery) -> Result> { - let degraded = match &mut self.inner { - ReadSessionInner::Replica { tx, writer } => { - match event::query_events_on(tx, q).await { - Ok(rows) => return Ok(rows), - Err(e) => { - tracing::warn!( - error = %e, - "replica session query failed mid-request; degrading to writer" - ); - // Deliberately not a `buzz_db_route_decision` event: - // the page's route was already recorded, and the - // offload metric must stay one-event-per-request. - metrics::counter!("buzz_db_read_session_degraded").increment(1); - writer.clone() - } - } - } - ReadSessionInner::Writer(pool) => return event::query_events(pool, q).await, - }; - // Replacing the inner drops the replica transaction (rolling it - // back and returning the reader connection to its pool). - self.inner = ReadSessionInner::Writer(degraded.clone()); - event::query_events(°raded, q).await - } - - /// Whether this session is a proved replica connection (observability). - pub fn is_replica(&self) -> bool { - matches!(self.inner, ReadSessionInner::Replica { .. }) - } -} - -/// Where one routed read is served (see [`Db::route_read`]). -enum RouteDecision { - /// A reader request transaction whose first-statement heartbeat - /// observation proved this fence entry — the page runs inside it. The - /// `&'static str` is the metric reason (`covered`/`fresh`); the caller - /// records the route only once the page is actually served from the - /// replica, so a post-verification writer re-run or a mid-query replica - /// failure emits exactly one `buzz_db_route_decision` event per request - /// (the offload percentage is read straight off `decision="replica"`). - Replica( - sqlx::Transaction<'static, sqlx::Postgres>, - replica_fence::TokenEntry, - &'static str, - ), - /// Fail closed: serve from the writer pool (already recorded). - Writer, -} - -/// The ONLY place [`route_proof::ChannelScoped`] can be constructed. A -/// crate-root tuple struct would be mintable via `ChannelScoped(())` from -/// every descendant module — tuple-struct field privacy is module-scoped — -/// so the token lives in its own module and E0423 enforces the invariant. -mod route_proof { - use uuid::Uuid; - - /// Proof that a query/page can only return rows with - /// `channel_id IS NOT NULL` — the domain of the commit-time floor guard - /// (migration 0021). `channel_ids` (retains channel-NULL rows) and - /// `global_only = false` are explicitly NOT proofs. - /// - /// Each constructor keys off *how* its path proves channel-bearing-ness: - /// a pinned query filter, a bare `Uuid` argument, or a `NOT NULL` column - /// reached through an inner join. Do not add a universal constructor - /// callers reshape their inputs to fit, and never fabricate a throwaway - /// `EventQuery` purely to mint a token — the proof must be the SQL's - /// shape, not "someone assembled a struct". - #[derive(Clone, Copy)] - pub(crate) struct ChannelScoped(()); - - impl ChannelScoped { - /// Constructor 1: the query pins a single channel - /// (`EventQuery.channel_id = Some(_)`, compiled to a - /// `channel_id = $n` predicate). This proof covers BOTH query - /// builders — the SELECT builder (`event::query_events_on`) and the - /// COUNT builder (`event::count_events`) pin identically; if the - /// two ever drift, this comment is a lie and the routed COUNT seam - /// is unsound. - /// Sound under conjunction: any additional clause (e.g. - /// `channel_ids`, which alone retains channel-NULL rows) is ANDed, - /// and `channel_id = ` never matches NULL — the pin strictly - /// narrows and cannot be widened back out to global rows. - pub(crate) fn from_pinned_channel(q: &crate::event::EventQuery) -> Option { - q.channel_id.map(|_| ChannelScoped(())) - } - - /// Constructor 2 (thread pages): the page is an inner JOIN from - /// `thread_metadata` to `events`, and `thread_metadata.channel_id` - /// is `UUID NOT NULL` — every writer that creates a row passes a - /// concrete channel (`ThreadMetadataParams.channel_id: Uuid`, - /// non-Option). Channel-bearing by construction of the join, not by - /// query predicate. - pub(crate) fn from_thread_metadata_join() -> Self { - ChannelScoped(()) - } - - /// Constructor 3 (channel windows): the channel arrives as a bare - /// `Uuid` argument and the SQL binds it unconditionally - /// (`e.channel_id = $2` in `get_channel_window_on`); every served - /// row is channel-bearing. No `EventQuery` exists on this path. - pub(crate) fn from_channel_id(_channel_id: Uuid) -> Self { - ChannelScoped(()) - } - } -} -use route_proof::ChannelScoped; - -/// The predicate one routed read must satisfy (see [`Db::route_read`]). -/// -/// Discipline: no `Default`, no `Deserialize`, stays non-`pub` — any of -/// those re-opens the [`ChannelScoped`] mint. -enum RoutePredicate { - /// Bounded staleness: the proved entry must be within the configured - /// read budget `B` (default off). Bounds TIME — the page misses at most - /// the freshest `B` of writes. Sound for ANY query shape, including - /// global (channel-NULL) rows: it relies only on heartbeat commit order, - /// not the floor guard. - Bounded, - /// Completeness: the proved wall must cover the page's upper bound. - /// Bounds CONTENT — every row at/below `upper` is present, meaningful - /// even when the cursor is hours old, where `B`-freshness says nothing. - /// Sound ONLY on the floor guard's domain (channel-bearing rows), hence - /// the proof token. `upper` is non-optional: the no-upper-bound - /// post-verifying case is [`RoutePredicate::CoveredPostVerified`]. - /// - /// Bounds INSERT-completeness only — "no missing rows", not "no extra - /// rows". Soft deletes are `UPDATE .. SET deleted_at` commits outside - /// the floor guard and never touch `created_at`, so a covered page can - /// briefly serve a row the writer already excludes; deletion visibility - /// is bounded by replication lag under `FENCE_STALENESS` (30s), not by - /// `upper` or `B`. Do not extend the covered arm to a surface that - /// cannot absorb extra rows (this is why the routed COUNT seam is - /// bounded-only). - Covered { - upper: DateTime, - /// Never read — the field exists so constructing this variant - /// requires minting the token through `route_proof`. - #[allow(dead_code)] - proof: ChannelScoped, - }, - /// Forward-walking thread pages: no upper bound is derivable from the - /// cursor; the caller post-verifies the served rows against the proved - /// wall (full page + tail at/below the wall, else re-run on the writer). - /// Only the thread path constructs this — a general routed caller does - /// no post-verification and must never self-certify. - CoveredPostVerified { - #[allow(dead_code)] - proof: ChannelScoped, - }, - /// Either arm admits, covered tried first (it has no budget dependence). - /// For general routed reads that are channel-pinned AND carry an - /// `until` upper bound. - BoundedOrCovered { - upper: DateTime, - /// Never read — see [`RoutePredicate::Covered::proof`]. - #[allow(dead_code)] - proof: ChannelScoped, - }, -} - -impl RoutePredicate { - /// A channel-window request: cursor pages are covered-only — for deep - /// keyset pages only coverage answers "have all rows below the cursor - /// replayed?" — and a head fetch is bounded. The channel id is the - /// bare-`Uuid` proof that the window SQL pins a channel. - fn from_channel_cursor(channel_id: Uuid, cursor: &Option<(DateTime, Vec)>) -> Self { - match cursor { - Some((ts, _)) => RoutePredicate::Covered { - upper: *ts, - proof: ChannelScoped::from_channel_id(channel_id), - }, - None => RoutePredicate::Bounded, - } - } - - /// General entry point for the routed query seams: derives the strongest - /// sound predicate from the query shape. Never produces a covered arm - /// without both a channel-scope proof AND a real upper bound. - /// - /// `routing_enabled` is whether `BUZZ_REPLICA_READ_MAX_AGE_MS` is set - /// (non-zero). When it is NOT, this returns `Bounded` — which the zero - /// budget then fails closed — so the new seams are genuinely dark at - /// the deploy default even for channel-pinned queries carrying `until`. - /// Without this gate, `BoundedOrCovered` would take the covered arm - /// (which has no budget dependence) and route on day one with no env - /// var set and no kill switch short of removing the replica URL - /// (Dawn's covered-at-zero-budget catch). The pre-existing cursor - /// paths (`Covered`/`CoveredPostVerified` from channel windows and - /// thread pages) intentionally still route at B=0 — status quo, - /// unchanged. - fn for_query(q: &event::EventQuery, routing_enabled: bool) -> Self { - if !routing_enabled { - return RoutePredicate::Bounded; - } - match (ChannelScoped::from_pinned_channel(q), q.until) { - (Some(proof), Some(upper)) => RoutePredicate::BoundedOrCovered { upper, proof }, - _ => RoutePredicate::Bounded, - } - } -} - -/// Map the configured read budget (`BUZZ_REPLICA_READ_MAX_AGE_MS`) to the -/// runtime gate: `0` disables bounded-staleness routing; anything above the -/// fence staleness gate is clamped to it (an entry older than the staleness -/// gate never routes anyway, so a larger budget would only misrepresent the -/// config). -fn read_budget_from_ms(ms: u64) -> Option { - match ms { - 0 => None, - ms => Some(Duration::from_millis(ms).min(replica_fence::FENCE_STALENESS)), - } -} - -/// Snapshot of Postgres connection pool utilisation. -#[derive(Debug, Clone, Copy)] -pub struct DbPoolStats { - /// Total connections currently in the pool (idle + active). - pub size: u32, - /// Connections available for immediate reuse. - pub idle: u32, - /// Pool ceiling — the `max_connections` value set at construction. - pub max: u32, -} - -/// Owns the detached Postgres session holding the relay usage-metrics advisory lock. -/// -/// The connection deliberately does not return to the main pool: session advisory -/// locks must remain bound to this exact physical connection, and the poller -/// pings it before each leader-only collection tick. -pub struct UsageMetricsLeader { - connection: PgConnection, -} - -impl UsageMetricsLeader { - /// Returns whether the lock-owning session is still reachable. - /// - /// Bounded to 5 seconds — a blackholed connection (no RST) would otherwise - /// stall the entire poller tick until the OS TCP timeout. - pub async fn is_live(&mut self) -> bool { - tokio::time::timeout(std::time::Duration::from_secs(5), self.connection.ping()) - .await - .is_ok_and(|r| r.is_ok()) - } -} - -/// Configuration for the Postgres connection pool. -#[derive(Debug, Clone)] -pub struct DbConfig { - /// Postgres connection URL (usually sourced from `DATABASE_URL`). - pub database_url: String, - /// Optional read-replica connection URL (usually sourced from - /// `READ_DATABASE_URL`, e.g. an Aurora `cluster-ro-` endpoint). `None` - /// disables replica routing: [`Db::read`] falls back to the writer pool. - pub read_database_url: Option, - /// Maximum number of connections in the pool. - pub max_connections: u32, - /// Maximum connections in the read-replica pool (env - /// `BUZZ_DB_READ_POOL_SIZE`). `None` inherits [`Self::max_connections`]. - pub read_max_connections: Option, - /// Minimum number of idle connections to maintain. - pub min_connections: u32, - /// Seconds to wait when acquiring a connection before timing out. - pub acquire_timeout_secs: u64, - /// Maximum connection lifetime in seconds before recycling. - pub max_lifetime_secs: u64, - /// Seconds a connection may sit idle before being closed. - pub idle_timeout_secs: u64, - /// Replica read budget `B` in milliseconds (bounded arm, env - /// `BUZZ_REPLICA_READ_MAX_AGE_MS`). `0` disables bounded-staleness - /// routing — the rollout default. Values above - /// [`replica_fence::FENCE_STALENESS`] are clamped to it: an entry older - /// than the staleness gate never routes anyway, so a larger budget - /// would only misrepresent the config. - pub replica_read_max_age_ms: u64, -} - -impl Default for DbConfig { - /// Sized for a single relay pod against PG max_connections=100. - /// Staging measured 51 idle + 1 active out of 50 — most connections sat unused. - /// At 20 main + 5 audit = 25/pod, four relay pods fit within the PG limit. - fn default() -> Self { - Self { - database_url: "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string(), // sadscan:disable np.postgres.1 - read_database_url: None, - max_connections: 20, - read_max_connections: None, - min_connections: 2, - acquire_timeout_secs: 3, - max_lifetime_secs: 1800, - idle_timeout_secs: 600, - replica_read_max_age_ms: 0, - } - } -} - -/// Token summary returned by [`Db::list_active_tokens`]. -#[derive(Debug, Clone)] -pub struct TokenSummary { - /// Unique token identifier. - pub id: Uuid, - /// Human-readable token name. - pub name: String, - /// Compressed public key bytes of the token owner. - pub owner_pubkey: Vec, - /// Permission scopes granted to this token. - pub scopes: Vec, - /// When the token was created. - pub created_at: DateTime, - /// Optional expiry timestamp; `None` means no expiry. - pub expires_at: Option>, -} - -impl Db { - /// Creates a new `Db` by connecting a Postgres pool with the given config. - /// - /// When `config.read_database_url` is set, a second pool with the same - /// sizing is connected to it for lag-tolerant reads (see [`Db::read`]). - /// - /// The writer pool arms the commit-time `created_at` floor guard - /// (migration 0021) on every connection by setting the - /// `buzz.created_at_floor` GUC — this is what makes the replica fence - /// proof hold for every insert path that goes through this pool. - pub async fn new(config: &DbConfig) -> Result { - let pool = Self::connect_pool(config, &config.database_url).await?; - let read_max_connections = config - .read_max_connections - .unwrap_or(config.max_connections); - let read_pool = match &config.read_database_url { - Some(url) => Some(Self::connect_read_pool(config, url, read_max_connections)?), - None => None, - }; - let replica_read_max_age = read_budget_from_ms(config.replica_read_max_age_ms); - Ok(Self { - pool, - max_connections: config.max_connections, - read_pool, - read_max_connections, - fence: std::sync::Arc::new(replica_fence::ReplicaFence::new()), - replica_read_max_age, - reader_aurora_identity: std::sync::Arc::new(std::sync::OnceLock::new()), - }) - } - - /// Connect the writer pool with all session-level safety premises. - /// - /// SQLx stores one `after_connect` hook, so the floor guard and transaction - /// isolation assertion must remain in this single closure. Registering a - /// second hook replaces the first and silently disarms the floor trigger. - async fn connect_pool(config: &DbConfig, url: &str) -> Result { - let options = PgPoolOptions::new() - .max_connections(config.max_connections) - .min_connections(config.min_connections) - .acquire_timeout(Duration::from_secs(config.acquire_timeout_secs)) - .max_lifetime(Duration::from_secs(config.max_lifetime_secs)) - .idle_timeout(Duration::from_secs(config.idle_timeout_secs)) - .after_connect(|conn, _meta| { - Box::pin(async move { - // `SET` cannot take bind parameters; `set_config` can. - sqlx::query("SELECT set_config('buzz.created_at_floor', $1, false)") - .bind(replica_fence::CREATED_AT_FLOOR_SECS.to_string()) - .execute(&mut *conn) - .await?; - let isolation: String = sqlx::query_scalar("SHOW transaction_isolation") - .fetch_one(&mut *conn) - .await?; - if isolation != "read committed" { - return Err(sqlx::Error::Configuration( - format!( - "writer pool requires READ COMMITTED transaction isolation, got {isolation}" - ) - .into(), - )); - } - Ok(()) - }) - }); - Ok(options.connect(url).await?) - } - - /// Reader acquire timeout — deliberately far below the writer's - /// (seconds-denominated) timeout. Failing closed to the writer must be - /// fast: a saturated reader pool that made routed reads wait the full - /// writer-style timeout would add dead latency during exactly the load - /// spike the offload exists for. A miss here surfaces as - /// `writer/reader_acquire_timeout` (see [`Db::proved_reader`] for why - /// the reason names the mechanism rather than a diagnosis). - const READER_ACQUIRE_TIMEOUT: Duration = Duration::from_millis(150); - - /// Connect the read-replica pool **lazily** — no connection is - /// attempted at construction, so a reader that is down at boot cannot - /// crash the relay (it starts all-writer with the fence closed and - /// recovers when the replica returns). - /// - /// `min_connections` is pinned to 0 explicitly: sqlx's lazy pool still - /// spawns an eager background connect task to satisfy a nonzero - /// minimum, which would reintroduce boot-time reader dial attempts (and - /// their log noise) that "lazy" is meant to avoid. With 0, connections - /// are dialed only on first acquire; the ~10-minute reaper never tops - /// the pool back up, which is fine — routed reads re-fill it on demand. - /// - /// No floor guard or writer-isolation assertion: replica sessions are - /// read-only, so the commit-time trigger from migration 0021 never fires - /// here and the write fence that depends on READ COMMITTED is never reached. - fn connect_read_pool(config: &DbConfig, url: &str, max_connections: u32) -> Result { - Ok(PgPoolOptions::new() - .max_connections(max_connections) - .min_connections(0) - .acquire_timeout(Self::READER_ACQUIRE_TIMEOUT) - .max_lifetime(Duration::from_secs(config.max_lifetime_secs)) - .idle_timeout(Duration::from_secs(config.idle_timeout_secs)) - .connect_lazy(url)?) - } - - /// Spawn a one-shot reader reachability probe that only WARNs. - /// - /// With a lazy pool and `min_connections(0)`, nothing dials the replica - /// until the first routed read — so a misconfigured `READ_DATABASE_URL` - /// would otherwise be invisible until traffic arrives and quietly falls - /// back to the writer. This ping is the only boot-time reader-down - /// visibility; it must never gate startup or [`Db::spawn_fence_probe`]. - /// - /// On success it also primes the Aurora identity capability cache - /// ([`Db::reader_aurora_identity`]) on the connection it already holds, - /// so the first routed read doesn't spend a second acquire (up to - /// another [`Db::READER_ACQUIRE_TIMEOUT`]) inside - /// [`Db::reader_aurora_capability_on`]. Prime failure is fine: the routed - /// path re-probes on the connection it already holds, so a failed prime - /// costs a round trip rather than a second acquire budget. - pub fn spawn_read_pool_boot_ping(&self) { - let Some(read_pool) = self.read_pool.clone() else { - return; - }; - let aurora_identity = self.reader_aurora_identity.clone(); - tokio::spawn(async move { - match observability::acquire(&read_pool, observability::PoolRole::Reader).await { - Ok(mut conn) => { - tracing::info!("read replica reachable at boot"); - match replica_fence::reader_supports_aurora_identity(&mut conn).await { - Ok(supported) => { - let _ = aurora_identity.set(supported); - } - Err(e) => tracing::debug!( - error = %e, - "aurora identity boot prime failed; first routed read will probe" - ), - } - } - Err(e) => tracing::warn!( - "read replica unreachable at boot; serving all-writer until it recovers: {e}" - ), - } - }); - } - - /// Creates a `Db` from an existing `PgPool` (useful in tests). - pub fn from_pool(pool: PgPool) -> Self { - Self { - max_connections: pool.options().get_max_connections(), - read_max_connections: pool.options().get_max_connections(), - pool, - read_pool: None, - fence: std::sync::Arc::new(replica_fence::ReplicaFence::new()), - replica_read_max_age: None, - reader_aurora_identity: std::sync::Arc::new(std::sync::OnceLock::new()), - } - } - - /// Creates a `Db` from distinct writer and read pools (useful in tests, - /// where a second database stands in for a lagged replica). - /// - /// The fence starts closed; tests that want cursor pages served by the - /// fake replica must open it via - /// [`replica_fence::ReplicaFence::force_open_for_tests`] (see - /// [`Db::fence`]). - pub fn from_pools(pool: PgPool, read_pool: PgPool) -> Self { - Self { - max_connections: pool.options().get_max_connections(), - read_max_connections: read_pool.options().get_max_connections(), - pool, - read_pool: Some(read_pool), - fence: std::sync::Arc::new(replica_fence::ReplicaFence::new()), - replica_read_max_age: None, - reader_aurora_identity: std::sync::Arc::new(std::sync::OnceLock::new()), - } - } - - /// Test hook: set the head-fetch routing budget (Predicate A), which - /// [`Db::from_pools`] leaves disabled. - pub fn set_replica_read_max_age_for_tests(&mut self, budget: Option) { - self.replica_read_max_age = budget; - } - - /// The freshness fence gating replica routing (see [`replica_fence`]). - pub fn fence(&self) -> &std::sync::Arc { - &self.fence - } - - /// Verify the floor guard end-to-end, then spawn the background fence - /// probe. Returns `Ok(false)` when no replica is configured. - /// - /// Ordering matters (Perci, PR #2084 review): this must run **after** - /// the migration decision. On a relay with `BUZZ_AUTO_MIGRATE` off, the - /// writer pool arms the GUC regardless, but if migration 0021 has not - /// been applied there is no trigger enforcing it — and a heartbeat probe - /// would open the fence over an unenforced floor. So the probe is gated - /// on an unconditional two-part verification against the live schema: - /// catalog shape ([`replica_fence::verify_floor_guard_catalog`]) and - /// observed semantics through this exact pool - /// ([`replica_fence::verify_floor_guard_behavior`]). - /// - /// On any verification failure the probe is never spawned and the fence - /// stays closed: every cursor page routes to the writer. The relay keeps - /// serving — degraded capacity, never holes. - pub async fn spawn_fence_probe(&self) -> Result { - if self.read_pool.is_none() { - return Ok(false); - } - replica_fence::verify_floor_guard_catalog(&self.pool).await?; - replica_fence::verify_floor_guard_behavior(&self.pool).await?; - tokio::spawn(replica_fence::run_probe( - self.pool.clone(), - std::sync::Arc::clone(&self.fence), - )); - Ok(true) - } - - /// The pool for lag-tolerant reads: the read replica when configured, - /// otherwise the writer pool. - /// - /// Removed as a public escape hatch (Dawn, review of 1b0aa0dfa): the - /// raw replica pool carries **no fence proof**, which is exactly the - /// bug class the routed-read machinery exists to eliminate. All replica - /// reads must go through [`Db::route_read`]-backed entry points; this - /// remains only for the fence's own plumbing tests. - #[cfg(test)] - fn read(&self) -> &PgPool { - self.read_pool.as_ref().unwrap_or(&self.pool) - } - - /// Whether a distinct read-replica pool is configured. - pub fn has_read_pool(&self) -> bool { - self.read_pool.is_some() - } - - /// Open a reader request transaction and complete the connection-local - /// half of the fence proof: `BEGIN ISOLATION LEVEL REPEATABLE READ, READ - /// ONLY`, then observe the heartbeat token/epoch as the transaction's - /// **first statement** — anchoring the snapshot every follow-up - /// statement (page, participants, aux closure) sees to exactly the - /// snapshot the proof was taken against — and resolve it against the - /// retained ring. Returns the open transaction together with the - /// strongest [`replica_fence::TokenEntry`] its observation supports, or - /// the fail-closed reason for route metrics. - /// - /// `REPEATABLE READ` is the strongest isolation a hot standby supports - /// (`SERIALIZABLE` is writer-only); `READ ONLY` documents intent and - /// rejects accidental writes. Everything but `Ok` fails closed — begin - /// failure, missing heartbeat row (migration not yet replayed there), - /// observation error, epoch mismatch, or a token below every retained - /// entry all route the request to the writer. - async fn proved_reader( - &self, - read_pool: &PgPool, - ) -> std::result::Result< - ( - sqlx::Transaction<'static, sqlx::Postgres>, - replica_fence::TokenEntry, - ), - &'static str, - > { - // One checkout per routed read. The Aurora capability probe and the - // read-only transaction share a single `acquire()` so the request path - // spends exactly one READER_ACQUIRE_TIMEOUT budget. Probing through - // `read_pool` separately would spend a second budget whenever the - // capability is uncached — i.e. after a failed boot ping, which is - // precisely the reader-unavailable case the bound must hold for. - let conn = match observability::acquire(read_pool, observability::PoolRole::Reader).await { - Ok(conn) => conn, - Err(sqlx::Error::PoolTimedOut) => { - tracing::warn!("reader pool acquire timed out; routing to writer"); - return Err("reader_acquire_timeout"); - } - Err(e) => { - tracing::warn!(error = %e, "reader connection acquire failed; routing to writer"); - return Err("reader_validation_error"); - } - }; - let mut conn = conn; - let aurora = self.reader_aurora_capability_on(&mut conn).await; - let mut tx = match sqlx::Transaction::begin( - conn, - Some(sqlx::SqlStr::from_static( - "BEGIN ISOLATION LEVEL REPEATABLE READ, READ ONLY", - )), - ) - .await - { - Ok(tx) => tx, - // The acquire miss gets its own reason code: the reader pool's - // short acquire timeout (READER_ACQUIRE_TIMEOUT) makes this the - // fast fail-closed path under load, and - // `buzz_db_route_decision{decision="writer",reason="reader_acquire_timeout"}` - // is the operator's alert signal for a struggling reader pool. - // - // The reason deliberately names the mechanism, not a diagnosis: - // `PoolTimedOut` proves only that no connection was handed out - // within the 150ms budget. That budget includes cold connect - // (TCP+TLS+auth), and sqlx's `size` counts in-flight dials, so - // this fires for slow connection establishment as well as for - // established-connection contention — and neither `size == 0` - // nor `size >= max` recovers the missing causal bit (in-flight - // dials hold a size slot, and a cold burst can push - // `active = size - idle` toward max with zero busy connections). - // Runbook: correlate with `buzz_db_read_pool_active` / `_max` - // and reader connection health/latency; high active suggests - // contention, but this metric alone does not distinguish - // contention from slow connects. Note the gauge is a coarse - // sample (BUZZ_POOL_METRICS_INTERVAL_SECS, default 10s) while - // the event it explains lasts ~150ms — a short burst may fall - // between samples entirely, so absence of elevated active is - // NOT evidence of a cold connect. - Err(sqlx::Error::PoolTimedOut) => { - tracing::warn!("reader pool acquire timed out; routing to writer"); - return Err("reader_acquire_timeout"); - } - Err(e) => { - tracing::warn!(error = %e, "reader transaction begin failed; routing to writer"); - return Err("reader_validation_error"); - } - }; - let obs = match replica_fence::observe_heartbeat(&mut tx, aurora).await { - Ok(Some(observation)) => observation, - Ok(None) => return Err("reader_validation_error"), - Err(e) => { - tracing::warn!(error = %e, "heartbeat observation failed; routing to writer"); - return Err("reader_validation_error"); - } - }; - match self.fence.resolve(obs.token, obs.epoch) { - replica_fence::ResolveOutcome::Proved(entry) => { - tracing::debug!( - token = obs.token, - proved_token = entry.token, - backend = %obs.backend, - "reader snapshot proved fence coverage" - ); - Ok((tx, entry)) - } - replica_fence::ResolveOutcome::EpochMismatch => Err("reader_validation_error"), - replica_fence::ResolveOutcome::TokenBehind => Err("reader_token_behind"), - } - } - - /// Whether the reader endpoint supports the Aurora PostgreSQL identity - /// function ([`replica_fence::AURORA_IDENTITY_FN`]), probed - /// once per process and cached (see [`Db::reader_aurora_identity`]). - /// The probe runs on a plain autocommit checkout — never inside the - /// request transaction, where an undefined-function error would abort - /// it. Probe failure (acquire or transient) degrades to the plain - /// identity tuple for THIS request without caching, so a later request - /// retries; identity is evidence, never a routing gate. - /// Aurora capability on a connection the caller already holds, so the - /// routed path never spends a second acquire budget. - async fn reader_aurora_capability_on( - &self, - conn: &mut sqlx::pool::PoolConnection, - ) -> bool { - if let Some(cached) = self.reader_aurora_identity.get() { - return *cached; - } - match replica_fence::reader_supports_aurora_identity(conn).await { - Ok(supported) => *self.reader_aurora_identity.get_or_init(|| supported), - Err(e) => { - tracing::debug!(error = %e, "aurora identity probe failed; will retry"); - false - } - } - } - - /// Record one route decision (Rev 2 observability): which path, where it - /// went, and why. - fn record_route(path: &'static str, decision: &'static str, reason: &'static str) { - metrics::counter!( - "buzz_db_route_decision", - "path" => path, - "decision" => decision, - "reason" => reason, - ) - .increment(1); - } - - /// Run pending database migrations. - #[datastore_span(name = "migrate", system = "postgresql")] - pub async fn migrate(&self) -> Result<()> { - migration::run_migrations(&self.pool).await - } - - /// Returns `true` if the database is reachable (used by readiness probes). - pub async fn ping(&self) -> bool { - sqlx::query("SELECT 1").execute(&self.pool).await.is_ok() - } - - /// Validate the minimum deletion fence catalog required by serving paths. - pub async fn validate_deletion_serving_catalog(&self) -> Result<()> { - self.deletion_store().validate_serving_catalog().await - } - - /// Validate the exact live community-deletion tenant catalog for destruction. - pub async fn validate_deletion_catalog(&self) -> Result<()> { - self.deletion_store().validate_catalog().await - } - - /// Returns pool utilisation stats for metrics emission. - /// - /// `size` — total connections (idle + active) - /// `idle` — connections available for immediate reuse - /// `max` — pool ceiling set at construction - pub fn pool_stats(&self) -> DbPoolStats { - DbPoolStats { - size: self.pool.size(), - idle: self.pool.num_idle() as u32, - max: self.max_connections, - } - } - - /// Pool utilisation stats for the read-replica pool, when configured. - /// - /// `max` is the **reader's** ceiling ([`Db::read_max_connections`]), not - /// the writer's: `buzz_db_read_pool_active / buzz_db_read_pool_max` is - /// the operator's utilisation signal for tuning `BUZZ_DB_READ_POOL_SIZE`, - /// and deriving it from the writer's max would misreport saturation by - /// exactly the ratio of the two pool sizes — in the direction that hides - /// the problem. - pub fn read_pool_stats(&self) -> Option { - self.read_pool.as_ref().map(|p| DbPoolStats { - size: p.size(), - idle: p.num_idle() as u32, - max: self.read_max_connections, - }) - } - - /// Try to acquire the detached session advisory lock for relay usage metrics. - /// - /// The returned guard owns the exact connection that acquired the lock. It is - /// detached from the shared pool so a stable leader neither returns a locked - /// session to other callers nor permanently consumes a pool slot. Dropping the - /// guard closes the connection and releases the session-scoped lock. - #[datastore_span(name = "try_lock_usage_metrics", system = "postgresql")] - pub async fn try_lock_usage_metrics( - &self, - lock_key: i64, - ) -> Result> { - let mut connection = - observability::acquire(&self.pool, observability::PoolRole::Writer).await?; - let acquired = sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)") - .bind(lock_key) - .fetch_one(&mut *connection) - .await?; - if acquired { - Ok(Some(UsageMetricsLeader { - connection: connection.detach(), - })) - } else { - Ok(None) - } - } - - /// List reports for the deployment-global read-only admin plane. - #[allow(clippy::too_many_arguments)] - #[datastore_span(name = "admin_list_reports", system = "postgresql")] - pub async fn admin_list_reports( - &self, - community_id: Option, - status: Option<&str>, - report_type: Option<&str>, - target_kind: Option<&str>, - after: Option>, - before: Option>, - cursor: Option<(DateTime, Uuid)>, - limit: i64, - ) -> Result> { - admin_moderation::list_reports( - &self.pool, - community_id, - status, - report_type, - target_kind, - after, - before, - cursor, - limit, - ) - .await - } - - /// Fetch one report for the deployment-global read-only admin plane. - #[datastore_span(name = "admin_get_report", system = "postgresql")] - pub async fn admin_get_report( - &self, - id: Uuid, - ) -> Result> { - admin_moderation::get_report(&self.pool, id).await - } - - /// List feedback for the deployment-global read-only admin plane. - #[datastore_span(name = "admin_list_feedback", system = "postgresql")] - pub async fn admin_list_feedback( - &self, - limit: i64, - ) -> Result> { - admin_moderation::list_feedback(&self.pool, limit).await - } - - /// Fetch one feedback submission for the deployment-global admin plane. - #[datastore_span(name = "admin_get_feedback", system = "postgresql")] - pub async fn admin_get_feedback( - &self, - id: Uuid, - ) -> Result> { - admin_moderation::get_feedback(&self.pool, id).await - } - - /// Return total number of communities on this relay. - #[datastore_span(name = "usage_community_count", system = "postgresql")] - pub async fn usage_community_count(&self) -> Result { - usage::community_count(&self.pool).await - } - - /// Return per-community user counts split by human/agent. - #[datastore_span(name = "usage_user_counts", system = "postgresql")] - pub async fn usage_user_counts(&self) -> Result> { - usage::user_counts(&self.pool).await - } - - /// Return per-community channel counts by type. - #[datastore_span(name = "usage_channel_counts", system = "postgresql")] - pub async fn usage_channel_counts(&self) -> Result> { - usage::channel_counts(&self.pool).await - } - - /// Return per-community kind=9 message counts. - #[datastore_span(name = "usage_message_counts", system = "postgresql")] - pub async fn usage_message_counts(&self) -> Result> { - usage::message_counts(&self.pool).await - } - - /// Return per-community relay-member counts by role. - #[datastore_span(name = "usage_relay_member_counts", system = "postgresql")] - pub async fn usage_relay_member_counts(&self) -> Result> { - usage::relay_member_counts(&self.pool).await - } - - /// Return per-community workflow counts by status. - #[datastore_span(name = "usage_workflow_counts", system = "postgresql")] - pub async fn usage_workflow_counts(&self) -> Result> { - usage::workflow_counts(&self.pool).await - } - - /// Return per-community git-repo counts. - #[datastore_span(name = "usage_git_repo_counts", system = "postgresql")] - pub async fn usage_git_repo_counts(&self) -> Result> { - usage::git_repo_counts(&self.pool).await - } - - /// Return per-community distinct active-user counts for a given SQL interval. - /// - /// `interval_sql` must be a trusted literal such as `"1 day"` or `"7 days"`. - #[datastore_span(name = "usage_active_user_counts", system = "postgresql")] - pub async fn usage_active_user_counts( - &self, - interval_sql: &'static str, - ) -> Result> { - usage::active_user_counts(&self.pool, interval_sql).await - } - - /// Return per-community active-channel counts for a given SQL interval. - #[datastore_span(name = "usage_active_channel_counts", system = "postgresql")] - pub async fn usage_active_channel_counts( - &self, - interval_sql: &'static str, - ) -> Result> { - usage::active_channel_counts(&self.pool, interval_sql).await - } - - /// Return all community id → host mappings. - #[datastore_span(name = "usage_community_hosts", system = "postgresql")] - pub async fn usage_community_hosts(&self) -> Result> { - usage::community_hosts(&self.pool).await - } - - /// Return the shared durable whole-community deletion adapter. - pub fn deletion_store(&self) -> deletion::DeletionStore { - deletion::DeletionStore::new(self.pool.clone()) - } - - /// Begin a database transaction for atomic multi-statement operations. - /// - /// Returns a `'static` transaction because `PgPool` is `Arc`-backed internally. - /// The transaction holds an owned pool handle, not a borrow. - pub async fn begin_transaction(&self) -> Result> { - let connection = - observability::acquire(&self.pool, observability::PoolRole::Writer).await?; - sqlx::Transaction::begin(connection, None) - .await - .map_err(Into::into) - } - - /// Inserts an event. Returns `(StoredEvent, was_inserted)` — `false` on duplicate. - #[datastore_span(name = "insert_event", system = "postgresql")] - pub async fn insert_event( - &self, - community_id: CommunityId, - event: &nostr::Event, - channel_id: Option, - ) -> Result<(StoredEvent, bool)> { - let result = event::insert_event(&self.pool, community_id, event, channel_id).await?; - if result.1 { - if let Err(e) = insert_mentions(&self.pool, community_id, event, channel_id).await { - tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); - } - } - Ok(result) - } - - /// Insert an event while holding and validating an admitted serving-write - /// lease under the community ordering lock through commit. - /// - /// External side effects use a durable lease rather than one long-lived DB - /// transaction. Their final database mutation presents that exact lease so - /// it may finish during quiescing without admitting any new serving work. - pub async fn insert_event_with_serving_write_guard( - &self, - lease: &deletion::ServingWriteLease, - event: &nostr::Event, - channel_id: Option, - ) -> Result<(StoredEvent, bool)> { - let community_id = lease.community_id; - let kind_u16 = event.kind.as_u16(); - let kind_u32 = u32::from(kind_u16); - if kind_u32 == buzz_core::kind::KIND_AUTH { - return Err(DbError::AuthEventRejected); - } - if buzz_core::kind::is_ephemeral(kind_u32) { - return Err(DbError::EphemeralEventRejected(kind_u16)); - } - - let mut tx = self.pool.begin().await?; - self.deletion_store() - .guard_transaction_with_serving_lease(&mut tx, lease) - .await?; - let result = event::insert_event_with_thread_metadata_tx( - &mut tx, - community_id, - event, - channel_id, - None, - ) - .await?; - tx.commit().await?; - if result.1 { - if let Err(e) = insert_mentions(&self.pool, community_id, event, channel_id).await { - tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); - } - } - Ok(result) - } - - /// Queries events matching the given filter parameters. - /// - /// Always reads from the WRITER pool. If the result influences a write - /// or a permission decision, this is the method to call. Display-path - /// callers that tolerate bounded staleness should use - /// [`Db::query_events_routed`] instead — converting a caller is an - /// explicit, per-callsite decision, never a change to this method. - #[datastore_span(name = "query_events", system = "postgresql")] - pub async fn query_events(&self, q: &EventQuery) -> Result> { - event::query_events(&self.pool, q).await - } - - /// [`Db::query_events`] with replica routing — the opt-in fast path for - /// display reads. - /// - /// Rule of thumb: **if the result influences a write or a permission, - /// it reads from the writer** — do not convert such a caller to this - /// method. Every new caller must be added to the caller-classification - /// table in `PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md`. - /// - /// Routing derives the strongest sound predicate from the query shape - /// ([`RoutePredicate::for_query`]): a channel-pinned query with an - /// `until` upper bound may be served covered (provably complete below - /// the fence wall); anything else is bounded-staleness only. The whole - /// seam is gated on `BUZZ_REPLICA_READ_MAX_AGE_MS` (default off): when - /// unset, even covered-eligible queries stay on the writer, so merging - /// this seam is a true no-op until the budget is configured. Every - /// failure fails closed to the writer. - #[datastore_span(name = "query_events_routed", system = "postgresql")] - pub async fn query_events_routed( - &self, - path: &'static str, - q: &EventQuery, - ) -> Result> { - let predicate = RoutePredicate::for_query(q, self.replica_read_max_age.is_some()); - match self.route_read(path, predicate).await { - RouteDecision::Replica(mut tx, _entry, reason) => { - match event::query_events_on(&mut tx, q).await { - Ok(events) => { - Self::record_route(path, "replica", reason); - Ok(events) - } - Err(e) => { - // Mid-query replica failure: fail closed to the - // writer rather than surfacing a routed error. - tracing::warn!(path, "replica read failed; re-running on writer: {e}"); - Self::record_route(path, "writer", "replica_error"); - event::query_events(&self.pool, q).await - } - } - } - RouteDecision::Writer => event::query_events(&self.pool, q).await, - } - } - - /// [`Db::query_events_routed`] restricted to the BOUNDED arm — for - /// reads whose result feeds a COUNT rather than a displayed page. - /// - /// The covered arm bounds insert-completeness only; stale deletions can - /// briefly inflate the result set (see [`RoutePredicate::Covered`]). A - /// display page absorbs that per-row; a number derived from the rows - /// does not. Same classification-table requirement as - /// [`Db::query_events_routed`]. - #[datastore_span(name = "query_events_routed_bounded", system = "postgresql")] - pub async fn query_events_routed_bounded( - &self, - path: &'static str, - q: &EventQuery, - ) -> Result> { - match self.route_read(path, RoutePredicate::Bounded).await { - RouteDecision::Replica(mut tx, _entry, reason) => { - match event::query_events_on(&mut tx, q).await { - Ok(events) => { - Self::record_route(path, "replica", reason); - Ok(events) - } - Err(e) => { - tracing::warn!(path, "replica read failed; re-running on writer: {e}"); - Self::record_route(path, "writer", "replica_error"); - event::query_events(&self.pool, q).await - } - } - } - RouteDecision::Writer => event::query_events(&self.pool, q).await, - } - } - - /// Count events matching the given query (NIP-45 COUNT support). - /// - /// Always reads from the WRITER pool — see [`Db::query_events`] for the - /// writer-vs-routed rule. - #[datastore_span(name = "count_events", system = "postgresql")] - pub async fn count_events(&self, q: &EventQuery) -> Result { - event::count_events(&self.pool, q).await - } - - /// [`Db::count_events`] with replica routing — same contract, rules, - /// and classification-table requirement as [`Db::query_events_routed`]. - /// - /// Counts route on the BOUNDED arm only, never covered: the covered - /// arm bounds insert-completeness but not deletion visibility (soft - /// deletes are UPDATEs outside the floor guard), and a count has no - /// downstream per-row re-filter to absorb extra rows — a silently - /// inflated number for up to `FENCE_STALENESS` is a different product - /// statement than a page briefly showing a deleted row. `Bounded` ties - /// the error to the accepted budget `B`. - #[datastore_span(name = "count_events_routed", system = "postgresql")] - pub async fn count_events_routed(&self, path: &'static str, q: &EventQuery) -> Result { - match self.route_read(path, RoutePredicate::Bounded).await { - RouteDecision::Replica(mut tx, _entry, reason) => { - match event::count_events_on(&mut tx, q).await { - Ok(count) => { - Self::record_route(path, "replica", reason); - Ok(count) - } - Err(e) => { - tracing::warn!(path, "replica count failed; re-running on writer: {e}"); - Self::record_route(path, "writer", "replica_error"); - event::count_events(&self.pool, q).await - } - } - } - RouteDecision::Writer => event::count_events(&self.pool, q).await, - } - } - - /// Return whether a creator-signed huddle-start event links a parent - /// channel to an ephemeral huddle channel. - #[datastore_span(name = "huddle_started_link_exists", system = "postgresql")] - pub async fn huddle_started_link_exists( - &self, - community_id: CommunityId, - parent_channel_id: Uuid, - ephemeral_channel_id: Uuid, - creator_pubkey: &[u8], - ) -> Result { - event::huddle_started_link_exists( - &self.pool, - community_id, - parent_channel_id, - ephemeral_channel_id, - creator_pubkey, - ) - .await - } - - /// Fetch the latest replaceable event for a (kind, pubkey) pair. - /// - /// Uses canonical NIP-16 ordering: `created_at DESC, id ASC`. - /// This matches the write path in [`replace_addressable_event`] and handles - /// historical duplicate survivors correctly. - #[datastore_span(name = "get_latest_global_replaceable", system = "postgresql")] - pub async fn get_latest_global_replaceable( - &self, - community_id: CommunityId, - kind: i32, - pubkey_bytes: &[u8], - ) -> Result> { - event::get_latest_global_replaceable(&self.pool, community_id, kind, pubkey_bytes).await - } - - /// Fetches a single non-deleted event by its raw ID bytes. - /// - /// Returns `None` if the event does not exist or has been soft-deleted. - #[datastore_span(name = "get_event_by_id", system = "postgresql")] - pub async fn get_event_by_id( - &self, - community_id: CommunityId, - id_bytes: &[u8], - ) -> Result> { - event::get_event_by_id(&self.pool, community_id, id_bytes).await - } - - /// Fetches a single event by its raw ID bytes, **including soft-deleted rows**. - #[datastore_span(name = "get_event_by_id_including_deleted", system = "postgresql")] - pub async fn get_event_by_id_including_deleted( - &self, - community_id: CommunityId, - id_bytes: &[u8], - ) -> Result> { - event::get_event_by_id_including_deleted(&self.pool, community_id, id_bytes).await - } - - /// Soft-deletes an event. Returns `Ok(true)` if deleted, `Ok(false)` if already deleted. - #[datastore_span(name = "soft_delete_event", system = "postgresql")] - pub async fn soft_delete_event( - &self, - community_id: CommunityId, - event_id: &[u8], - ) -> Result { - event::soft_delete_event(&self.pool, community_id, event_id).await - } - - /// Soft-delete the live row for an addressable coordinate `(kind, pubkey, d_tag)` - /// when it is not newer than the deletion request. - /// Used by NIP-09 a-tag deletion for parameterized-replaceable kinds; - /// `deletion_created_at_secs` is the deletion event's `created_at`. - #[datastore_span(name = "soft_delete_by_coordinate", system = "postgresql")] - pub async fn soft_delete_by_coordinate( - &self, - community_id: CommunityId, - kind: i32, - pubkey: &[u8], - d_tag: &str, - deletion_created_at_secs: i64, - ) -> Result { - event::soft_delete_by_coordinate( - &self.pool, - community_id, - kind, - pubkey, - d_tag, - deletion_created_at_secs, - ) - .await - } - - /// Atomically soft-delete an event and decrement thread reply counters. - #[datastore_span(name = "soft_delete_event_and_update_thread", system = "postgresql")] - pub async fn soft_delete_event_and_update_thread( - &self, - community_id: CommunityId, - event_id: &[u8], - parent_event_id: Option<&[u8]>, - root_event_id: Option<&[u8]>, - ) -> Result { - event::soft_delete_event_and_update_thread( - &self.pool, - community_id, - event_id, - parent_event_id, - root_event_id, - ) - .await - } - - /// Returns the most recent `created_at` for a channel. - #[datastore_span(name = "get_last_message_at", system = "postgresql")] - pub async fn get_last_message_at( - &self, - community_id: CommunityId, - channel_id: Uuid, - ) -> Result>> { - event::get_last_message_at(&self.pool, community_id, channel_id).await - } - - /// Bulk-fetch the most recent `created_at` for a set of channel IDs. - #[datastore_span(name = "get_last_message_at_bulk", system = "postgresql")] - pub async fn get_last_message_at_bulk( - &self, - community_id: CommunityId, - channel_ids: &[Uuid], - ) -> Result>> { - event::get_last_message_at_bulk(&self.pool, community_id, channel_ids).await - } - - /// Batch-fetch non-deleted events by their raw IDs. - #[datastore_span(name = "get_events_by_ids", system = "postgresql")] - pub async fn get_events_by_ids( - &self, - community_id: CommunityId, - ids: &[&[u8]], - ) -> Result> { - event::get_events_by_ids(&self.pool, community_id, ids).await - } - - /// [`Db::get_events_by_ids`] with replica routing — same contract and - /// classification-table requirement as [`Db::query_events_routed`]. - /// - /// By-id fetches route on the BOUNDED arm only: an id list carries no - /// channel pin, so no fence floor can prove insert-completeness — the - /// covered arm is structurally unavailable. Used for FTS hit hydration, - /// where a missing row degrades to a skipped search hit downstream. - #[datastore_span(name = "get_events_by_ids_routed", system = "postgresql")] - pub async fn get_events_by_ids_routed( - &self, - path: &'static str, - community_id: CommunityId, - ids: &[&[u8]], - ) -> Result> { - match self.route_read(path, RoutePredicate::Bounded).await { - RouteDecision::Replica(mut tx, _entry, reason) => { - match event::get_events_by_ids_on(&mut tx, community_id, ids).await { - Ok(events) => { - Self::record_route(path, "replica", reason); - Ok(events) - } - Err(e) => { - tracing::warn!(path, "replica read failed; re-running on writer: {e}"); - Self::record_route(path, "writer", "replica_error"); - event::get_events_by_ids(&self.pool, community_id, ids).await - } - } - } - RouteDecision::Writer => event::get_events_by_ids(&self.pool, community_id, ids).await, - } - } - - /// Exclusively claim a batch of due matcher jobs from one community. - #[datastore_span(name = "claim_due_push_match_batch", system = "postgresql")] - pub async fn claim_due_push_match_batch( - &self, - limit: i64, - lease_until: DateTime, - ) -> Result> { - push::claim_due_match_batch(&self.pool, limit, lease_until).await - } - - /// Load active endpoint-enabled leases eligible for push matching. - #[datastore_span(name = "active_push_match_leases", system = "postgresql")] - pub async fn active_push_match_leases( - &self, - community: CommunityId, - ) -> Result> { - push::active_match_leases(&self.pool, community).await - } - - /// Complete matcher jobs from one claimed batch while the fence holds. - #[datastore_span(name = "complete_push_match_batch", system = "postgresql")] - pub async fn complete_push_match_batch( - &self, - community: CommunityId, - claim_id: uuid::Uuid, - event_ids: &[Vec], - ) -> Result { - push::complete_match_batch(&self.pool, community, claim_id, event_ids).await - } - - /// Release fenced matcher claims from one batch for retry. - #[datastore_span(name = "retry_push_match_batch", system = "postgresql")] - pub async fn retry_push_match_batch( - &self, - community: CommunityId, - claim_id: uuid::Uuid, - event_ids: &[Vec], - next: DateTime, - ) -> Result { - push::retry_match_batch(&self.pool, community, claim_id, event_ids, next).await - } - - /// Delete exhausted matcher jobs (periodic sweep, off the claim path). - #[datastore_span(name = "reap_exhausted_push_matches", system = "postgresql")] - pub async fn reap_exhausted_push_matches(&self) -> Result { - push::reap_exhausted_matches(&self.pool).await - } - - /// Idempotently enqueue a wake for a matched lease and event. - #[datastore_span(name = "enqueue_push_wake", system = "postgresql")] - pub async fn enqueue_push_wake( - &self, - community: CommunityId, - author: &[u8], - installation_id: &str, - wake: push::NewWake<'_>, - ) -> Result { - push::enqueue_wake(&self.pool, community, author, installation_id, wake).await - } - - /// Set-wise [`Self::enqueue_push_wake`]: one transaction per batch. - #[datastore_span(name = "enqueue_push_wakes", system = "postgresql")] - pub async fn enqueue_push_wakes( - &self, - community: CommunityId, - requests: &[push::WakeRequest], - ) -> Result> { - push::enqueue_wakes(&self.pool, community, requests).await - } - - /// Exclusively claim due wake jobs for one community. - #[datastore_span(name = "claim_due_push_wakes", system = "postgresql")] - pub async fn claim_due_push_wakes( - &self, - community: CommunityId, - limit: i64, - lease_until: DateTime, - ) -> Result> { - push::claim_due_wakes(&self.pool, community, limit, lease_until).await - } - - /// Revalidate a wake's claim, source event, and current lease before send. - #[datastore_span(name = "revalidate_push_wake", system = "postgresql")] - pub async fn revalidate_push_wake( - &self, - community: CommunityId, - id: Uuid, - claim_id: Uuid, - ) -> Result { - push::revalidate_wake_for_send(&self.pool, community, id, claim_id).await - } - - /// Mark a fenced wake claim delivered. - #[datastore_span(name = "complete_push_wake", system = "postgresql")] - pub async fn complete_push_wake( - &self, - community: CommunityId, - id: Uuid, - claim_id: Uuid, - ) -> Result { - push::complete_wake(&self.pool, community, id, claim_id).await - } - - /// Release a fenced wake claim for retry at the supplied time. - #[datastore_span(name = "retry_push_wake", system = "postgresql")] - pub async fn retry_push_wake( - &self, - community: CommunityId, - id: Uuid, - claim_id: Uuid, - next: DateTime, - ) -> Result { - push::retry_wake(&self.pool, community, id, claim_id, next).await - } - - /// Mark a fenced wake claim terminally failed. - #[datastore_span(name = "fail_push_wake", system = "postgresql")] - pub async fn fail_push_wake( - &self, - community: CommunityId, - id: Uuid, - claim_id: Uuid, - ) -> Result { - push::fail_wake(&self.pool, community, id, claim_id).await - } - - /// Disable an endpoint only if the specified lease generation is current. - #[datastore_span(name = "disable_push_endpoint", system = "postgresql")] - pub async fn disable_push_endpoint( - &self, - community: CommunityId, - author: &[u8], - installation_id: &str, - generation: i64, - ) -> Result { - push::disable_endpoint_generation( - &self.pool, - community, - author, - installation_id, - generation, - ) - .await - } - - /// Atomically persist a validated kind:30350 event and its effective lease. - #[allow(clippy::too_many_arguments)] - #[datastore_span(name = "accept_push_lease_event", system = "postgresql")] - pub async fn accept_push_lease_event( - &self, - community: CommunityId, - event: &nostr::Event, - installation_id: &str, - version: push::LeaseVersion<'_>, - active: Option>, - max_active_leases: i64, - ) -> Result { - push::accept_lease_event( - &self.pool, - community, - event, - installation_id, - version, - active, - max_active_leases, - ) - .await - } - - /// Atomically insert an event AND its thread metadata in a single transaction. - #[datastore_span(name = "insert_event_with_thread_metadata", system = "postgresql")] - pub async fn insert_event_with_thread_metadata( - &self, - community_id: CommunityId, - event: &nostr::Event, - channel_id: Option, - thread_meta: Option>, - ) -> Result<(StoredEvent, bool)> { - let result = event::insert_event_with_thread_metadata( - &self.pool, - community_id, - event, - channel_id, - thread_meta, - ) - .await?; - if result.1 { - if let Err(e) = insert_mentions(&self.pool, community_id, event, channel_id).await { - tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); - } - } - Ok(result) - } - - /// Atomically insert a kind:7 reaction event and its reaction row. - #[allow(clippy::too_many_arguments)] - #[datastore_span( - name = "insert_reaction_event_with_thread_metadata", - system = "postgresql" - )] - pub async fn insert_reaction_event_with_thread_metadata( - &self, - community_id: CommunityId, - event: &nostr::Event, - channel_id: Option, - thread_meta: Option>, - target_event_id: &[u8], - actor_pubkey: &[u8], - emoji: &str, - ) -> Result { - let outcome = event::insert_reaction_event_with_thread_metadata( - &self.pool, - community_id, - event, - channel_id, - thread_meta, - target_event_id, - actor_pubkey, - emoji, - ) - .await?; - if let event::ReactionEventInsertOutcome::Inserted { - was_inserted: true, .. - } = &outcome - { - if let Err(e) = insert_mentions(&self.pool, community_id, event, channel_id).await { - tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); - } - } - Ok(outcome) - } - - /// Query due reminders ready for delivery. - #[datastore_span(name = "query_due_reminders", system = "postgresql")] - pub async fn query_due_reminders( - &self, - now_secs: i64, - batch_limit: i64, - ) -> Result> { - event::query_due_reminders(&self.pool, now_secs, batch_limit).await - } - - /// Atomically claim a due reminder for delivery (cross-pod dedup). - #[datastore_span(name = "claim_due_reminder", system = "postgresql")] - pub async fn claim_due_reminder( - &self, - community_id: CommunityId, - event_id: &[u8], - event_created_at: chrono::DateTime, - ) -> Result { - event::claim_due_reminder(&self.pool, community_id, event_id, event_created_at).await - } - - /// Atomically claim a due reminder using a caller-supplied delivery stamp. - #[datastore_span(name = "claim_due_reminder_with_stamp", system = "postgresql")] - pub async fn claim_due_reminder_with_stamp( - &self, - community_id: CommunityId, - event_id: &[u8], - event_created_at: chrono::DateTime, - delivery_stamp: i64, - ) -> Result { - event::claim_due_reminder_with_stamp( - &self.pool, - community_id, - event_id, - event_created_at, - delivery_stamp, - ) - .await - } - - /// Release a claimed due reminder after a publish failure. - #[datastore_span(name = "release_due_reminder", system = "postgresql")] - pub async fn release_due_reminder( - &self, - community_id: CommunityId, - event_id: &[u8], - event_created_at: chrono::DateTime, - delivery_stamp: i64, - ) -> Result { - event::release_due_reminder( - &self.pool, - community_id, - event_id, - event_created_at, - delivery_stamp, - ) - .await - } - - /// Ensure a user record exists (upsert). - /// - /// Returns `true` if a new row was inserted (first time), `false` if it - /// already existed. Callers use the `true` return to increment - /// `buzz_users_created_total`. - #[datastore_span(name = "ensure_user", system = "postgresql")] - pub async fn ensure_user(&self, community_id: CommunityId, pubkey: &[u8]) -> Result { - user::ensure_user(&self.pool, community_id, pubkey).await - } - - /// Get a single user record by pubkey. - #[datastore_span(name = "get_user", system = "postgresql")] - pub async fn get_user( - &self, - community_id: CommunityId, - pubkey: &[u8], - ) -> Result> { - user::get_user(&self.pool, community_id, pubkey).await - } - - /// Update a user's profile fields. - #[datastore_span(name = "update_user_profile", system = "postgresql")] - pub async fn update_user_profile( - &self, - community_id: CommunityId, - pubkey: &[u8], - display_name: Option<&str>, - avatar_url: Option<&str>, - about: Option<&str>, - nip05_handle: Option<&str>, - ) -> Result<()> { - user::update_user_profile( - &self.pool, - community_id, - pubkey, - display_name, - avatar_url, - about, - nip05_handle, - ) - .await - } - - /// Look up a user by NIP-05 handle. - #[datastore_span(name = "get_user_by_nip05", system = "postgresql")] - pub async fn get_user_by_nip05( - &self, - community_id: CommunityId, - local_part: &str, - domain: &str, - ) -> Result> { - user::get_user_by_nip05(&self.pool, community_id, local_part, domain).await - } - - /// Search users by display name, NIP-05 handle, or pubkey prefix. - #[datastore_span(name = "search_users", system = "postgresql")] - pub async fn search_users( - &self, - community_id: CommunityId, - query: &str, - limit: u32, - ) -> Result> { - user::search_users(&self.pool, community_id, query, limit).await - } - - /// Atomically set agent owner — only if no owner is currently assigned. - /// Returns Ok(true) if set, Ok(false) if an owner already exists. - #[datastore_span(name = "set_agent_owner", system = "postgresql")] - pub async fn set_agent_owner( - &self, - community_id: CommunityId, - agent_pubkey: &[u8], - owner_pubkey: &[u8], - ) -> Result { - user::set_agent_owner(&self.pool, community_id, agent_pubkey, owner_pubkey).await - } - - /// Get the channel_add_policy and agent_owner_pubkey for a user. - #[datastore_span(name = "get_agent_channel_policy", system = "postgresql")] - pub async fn get_agent_channel_policy( - &self, - community_id: CommunityId, - pubkey: &[u8], - ) -> Result>)>> { - user::get_agent_channel_policy(&self.pool, community_id, pubkey).await - } - - /// Check whether `actor_pubkey` is the agent owner of `target_pubkey`. - #[datastore_span(name = "is_agent_owner", system = "postgresql")] - pub async fn is_agent_owner( - &self, - community_id: CommunityId, - target_pubkey: &[u8], - actor_pubkey: &[u8], - ) -> Result { - user::is_agent_owner(&self.pool, community_id, target_pubkey, actor_pubkey).await - } - - /// Set the channel_add_policy for a user. - #[datastore_span(name = "set_channel_add_policy", system = "postgresql")] - pub async fn set_channel_add_policy( - &self, - community_id: CommunityId, - pubkey: &[u8], - policy: &str, - ) -> Result<()> { - user::set_channel_add_policy(&self.pool, community_id, pubkey, policy).await - } - - /// Find an existing DM by its participant hash. - #[datastore_span(name = "find_dm_by_participants", system = "postgresql")] - pub async fn find_dm_by_participants( - &self, - community_id: CommunityId, - participant_hash: &[u8], - ) -> Result> { - dm::find_dm_by_participants(&self.pool, community_id, participant_hash).await - } - - /// Create or return an existing DM channel. - #[datastore_span(name = "create_dm", system = "postgresql")] - pub async fn create_dm( - &self, - community_id: CommunityId, - participants: &[&[u8]], - created_by: &[u8], - ) -> Result { - dm::create_dm(&self.pool, community_id, participants, created_by).await - } - - /// List all DMs for a user. - #[datastore_span(name = "list_dms_for_user", system = "postgresql")] - pub async fn list_dms_for_user( - &self, - community_id: CommunityId, - pubkey: &[u8], - limit: u32, - cursor: Option, - ) -> Result> { - dm::list_dms_for_user(&self.pool, community_id, pubkey, limit, cursor).await - } - - /// Open or retrieve a DM for the given participants. - #[datastore_span(name = "open_dm", system = "postgresql")] - pub async fn open_dm( - &self, - community_id: CommunityId, - pubkeys: &[&[u8]], - created_by: &[u8], - ) -> Result<(channel::ChannelRecord, bool)> { - dm::open_dm(&self.pool, community_id, pubkeys, created_by).await - } - - /// Hide a DM channel for a specific user. - /// - /// The DM is not deleted — it can be restored by opening a new DM with - /// the same participants. - #[datastore_span(name = "hide_dm", system = "postgresql")] - pub async fn hide_dm( - &self, - community_id: CommunityId, - channel_id: Uuid, - pubkey: &[u8], - ) -> Result<()> { - dm::hide_dm(&self.pool, community_id, channel_id, pubkey).await - } - - /// Unhide a DM channel for a specific user. - #[datastore_span(name = "unhide_dm", system = "postgresql")] - pub async fn unhide_dm( - &self, - community_id: CommunityId, - channel_id: Uuid, - pubkey: &[u8], - ) -> Result<()> { - dm::unhide_dm(&self.pool, community_id, channel_id, pubkey).await - } - - /// List the channel IDs of all DMs the given user currently has hidden. - #[datastore_span(name = "list_hidden_dms", system = "postgresql")] - pub async fn list_hidden_dms( - &self, - community_id: CommunityId, - pubkey: &[u8], - ) -> Result> { - dm::list_hidden_dms(&self.pool, community_id, pubkey).await - } - - /// Insert thread metadata. - #[allow(clippy::too_many_arguments)] - #[datastore_span(name = "insert_thread_metadata", system = "postgresql")] - pub async fn insert_thread_metadata( - &self, - community_id: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - channel_id: Uuid, - parent_event_id: Option<&[u8]>, - parent_event_created_at: Option>, - root_event_id: Option<&[u8]>, - root_event_created_at: Option>, - depth: i32, - broadcast: bool, - ) -> Result<()> { - thread::insert_thread_metadata( - &self.pool, - community_id, - event_id, - event_created_at, - channel_id, - parent_event_id, - parent_event_created_at, - root_event_id, - root_event_created_at, - depth, - broadcast, - ) - .await - } - - /// Fetch replies under a root event. - /// - /// Routing mirrors [`Db::get_channel_window_with_session`]: a head - /// fetch (`cursor: None`) is Predicate A (bounded staleness, gated by - /// the default-off head budget); cursor pages are Predicate B - /// (completeness). Thread pagination walks **forward** from oldest to - /// newest, so a cursor carries no upper bound — instead the served page - /// is post-verified against the wall the serving session proved: - /// - /// - an under-`limit` page is a candidate terminal page — the client - /// treats it as EOF, so it is re-run on the writer to keep the EOF - /// decision authoritative (a lagged replica could truncate the tail); - /// - a full page whose newest row exceeds the proved fence wall could - /// straddle a row the session has not replayed (commit order is not - /// `created_at` order), so it is also re-run on the writer. Only a - /// full page that sits entirely at or below the proved wall is served - /// from the replica. - /// - /// A head fetch routed under Predicate A skips the re-run: bounded - /// staleness (missing at most the freshest budget-window of replies) is - /// exactly the semantic the head gate accepts. - #[datastore_span(name = "get_thread_replies", system = "postgresql")] - pub async fn get_thread_replies( - &self, - community_id: CommunityId, - root_event_id: &[u8], - depth_limit: Option, - limit: u32, - cursor: Option<&[u8]>, - ) -> Result> { - let (path, predicate): (&'static str, RoutePredicate) = match cursor { - Some(_) => ( - "thread_cursor", - RoutePredicate::CoveredPostVerified { - proof: ChannelScoped::from_thread_metadata_join(), - }, - ), - None => ("thread_head", RoutePredicate::Bounded), - }; - if let RouteDecision::Replica(mut tx, entry, reason) = - self.route_read(path, predicate).await - { - match thread::get_thread_replies_on( - &mut tx, - community_id, - root_event_id, - depth_limit, - limit, - cursor, - ) - .await - { - Ok(replies) => { - if cursor.is_none() { - // Predicate A: bounded-stale head page, served as proved. - Self::record_route(path, "replica", reason); - return Ok(replies); - } - let full = replies.len() >= limit as usize; - let below_fence = replies - .last() - .is_some_and(|tail| tail.created_at <= entry.fence_wall); - if full && below_fence { - Self::record_route(path, "replica", reason); - return Ok(replies); - } - // Candidate terminal page, or page reaching above the - // proved wall — verify against the writer. Recorded as - // the request's ONLY route event: the replica leg was - // discarded, so counting it would overstate offload. - Self::record_route("thread_eof", "writer", "stale"); - } - Err(e) => { - // Mid-request replica failure (e.g. a hot-standby - // recovery conflict) fails closed to the writer. - tracing::warn!( - error = %e, - path, - "replica thread query failed; re-running on writer" - ); - Self::record_route(path, "writer", "replica_error"); - } - } - } - thread::get_thread_replies( - &self.pool, - community_id, - root_event_id, - depth_limit, - limit, - cursor, - ) - .await - } - - /// Fetch aggregated thread stats. - #[datastore_span(name = "get_thread_summary", system = "postgresql")] - pub async fn get_thread_summary( - &self, - community_id: CommunityId, - event_id: &[u8], - ) -> Result> { - thread::get_thread_summary(&self.pool, community_id, event_id).await - } - - /// One channel window: top-level rows + summaries + server `has_more`. - /// - /// Convenience wrapper over [`Db::get_channel_window_with_session`] for - /// callers with no follow-up queries; the serving session is released. - pub async fn get_channel_window( - &self, - community_id: CommunityId, - channel_id: Uuid, - limit: u32, - cursor: Option<(DateTime, Vec)>, - kind_filter: Option<&[u32]>, - ) -> Result { - self.get_channel_window_with_session(community_id, channel_id, limit, cursor, kind_filter) - .await - .map(|(window, _session)| window) - } - - /// [`Db::get_channel_window`], additionally returning the session that - /// served the page so request-scoped follow-ups (the aux closure) run on - /// the same proved connection. - /// - /// Routing: - /// - /// - **Cursor page** (Predicate B — completeness): scrolls *backward* - /// into history bounded above by the cursor timestamp (`created_at < - /// ts`, or `= ts` with the id tiebreak), so it may be served by a - /// replica session when one is configured AND that session **proves** - /// coverage of the cursor timestamp: the heartbeat token/epoch is - /// observed on the exact connection that will serve the page and - /// resolved against the fence's retained ring ([`replica_fence`]). - /// - **Head fetch** (Predicate A — bounded staleness): served by a - /// proved replica session only when the head gate is configured - /// ([`DbConfig::replica_read_max_age_ms`], default off) and the - /// proved entry is within the budget. This trades a bounded staleness - /// window (budget plus probe cadence) on the GET leg for writer - /// offload. NOTE: enabling the budget also breaks read-your-own-writes - /// on the GET leg; the client-side WS `since`-overlap union intended - /// to cover fresh events has NOT shipped yet — do not enable - /// `BUZZ_REPLICA_HEAD_MAX_AGE_SECS` until it has, proven by a - /// post-then-immediately-refetch test. - /// - /// Every failure fails closed to the writer and is recorded in - /// `buzz_db_route_decision`. - #[datastore_span(name = "get_channel_window", system = "postgresql")] - pub async fn get_channel_window_with_session( - &self, - community_id: CommunityId, - channel_id: Uuid, - limit: u32, - cursor: Option<(DateTime, Vec)>, - kind_filter: Option<&[u32]>, - ) -> Result<(thread::ChannelWindow, ReadSession)> { - let path: &'static str = if cursor.is_some() { - "channel_cursor" - } else { - "channel_head" - }; - match self - .route_read( - path, - RoutePredicate::from_channel_cursor(channel_id, &cursor), - ) - .await - { - RouteDecision::Replica(mut tx, _entry, reason) => { - match thread::get_channel_window_on( - &mut tx, - community_id, - channel_id, - limit, - cursor.clone(), - kind_filter, - ) - .await - { - Ok(window) => { - Self::record_route(path, "replica", reason); - return Ok(( - window, - ReadSession { - inner: ReadSessionInner::Replica { - tx, - writer: self.pool.clone(), - }, - }, - )); - } - Err(e) => { - // A mid-request replica failure (e.g. a hot-standby - // recovery conflict cancelling the held snapshot) - // fails closed to the writer: a stale-but-served - // page, never an error the writer could have - // answered. Dropping `tx` rolls the reader - // transaction back. - tracing::warn!( - error = %e, - path, - "replica window query failed; re-running on writer" - ); - Self::record_route(path, "writer", "replica_error"); - } - } - } - RouteDecision::Writer => {} - } - let window = thread::get_channel_window( - &self.pool, - community_id, - channel_id, - limit, - cursor, - kind_filter, - ) - .await?; - Ok(( - window, - ReadSession { - inner: ReadSessionInner::Writer(self.pool.clone()), - }, - )) - } - - /// Shared route decision for one read: evaluate the predicate against a - /// proved reader session and record the decision. Fail closed to the - /// writer everywhere. - async fn route_read(&self, path: &'static str, predicate: RoutePredicate) -> RouteDecision { - let Some(read_pool) = &self.read_pool else { - Self::record_route(path, "writer", "disabled"); - return RouteDecision::Writer; - }; - // Cheap prechecks on the shared ring before spending a reader - // checkout; the connection-local observation still has to prove it. - let Some(newest) = self.fence.newest() else { - Self::record_route(path, "writer", "uninitialized"); - return RouteDecision::Writer; - }; - // Precheck helpers against the newest shared entry: if the newest - // cannot satisfy an arm, no proved (older-or-equal) entry can. - let bounded_precheck = - |budget: &Option| -> std::result::Result<(), &'static str> { - match budget { - Some(budget) if newest.committed_at.elapsed() <= *budget => Ok(()), - Some(_) => Err("stale"), - None => Err("disabled"), - } - }; - let covered_precheck = |upper: &DateTime| -> std::result::Result<(), &'static str> { - if *upper <= newest.fence_wall { - Ok(()) - } else { - Err("stale") - } - }; - let precheck = match &predicate { - RoutePredicate::Bounded => bounded_precheck(&self.replica_read_max_age), - RoutePredicate::Covered { upper, .. } => covered_precheck(upper), - // No upper bound: the caller post-verifies served rows. - RoutePredicate::CoveredPostVerified { .. } => Ok(()), - // Covered first (no budget dependence), else bounded. - RoutePredicate::BoundedOrCovered { upper, .. } => { - covered_precheck(upper).or_else(|_| bounded_precheck(&self.replica_read_max_age)) - } - }; - if let Err(reason) = precheck { - Self::record_route(path, "writer", reason); - return RouteDecision::Writer; - } - match self.proved_reader(read_pool).await { - Ok((tx, entry)) => { - // Re-evaluate against the entry the session actually proved - // (it may be older than the shared newest). - let bounded_holds = || { - self.replica_read_max_age - .is_some_and(|budget| entry.committed_at.elapsed() <= budget) - }; - let verdict: Option<&'static str> = match &predicate { - RoutePredicate::Bounded => bounded_holds().then_some("fresh"), - RoutePredicate::Covered { upper, .. } => { - (*upper <= entry.fence_wall).then_some("covered") - } - // No upper bound: the caller post-verifies the served - // rows against the proved wall. - RoutePredicate::CoveredPostVerified { .. } => Some("covered"), - RoutePredicate::BoundedOrCovered { upper, .. } => { - if *upper <= entry.fence_wall { - Some("covered") - } else { - bounded_holds().then_some("fresh") - } - } - }; - match verdict { - Some(reason) => RouteDecision::Replica(tx, entry, reason), - None => { - // The session proves an older entry than the - // predicate needs (replication lag) — fail closed. - Self::record_route(path, "writer", "stale"); - RouteDecision::Writer - } - } - } - Err(reason) => { - Self::record_route(path, "writer", reason); - RouteDecision::Writer - } - } - } - - /// Look up a single thread_metadata row by event_id. - #[datastore_span(name = "get_thread_metadata_by_event", system = "postgresql")] - pub async fn get_thread_metadata_by_event( - &self, - community_id: CommunityId, - event_id: &[u8], - ) -> Result> { - thread::get_thread_metadata_by_event(&self.pool, community_id, event_id).await - } - - /// Decrement reply counts. - #[datastore_span(name = "decrement_reply_count", system = "postgresql")] - pub async fn decrement_reply_count( - &self, - community_id: CommunityId, - parent_event_id: &[u8], - root_event_id: Option<&[u8]>, - ) -> Result<()> { - thread::decrement_reply_count(&self.pool, community_id, parent_event_id, root_event_id) - .await - } - - /// Add (or re-activate) a reaction. - #[datastore_span(name = "add_reaction", system = "postgresql")] - pub async fn add_reaction( - &self, - community: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - pubkey: &[u8], - emoji: &str, - reaction_event_id: Option<&[u8]>, - ) -> Result { - reaction::add_reaction( - &self.pool, - community, - event_id, - event_created_at, - pubkey, - emoji, - reaction_event_id, - ) - .await - } - - /// Soft-delete a reaction. - #[datastore_span(name = "remove_reaction", system = "postgresql")] - pub async fn remove_reaction( - &self, - community: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - pubkey: &[u8], - emoji: &str, - ) -> Result { - reaction::remove_reaction( - &self.pool, - community, - event_id, - event_created_at, - pubkey, - emoji, - ) - .await - } - - /// Soft-delete a reaction by its source event ID. - #[datastore_span(name = "remove_reaction_by_source_event_id", system = "postgresql")] - pub async fn remove_reaction_by_source_event_id( - &self, - community: CommunityId, - reaction_event_id: &[u8], - ) -> Result { - reaction::remove_reaction_by_source_event_id(&self.pool, community, reaction_event_id).await - } - - /// Look up the active reaction row for one actor + emoji + target tuple. - #[datastore_span(name = "get_active_reaction_record", system = "postgresql")] - pub async fn get_active_reaction_record( - &self, - community: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - pubkey: &[u8], - emoji: &str, - ) -> Result> { - reaction::get_active_reaction_record( - &self.pool, - community, - event_id, - event_created_at, - pubkey, - emoji, - ) - .await - } - - /// Backfill the source event ID on an active reaction row. - #[datastore_span(name = "set_reaction_event_id", system = "postgresql")] - pub async fn set_reaction_event_id( - &self, - community: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - pubkey: &[u8], - emoji: &str, - reaction_event_id: &[u8], - ) -> Result { - reaction::set_reaction_event_id( - &self.pool, - community, - event_id, - event_created_at, - pubkey, - emoji, - reaction_event_id, - ) - .await - } - - /// Get all active reactions for an event, grouped by emoji. - #[datastore_span(name = "get_reactions", system = "postgresql")] - pub async fn get_reactions( - &self, - community: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - limit: u32, - cursor: Option<&str>, - ) -> Result> { - reaction::get_reactions( - &self.pool, - community, - event_id, - event_created_at, - limit, - cursor, - ) - .await - } - - /// Batch-fetch emoji counts for a set of (event_id, event_created_at) pairs. - #[datastore_span(name = "get_reactions_bulk", system = "postgresql")] - pub async fn get_reactions_bulk( - &self, - community: CommunityId, - event_ids: &[(&[u8], DateTime)], - ) -> Result> { - reaction::get_reactions_bulk(&self.pool, community, event_ids).await - } - - /// Find events that @mention the given pubkey. - #[datastore_span(name = "query_feed_mentions", system = "postgresql")] - pub async fn query_feed_mentions( - &self, - community: CommunityId, - pubkey_bytes: &[u8], - accessible_channel_ids: &[Uuid], - since: Option>, - limit: i64, - ) -> Result> { - feed::query_mentions( - &self.pool, - community, - pubkey_bytes, - accessible_channel_ids, - since, - limit, - ) - .await - } - - /// [`Db::query_feed_mentions`] with replica routing — same contract and - /// classification-table requirement as [`Db::query_events_routed`]. - /// - /// Feed queries route on the BOUNDED arm only: the `accessible_channel_ids` - /// parameter admits community-global rows alongside channel rows, so no - /// single channel's fence floor can prove completeness — the covered arm - /// is structurally unavailable, not merely unchosen. - #[datastore_span(name = "query_feed_mentions_routed", system = "postgresql")] - pub async fn query_feed_mentions_routed( - &self, - path: &'static str, - community: CommunityId, - pubkey_bytes: &[u8], - accessible_channel_ids: &[Uuid], - since: Option>, - limit: i64, - ) -> Result> { - match self.route_read(path, RoutePredicate::Bounded).await { - RouteDecision::Replica(mut tx, _entry, reason) => { - match feed::query_mentions_on( - &mut tx, - community, - pubkey_bytes, - accessible_channel_ids, - since, - limit, - ) - .await - { - Ok(events) => { - Self::record_route(path, "replica", reason); - Ok(events) - } - Err(e) => { - tracing::warn!(path, "replica read failed; re-running on writer: {e}"); - Self::record_route(path, "writer", "replica_error"); - feed::query_mentions( - &self.pool, - community, - pubkey_bytes, - accessible_channel_ids, - since, - limit, - ) - .await - } - } - } - RouteDecision::Writer => { - feed::query_mentions( - &self.pool, - community, - pubkey_bytes, - accessible_channel_ids, - since, - limit, - ) - .await - } - } - } - - /// Find events that require action from the given pubkey. - #[datastore_span(name = "query_feed_needs_action", system = "postgresql")] - pub async fn query_feed_needs_action( - &self, - community: CommunityId, - pubkey_bytes: &[u8], - accessible_channel_ids: &[Uuid], - since: Option>, - limit: i64, - ) -> Result> { - feed::query_needs_action( - &self.pool, - community, - pubkey_bytes, - accessible_channel_ids, - since, - limit, - ) - .await - } - - /// [`Db::query_feed_needs_action`] with replica routing — BOUNDED arm - /// only; see [`Db::query_feed_mentions_routed`] for why the covered arm - /// is structurally unavailable to feed queries. - #[datastore_span(name = "query_feed_needs_action_routed", system = "postgresql")] - pub async fn query_feed_needs_action_routed( - &self, - path: &'static str, - community: CommunityId, - pubkey_bytes: &[u8], - accessible_channel_ids: &[Uuid], - since: Option>, - limit: i64, - ) -> Result> { - match self.route_read(path, RoutePredicate::Bounded).await { - RouteDecision::Replica(mut tx, _entry, reason) => { - match feed::query_needs_action_on( - &mut tx, - community, - pubkey_bytes, - accessible_channel_ids, - since, - limit, - ) - .await - { - Ok(events) => { - Self::record_route(path, "replica", reason); - Ok(events) - } - Err(e) => { - tracing::warn!(path, "replica read failed; re-running on writer: {e}"); - Self::record_route(path, "writer", "replica_error"); - feed::query_needs_action( - &self.pool, - community, - pubkey_bytes, - accessible_channel_ids, - since, - limit, - ) - .await - } - } - } - RouteDecision::Writer => { - feed::query_needs_action( - &self.pool, - community, - pubkey_bytes, - accessible_channel_ids, - since, - limit, - ) - .await - } - } - } - - /// Find recent activity across accessible channels. - #[datastore_span(name = "query_feed_activity", system = "postgresql")] - pub async fn query_feed_activity( - &self, - community: CommunityId, - accessible_channel_ids: &[Uuid], - since: Option>, - limit: i64, - ) -> Result> { - feed::query_activity(&self.pool, community, accessible_channel_ids, since, limit).await - } - - /// [`Db::query_feed_activity`] with replica routing — BOUNDED arm only; - /// see [`Db::query_feed_mentions_routed`] for why the covered arm is - /// structurally unavailable to feed queries. - #[datastore_span(name = "query_feed_activity_routed", system = "postgresql")] - pub async fn query_feed_activity_routed( - &self, - path: &'static str, - community: CommunityId, - accessible_channel_ids: &[Uuid], - since: Option>, - limit: i64, - ) -> Result> { - match self.route_read(path, RoutePredicate::Bounded).await { - RouteDecision::Replica(mut tx, _entry, reason) => { - match feed::query_activity_on( - &mut tx, - community, - accessible_channel_ids, - since, - limit, - ) - .await - { - Ok(events) => { - Self::record_route(path, "replica", reason); - Ok(events) - } - Err(e) => { - tracing::warn!(path, "replica read failed; re-running on writer: {e}"); - Self::record_route(path, "writer", "replica_error"); - feed::query_activity( - &self.pool, - community, - accessible_channel_ids, - since, - limit, - ) - .await - } - } - } - RouteDecision::Writer => { - feed::query_activity(&self.pool, community, accessible_channel_ids, since, limit) - .await - } - } - } - - /// Create a new API token record. - #[allow(clippy::too_many_arguments)] - #[datastore_span(name = "create_api_token", system = "postgresql")] - pub async fn create_api_token( - &self, - community_id: CommunityId, - token_hash: &[u8], - owner_pubkey: &[u8], - name: &str, - scopes: &[String], - channel_ids: Option<&[Uuid]>, - expires_at: Option>, - ) -> Result { - api_token::create_api_token( - &self.pool, - *community_id.as_uuid(), - token_hash, - owner_pubkey, - name, - scopes, - channel_ids, - expires_at, - ) - .await - } - - /// Atomic conditional INSERT with 10-token limit (per (community, owner)). - #[allow(clippy::too_many_arguments)] - #[datastore_span(name = "create_api_token_if_under_limit", system = "postgresql")] - pub async fn create_api_token_if_under_limit( - &self, - community_id: CommunityId, - token_hash: &[u8], - owner_pubkey: &[u8], - name: &str, - scopes: &[String], - channel_ids: Option<&[Uuid]>, - expires_at: Option>, - ) -> Result> { - api_token::create_api_token_if_under_limit( - &self.pool, - *community_id.as_uuid(), - token_hash, - owner_pubkey, - name, - scopes, - channel_ids, - expires_at, - ) - .await - } - - /// Look up an active (non-revoked) API token by its SHA-256 hash, - /// scoped to the request's community. - /// - /// See [`api_token::get_api_token_by_hash_including_revoked`] for the - /// row-44 conformance rationale — the `(community_id, token_hash)` key - /// is enforced both by the storage UNIQUE index and by this WHERE clause. - #[datastore_span(name = "get_api_token_by_hash", system = "postgresql")] - pub async fn get_api_token_by_hash( - &self, - community_id: CommunityId, - hash: &[u8], - ) -> Result> { - let row = sqlx::query( - r#" - SELECT id, token_hash, owner_pubkey, name, scopes, channel_ids, - created_at, expires_at, last_used_at, revoked_at - FROM api_tokens - WHERE community_id = $1 AND token_hash = $2 AND revoked_at IS NULL - "#, - ) - .bind(community_id.as_uuid()) - .bind(hash) - .fetch_optional(&self.pool) - .await?; - - match row { - None => Ok(None), - Some(r) => parse_api_token_row(r).map(Some), - } - } - - /// Look up an API token by hash, including revoked, scoped to community. - #[datastore_span( - name = "get_api_token_by_hash_including_revoked", - system = "postgresql" - )] - pub async fn get_api_token_by_hash_including_revoked( - &self, - community_id: CommunityId, - hash: &[u8], - ) -> Result> { - api_token::get_api_token_by_hash_including_revoked( - &self.pool, - *community_id.as_uuid(), - hash, - ) - .await - } - - /// Record a token usage (update `last_used_at`), scoped to community. - #[datastore_span(name = "touch_api_token", system = "postgresql")] - pub async fn touch_api_token(&self, community_id: CommunityId, hash: &[u8]) -> Result<()> { - sqlx::query( - "UPDATE api_tokens SET last_used_at = NOW() WHERE community_id = $1 AND token_hash = $2", - ) - .bind(community_id.as_uuid()) - .bind(hash) - .execute(&self.pool) - .await?; - Ok(()) - } - - /// Alias for [`Self::touch_api_token`]. - pub async fn update_token_last_used( - &self, - community_id: CommunityId, - hash: &[u8], - ) -> Result<()> { - self.touch_api_token(community_id, hash).await - } - - /// List all active (non-revoked) tokens in a community, newest first. - #[datastore_span(name = "list_active_tokens", system = "postgresql")] - pub async fn list_active_tokens(&self, community_id: CommunityId) -> Result> { - let rows = sqlx::query( - r#" - SELECT id, name, owner_pubkey, scopes, created_at, expires_at - FROM api_tokens - WHERE community_id = $1 AND revoked_at IS NULL - ORDER BY created_at DESC - LIMIT 1000 - "#, - ) - .bind(community_id.as_uuid()) - .fetch_all(&self.pool) - .await?; - - let mut out = Vec::with_capacity(rows.len()); - for row in rows { - let id: Uuid = row.try_get("id")?; - let scopes_json: serde_json::Value = row.try_get("scopes")?; - let scopes: Vec = serde_json::from_value(scopes_json) - .map_err(|e| DbError::InvalidData(format!("scopes JSON: {e}")))?; - - out.push(TokenSummary { - id, - name: row.try_get("name")?, - owner_pubkey: row.try_get("owner_pubkey")?, - scopes, - created_at: row.try_get("created_at")?, - expires_at: row.try_get("expires_at")?, - }); - } - Ok(out) - } - - /// List all tokens for a (community, owner) pair (including revoked). - #[datastore_span(name = "list_tokens_by_owner", system = "postgresql")] - pub async fn list_tokens_by_owner( - &self, - community_id: CommunityId, - pubkey: &[u8], - ) -> Result> { - api_token::list_tokens_by_owner(&self.pool, *community_id.as_uuid(), pubkey).await - } - - /// Revoke a single token by ID, scoped to (community, owner). - #[datastore_span(name = "revoke_token", system = "postgresql")] - pub async fn revoke_token( - &self, - community_id: CommunityId, - id: Uuid, - owner_pubkey: &[u8], - revoked_by: &[u8], - ) -> Result { - api_token::revoke_token( - &self.pool, - *community_id.as_uuid(), - id, - owner_pubkey, - revoked_by, - ) - .await - } - - /// Revoke all active tokens for a (community, owner) pair. - #[datastore_span(name = "revoke_all_tokens", system = "postgresql")] - pub async fn revoke_all_tokens( - &self, - community_id: CommunityId, - owner_pubkey: &[u8], - revoked_by: &[u8], - ) -> Result { - api_token::revoke_all_tokens( - &self.pool, - *community_id.as_uuid(), - owner_pubkey, - revoked_by, - ) - .await - } - - /// Create a new workflow. - #[datastore_span(name = "create_workflow", system = "postgresql")] - pub async fn create_workflow( - &self, - community_id: CommunityId, - channel_id: Option, - owner_pubkey: &[u8], - name: &str, - definition_json: &str, - definition_hash: &[u8], - ) -> Result { - workflow::create_workflow( - &self.pool, - community_id, - channel_id, - owner_pubkey, - name, - definition_json, - definition_hash, - ) - .await - } - - /// Insert or update a workflow using its NIP-33 `d`-tag UUID. - #[allow(clippy::too_many_arguments)] - #[datastore_span(name = "upsert_workflow", system = "postgresql")] - pub async fn upsert_workflow( - &self, - community_id: CommunityId, - id: Uuid, - channel_id: Option, - owner_pubkey: &[u8], - name: &str, - definition_json: &str, - definition_hash: &[u8], - ) -> Result<()> { - workflow::upsert_workflow( - &self.pool, - community_id, - id, - channel_id, - owner_pubkey, - name, - definition_json, - definition_hash, - ) - .await - } - - /// Fetch a single workflow by ID, scoped to its community. - #[datastore_span(name = "get_workflow", system = "postgresql")] - pub async fn get_workflow( - &self, - community_id: CommunityId, - id: Uuid, - ) -> Result { - workflow::get_workflow(&self.pool, community_id, id).await - } - - /// List workflows for a channel. - #[datastore_span(name = "list_channel_workflows", system = "postgresql")] - pub async fn list_channel_workflows( - &self, - community_id: CommunityId, - channel_id: Uuid, - limit: Option, - offset: Option, - ) -> Result> { - workflow::list_channel_workflows(&self.pool, community_id, channel_id, limit, offset).await - } - - /// List active, enabled workflows for a channel. - #[datastore_span(name = "list_enabled_channel_workflows", system = "postgresql")] - pub async fn list_enabled_channel_workflows( - &self, - community_id: CommunityId, - channel_id: Uuid, - ) -> Result> { - workflow::list_enabled_channel_workflows(&self.pool, community_id, channel_id).await - } - - /// List all active, enabled schedule-triggered workflows. - #[datastore_span(name = "list_all_enabled_workflows", system = "postgresql")] - pub async fn list_all_enabled_workflows(&self) -> Result> { - workflow::list_all_enabled_workflows(&self.pool).await - } - - /// Claim a scheduled workflow fire for an authoritative schedule instant. - /// - /// Returns `Some` only for the first pod to claim `(community_id, - /// workflow_id, scheduled_for)`; all other pods must skip creating a run. - /// `community_id` is server provenance (the workflow row's own community - /// from the scheduler scan), never client-supplied — `workflows` is keyed - /// `(community_id, id)`, so the claim must bind both to avoid fanning - /// across communities that share the workflow UUID. - #[datastore_span(name = "claim_scheduled_workflow_fire", system = "postgresql")] - pub async fn claim_scheduled_workflow_fire( - &self, - community_id: CommunityId, - workflow_id: Uuid, - scheduled_for: chrono::DateTime, - ) -> Result> { - workflow::claim_scheduled_workflow_fire( - &self.pool, - community_id, - workflow_id, - scheduled_for, - ) - .await - } - - /// Fetch the latest claimed schedule instant for interval trigger anchoring. - #[datastore_span(name = "latest_scheduled_workflow_fire", system = "postgresql")] - pub async fn latest_scheduled_workflow_fire( - &self, - community_id: CommunityId, - workflow_id: Uuid, - ) -> Result>> { - workflow::latest_scheduled_workflow_fire(&self.pool, community_id, workflow_id).await - } - - /// Attach the workflow run id created from a won scheduled-fire claim. - #[datastore_span(name = "attach_scheduled_workflow_run", system = "postgresql")] - pub async fn attach_scheduled_workflow_run( - &self, - community_id: CommunityId, - workflow_id: Uuid, - scheduled_for: chrono::DateTime, - workflow_run_id: Uuid, - ) -> Result { - workflow::attach_scheduled_workflow_run( - &self.pool, - community_id, - workflow_id, - scheduled_for, - workflow_run_id, - ) - .await - } - - /// Delete old scheduled workflow fire claims before a retention cutoff. - #[datastore_span(name = "prune_scheduled_workflow_fires_before", system = "postgresql")] - pub async fn prune_scheduled_workflow_fires_before( - &self, - older_than: chrono::DateTime, - ) -> Result { - workflow::prune_scheduled_workflow_fires_before(&self.pool, older_than).await - } - - /// Update a workflow's name, definition, and hash. - #[datastore_span(name = "update_workflow", system = "postgresql")] - pub async fn update_workflow( - &self, - community_id: CommunityId, - id: Uuid, - name: &str, - definition_json: &str, - definition_hash: &[u8], - ) -> Result<()> { - workflow::update_workflow( - &self.pool, - community_id, - id, - name, - definition_json, - definition_hash, - ) - .await - } - - /// Update a workflow's status. - #[datastore_span(name = "update_workflow_status", system = "postgresql")] - pub async fn update_workflow_status( - &self, - community_id: CommunityId, - id: Uuid, - status: workflow::WorkflowStatus, - ) -> Result<()> { - workflow::update_workflow_status(&self.pool, community_id, id, status).await - } - - /// Enable or disable a workflow. - #[datastore_span(name = "set_workflow_enabled", system = "postgresql")] - pub async fn set_workflow_enabled( - &self, - community_id: CommunityId, - id: Uuid, - enabled: bool, - ) -> Result<()> { - workflow::set_workflow_enabled(&self.pool, community_id, id, enabled).await - } - - /// Disable all of an owner's workflows in a channel (SEC-006, on - /// membership loss). Returns the number of workflows disabled. - #[datastore_span(name = "disable_workflows_for_owner_in_channel", system = "postgresql")] - pub async fn disable_workflows_for_owner_in_channel( - &self, - community_id: CommunityId, - channel_id: Uuid, - owner_pubkey: &[u8], - ) -> Result { - workflow::disable_workflows_for_owner_in_channel( - &self.pool, - community_id, - channel_id, - owner_pubkey, - ) - .await - } - - /// Delete a workflow and all its runs/approvals. - #[datastore_span(name = "delete_workflow", system = "postgresql")] - pub async fn delete_workflow(&self, community_id: CommunityId, id: Uuid) -> Result<()> { - workflow::delete_workflow(&self.pool, community_id, id).await - } - - /// Delete a workflow only when it belongs to the provided owner. - /// Returns the deleted workflow's `channel_id`. - #[datastore_span(name = "delete_workflow_for_owner", system = "postgresql")] - pub async fn delete_workflow_for_owner( - &self, - community_id: CommunityId, - id: Uuid, - owner_pubkey: &[u8], - ) -> Result> { - workflow::delete_workflow_for_owner(&self.pool, community_id, id, owner_pubkey).await - } - - /// Find a workflow by owner pubkey and name within a community. Used for - /// NIP-09 a-tag deletion where the d-tag is the workflow name (not UUID). - #[datastore_span(name = "find_workflow_by_owner_and_name", system = "postgresql")] - pub async fn find_workflow_by_owner_and_name( - &self, - community_id: CommunityId, - owner_pubkey: &[u8], - name: &str, - ) -> Result> { - workflow::find_by_owner_and_name(&self.pool, community_id, owner_pubkey, name).await - } - - /// Create a new workflow run. - #[datastore_span(name = "create_workflow_run", system = "postgresql")] - pub async fn create_workflow_run( - &self, - community_id: CommunityId, - workflow_id: Uuid, - trigger_event_id: Option<&[u8]>, - trigger_context: Option<&serde_json::Value>, - ) -> Result { - workflow::create_workflow_run( - &self.pool, - community_id, - workflow_id, - trigger_event_id, - trigger_context, - ) - .await - } - - /// Fetch a single workflow run, scoped to its community. - #[datastore_span(name = "get_workflow_run", system = "postgresql")] - pub async fn get_workflow_run( - &self, - community_id: CommunityId, - id: Uuid, - ) -> Result { - workflow::get_workflow_run(&self.pool, community_id, id).await - } - - /// List runs for a workflow. - #[datastore_span(name = "list_workflow_runs", system = "postgresql")] - pub async fn list_workflow_runs( - &self, - community_id: CommunityId, - workflow_id: Uuid, - limit: i64, - ) -> Result> { - workflow::list_workflow_runs(&self.pool, community_id, workflow_id, limit).await - } - - /// List one keyset-paginated page of workflow runs. - #[datastore_span(name = "list_workflow_runs_page", system = "postgresql")] - pub async fn list_workflow_runs_page( - &self, - community_id: CommunityId, - workflow_id: Uuid, - before: Option>, - before_id: Option, - limit: i64, - ) -> Result> { - workflow::list_workflow_runs_page( - &self.pool, - community_id, - workflow_id, - before, - before_id, - limit, - ) - .await - } - - /// Update a workflow run's status. - #[datastore_span(name = "update_workflow_run", system = "postgresql")] - pub async fn update_workflow_run( - &self, - community_id: CommunityId, - id: Uuid, - status: workflow::RunStatus, - current_step: i32, - trace: &serde_json::Value, - failure: Option>, - ) -> Result<()> { - workflow::update_workflow_run( - &self.pool, - community_id, - id, - status, - current_step, - trace, - failure, - ) - .await - } - - /// Create an approval request. - #[datastore_span(name = "create_approval", system = "postgresql")] - pub async fn create_approval(&self, params: workflow::CreateApprovalParams<'_>) -> Result<()> { - workflow::create_approval(&self.pool, params).await - } - - /// Fetch an approval by raw token. - #[datastore_span(name = "get_approval", system = "postgresql")] - pub async fn get_approval( - &self, - community_id: CommunityId, - token: &str, - ) -> Result { - workflow::get_approval(&self.pool, community_id, token).await - } - - /// Fetch an approval by its already-hashed token (no re-hashing). - #[datastore_span(name = "get_approval_by_stored_hash", system = "postgresql")] - pub async fn get_approval_by_stored_hash( - &self, - community_id: CommunityId, - token_hash: &[u8], - ) -> Result { - workflow::get_approval_by_stored_hash(&self.pool, community_id, token_hash).await - } - - /// Fetch all approvals for a workflow run. - #[datastore_span(name = "get_run_approvals", system = "postgresql")] - pub async fn get_run_approvals( - &self, - community_id: CommunityId, - workflow_id: uuid::Uuid, - run_id: uuid::Uuid, - ) -> Result> { - workflow::get_run_approvals(&self.pool, community_id, workflow_id, run_id).await - } - - /// Update an approval's status. - #[datastore_span(name = "update_approval", system = "postgresql")] - pub async fn update_approval( - &self, - community_id: CommunityId, - token: &str, - status: workflow::ApprovalStatus, - approver_pubkey: Option<&[u8]>, - note: Option<&str>, - ) -> Result { - workflow::update_approval( - &self.pool, - community_id, - token, - status, - approver_pubkey, - note, - ) - .await - } - - /// Update an approval by its already-hashed token (no re-hashing). - #[datastore_span(name = "update_approval_by_stored_hash", system = "postgresql")] - pub async fn update_approval_by_stored_hash( - &self, - community_id: CommunityId, - token_hash: &[u8], - status: workflow::ApprovalStatus, - approver_pubkey: Option<&[u8]>, - note: Option<&str>, - ) -> Result { - workflow::update_approval_by_stored_hash( - &self.pool, - community_id, - token_hash, - status, - approver_pubkey, - note, - ) - .await - } - - /// Ensures monthly partitions exist for the next N months. - #[datastore_span(name = "ensure_future_partitions", system = "postgresql")] - pub async fn ensure_future_partitions(&self, months_ahead: u32) -> Result<()> { - partition::ensure_future_partitions(&self.pool, months_ahead).await - } - - /// Backfill `d_tag` for existing NIP-33 events (kind 30000–39999) that have `d_tag IS NULL`. - /// - /// Idempotent — safe to call on every startup. No-ops when all rows are already populated. - /// Runs a single UPDATE touching only NIP-33 rows with NULL d_tag. - #[datastore_span(name = "backfill_d_tags", system = "postgresql")] - pub async fn backfill_d_tags(&self) -> Result { - let result = sqlx::query( - "UPDATE events \ - SET d_tag = COALESCE( \ - (SELECT elem->>1 FROM jsonb_array_elements(tags) AS elem \ - WHERE elem->>0 = 'd' LIMIT 1), \ - '' \ - ) \ - WHERE kind BETWEEN 30000 AND 39999 AND d_tag IS NULL \ - AND community_write_allowed(community_id)", - ) - .execute(&self.pool) - .await?; - Ok(result.rows_affected()) - } - - /// Check if a pubkey is in the allowlist for `community`. - #[datastore_span(name = "is_pubkey_allowed", system = "postgresql")] - pub async fn is_pubkey_allowed(&self, community: CommunityId, pubkey: &[u8]) -> Result { - let row = sqlx::query( - "SELECT COUNT(*) as cnt FROM pubkey_allowlist WHERE community_id = $1 AND pubkey = $2", - ) - .bind(community.as_uuid()) - .bind(pubkey) - .fetch_one(&self.pool) - .await?; - let cnt: i64 = row.try_get("cnt")?; - Ok(cnt > 0) - } - - /// Check if the community allowlist has any entries (i.e. is enforcement active). - #[datastore_span(name = "has_allowlist_entries", system = "postgresql")] - pub async fn has_allowlist_entries(&self, community: CommunityId) -> Result { - let row = - sqlx::query("SELECT COUNT(*) as cnt FROM pubkey_allowlist WHERE community_id = $1") - .bind(community.as_uuid()) - .fetch_one(&self.pool) - .await?; - let cnt: i64 = row.try_get("cnt")?; - Ok(cnt > 0) - } - - /// Add a pubkey to the community allowlist. - #[datastore_span(name = "add_to_allowlist", system = "postgresql")] - pub async fn add_to_allowlist( - &self, - community: CommunityId, - pubkey: &[u8], - added_by: &[u8], - note: Option<&str>, - ) -> Result { - let result = sqlx::query( - "INSERT INTO pubkey_allowlist (community_id, pubkey, added_by, note) VALUES ($1, $2, $3, $4) \ - ON CONFLICT DO NOTHING", - ) - .bind(community.as_uuid()) - .bind(pubkey) - .bind(added_by) - .bind(note) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() > 0) - } - - /// Remove a pubkey from the community allowlist. - #[datastore_span(name = "remove_from_allowlist", system = "postgresql")] - pub async fn remove_from_allowlist( - &self, - community: CommunityId, - pubkey: &[u8], - ) -> Result { - let result = - sqlx::query("DELETE FROM pubkey_allowlist WHERE community_id = $1 AND pubkey = $2") - .bind(community.as_uuid()) - .bind(pubkey) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() > 0) - } - - /// List all pubkeys in the community allowlist. - #[datastore_span(name = "list_allowlist", system = "postgresql")] - pub async fn list_allowlist(&self, community: CommunityId) -> Result> { - let rows = sqlx::query( - "SELECT pubkey, added_by, added_at, note FROM pubkey_allowlist WHERE community_id = $1 ORDER BY added_at DESC", - ) - .bind(community.as_uuid()) - .fetch_all(&self.pool) - .await?; - - let mut out = Vec::with_capacity(rows.len()); - for row in rows { - out.push(AllowlistEntry { - pubkey: row.try_get("pubkey")?, - added_by: row.try_get("added_by")?, - added_at: row.try_get("added_at")?, - note: row.try_get("note")?, - }); - } - Ok(out) - } - - /// Returns `true` if `pubkey` (64-char hex) is a member of `community`. - /// - /// Replica-routed on the bounded arm — the one PERMISSION read routed by - /// explicit product decision (bounded-stale membership beats the 10s - /// cache it replaced). Admits and revokes may lag by at most the budget - /// `B`; everything else fails closed to the writer, exactly like - /// [`Db::query_events_routed_bounded`]. Not precedent for routing other - /// permission reads. - #[datastore_span(name = "is_relay_member", system = "postgresql")] - pub async fn is_relay_member(&self, community: CommunityId, pubkey: &str) -> Result { - let path = "relay_membership"; - match self.route_read(path, RoutePredicate::Bounded).await { - RouteDecision::Replica(mut tx, _entry, reason) => { - match relay_members::is_relay_member_on(&mut tx, community, pubkey).await { - Ok(is_member) => { - Self::record_route(path, "replica", reason); - Ok(is_member) - } - Err(e) => { - tracing::warn!(path, "replica read failed; re-running on writer: {e}"); - Self::record_route(path, "writer", "replica_error"); - relay_members::is_relay_member(&self.pool, community, pubkey).await - } - } - } - RouteDecision::Writer => { - relay_members::is_relay_member(&self.pool, community, pubkey).await - } - } - } - - /// Returns the relay member record for `pubkey` in `community`, or `None` if not found. - #[datastore_span(name = "get_relay_member", system = "postgresql")] - pub async fn get_relay_member( - &self, - community: CommunityId, - pubkey: &str, - ) -> Result> { - relay_members::get_relay_member(&self.pool, community, pubkey).await - } - - /// Returns all relay members of `community` ordered by `created_at` ascending. - #[datastore_span(name = "list_relay_members", system = "postgresql")] - pub async fn list_relay_members( - &self, - community: CommunityId, - ) -> Result> { - relay_members::list_relay_members(&self.pool, community).await - } - - /// Adds a new relay member to `community`. - /// - /// Returns `true` if the row was actually inserted, `false` if the pubkey - /// already existed in `community` (idempotent — `ON CONFLICT DO NOTHING`). - #[datastore_span(name = "add_relay_member", system = "postgresql")] - pub async fn add_relay_member( - &self, - community: CommunityId, - pubkey: &str, - role: &str, - added_by: Option<&str>, - ) -> Result { - relay_members::add_relay_member(&self.pool, community, pubkey, role, added_by).await - } - - /// Claims relay membership via an invite and atomically persists the - /// accepted policy version when a policy is configured. - #[datastore_span(name = "claim_relay_membership", system = "postgresql")] - pub async fn claim_relay_membership( - &self, - community: CommunityId, - pubkey: &str, - role: &str, - policy_version: Option<&str>, - ) -> Result { - relay_members::claim_relay_membership(&self.pool, community, pubkey, role, policy_version) - .await - } - - /// Returns whether a member has persisted acceptance evidence for a policy version. - #[datastore_span(name = "has_join_policy_acceptance", system = "postgresql")] - pub async fn has_join_policy_acceptance( - &self, - community: CommunityId, - pubkey: &str, - policy_version: &str, - ) -> Result { - relay_members::has_join_policy_acceptance(&self.pool, community, pubkey, policy_version) - .await - } - - /// Removes a relay member from `community` atomically, refusing to delete the owner. - #[datastore_span(name = "remove_relay_member", system = "postgresql")] - pub async fn remove_relay_member( - &self, - community: CommunityId, - pubkey: &str, - ) -> Result { - relay_members::remove_relay_member(&self.pool, community, pubkey).await - } - - /// Removes a relay member from `community` only if their current role matches `expected_role`. - /// - /// Atomic conditional delete — eliminates the TOCTOU race between a - /// prior role read and the delete. See [`relay_members::remove_relay_member_if_role`]. - #[datastore_span(name = "remove_relay_member_if_role", system = "postgresql")] - pub async fn remove_relay_member_if_role( - &self, - community: CommunityId, - pubkey: &str, - expected_role: &str, - ) -> Result { - relay_members::remove_relay_member_if_role(&self.pool, community, pubkey, expected_role) - .await - } - - /// Updates the role of an existing relay member in `community`. Returns `true` if updated. - #[datastore_span(name = "update_relay_member_role", system = "postgresql")] - pub async fn update_relay_member_role( - &self, - community: CommunityId, - pubkey: &str, - new_role: &str, - ) -> Result { - relay_members::update_relay_member_role(&self.pool, community, pubkey, new_role).await - } - - /// Ensures the owner pubkey exists with role `"owner"` in `community`. Called at startup. - #[datastore_span(name = "bootstrap_owner", system = "postgresql")] - pub async fn bootstrap_owner(&self, community: CommunityId, owner_pubkey: &str) -> Result<()> { - relay_members::bootstrap_owner(&self.pool, community, owner_pubkey).await - } - - /// Returns `true` if any member of `community` holds the `admin` or - /// `owner` role. - pub async fn has_admin_or_owner(&self, community: CommunityId) -> Result { - relay_members::has_admin_or_owner(&self.pool, community).await - } - - /// Atomically transfers ownership of `community` to `new_owner_pubkey`, - /// demoting the previous owner(s) to `member`. Verifies - /// `expected_owner_pubkey` matches the current owner inside the same - /// transaction to prevent stale-owner races. - #[datastore_span(name = "transfer_ownership", system = "postgresql")] - pub async fn transfer_ownership( - &self, - community: CommunityId, - new_owner_pubkey: &str, - expected_owner_pubkey: &str, - ) -> Result { - relay_members::transfer_ownership( - &self.pool, - community, - new_owner_pubkey, - expected_owner_pubkey, - ) - .await - } - - /// Migrates existing `pubkey_allowlist` entries into `relay_members` for `community`. - /// - /// Idempotent — uses `ON CONFLICT DO NOTHING`. Returns the number of rows - /// inserted, or 0 if the `pubkey_allowlist` table doesn't exist. - #[datastore_span(name = "backfill_from_allowlist", system = "postgresql")] - pub async fn backfill_from_allowlist(&self, community: CommunityId) -> Result { - relay_members::backfill_from_allowlist(&self.pool, community).await - } - - // ── relay_operators (deployment-global principal roster) ────────────────── - - /// Fetch one relay operator/moderator row by pubkey (32-byte binary). - pub async fn get_relay_operator( - &self, - pubkey: &[u8], - ) -> Result> { - relay_operators::get(&self.pool, pubkey).await - } - - /// List all relay operator/moderator rows ordered by creation time. - pub async fn list_relay_operators(&self) -> Result> { - relay_operators::list(&self.pool).await - } - - /// Insert or update a relay operator/moderator row (upsert by pubkey). - /// - /// `config_operator_exists` is the caller's request-time snapshot of - /// whether a config-backed operator is effective; a demotion that would - /// leave no effective operator is rejected with [`DbError::LastOperator`]. - pub async fn upsert_relay_operator( - &self, - pubkey: &[u8], - role: &str, - added_by: &[u8], - config_operator_exists: bool, - ) -> Result<()> { - relay_operators::upsert(&self.pool, pubkey, role, added_by, config_operator_exists).await - } - - /// Remove a relay operator/moderator row. Returns `true` if deleted. - /// Records the revocation in the append-only audit trail; `actor` is the - /// authenticated operator performing the removal. `config_operator_exists` - /// is the caller's request-time snapshot of whether a config-backed - /// operator is effective; deleting the sole effective operator is rejected - /// with [`DbError::LastOperator`]. - pub async fn remove_relay_operator( - &self, - pubkey: &[u8], - actor: &[u8], - config_operator_exists: bool, - ) -> Result { - relay_operators::remove(&self.pool, pubkey, actor, config_operator_exists).await - } - - // ── relay_admin_actions (HTTP enforcement state machine) ────────────────── - - /// Atomic decision-only report closure: CAS open→terminal + audit row in one transaction. - #[allow(clippy::too_many_arguments)] - pub async fn resolve_report_decision_atomic( - &self, - community_id: CommunityId, - report_id: uuid::Uuid, - terminal_status: &str, - audit_action: &str, - actor_pubkey: &[u8], - actor_authority: &str, - target_pubkey: Option<&[u8]>, - target_event_id: Option<&[u8]>, - channel_id: Option, - reason: Option<&str>, - ) -> Result { - relay_admin_actions::resolve_report_decision_atomic( - &self.pool, - community_id, - report_id, - terminal_status, - audit_action, - actor_pubkey, - actor_authority, - target_pubkey, - target_event_id, - channel_id, - reason, - ) - .await - } - - /// Attempt to claim a report for HTTP enforcement (CAS open → processing). - #[allow(clippy::too_many_arguments)] - pub async fn claim_report_for_enforcement( - &self, - community_id: CommunityId, - report_id: uuid::Uuid, - request_id: uuid::Uuid, - actor_pubkey: &[u8], - actor_role: &str, - action: &str, - reason: Option<&str>, - timeout_until: Option>, - audit_action: &str, - actor_authority: &str, - target_pubkey: Option<&[u8]>, - target_event_id: Option<&[u8]>, - channel_id: Option, - ) -> Result { - relay_admin_actions::claim_report( - &self.pool, - community_id, - report_id, - request_id, - actor_pubkey, - actor_role, - action, - reason, - timeout_until, - audit_action, - actor_authority, - target_pubkey, - target_event_id, - channel_id, - ) - .await - } - - /// Advance an action from 'pending' to 'enforcing'. - pub async fn begin_enforcing_action(&self, action_id: uuid::Uuid) -> Result { - relay_admin_actions::begin_enforcing(&self.pool, action_id).await - } - - /// Commit the core mutation step (advance step_marker to 'mutation_committed'). - pub async fn commit_action_mutation_step(&self, action_id: uuid::Uuid) -> Result { - relay_admin_actions::commit_mutation_step(&self.pool, action_id).await - } - - /// Finalize enforcement: action → succeeded, report → terminal status, - /// and enqueue outbox delivery rows atomically. - #[allow(clippy::too_many_arguments)] - pub async fn finalize_action_success( - &self, - action_id: uuid::Uuid, - community_id: CommunityId, - report_id: uuid::Uuid, - terminal_status: &str, - actor_pubkey: &[u8], - action_name: &str, - target_pubkey: Option<&[u8]>, - target_event_id: Option<&[u8]>, - channel_id: Option, - reason: Option<&str>, - timeout_until: Option>, - ) -> Result { - relay_admin_actions::finalize_success( - &self.pool, - action_id, - community_id, - report_id, - terminal_status, - actor_pubkey, - action_name, - target_pubkey, - target_event_id, - channel_id, - reason, - timeout_until, - ) - .await - } - - /// Atomically execute a ban mutation and commit the step marker. - /// Returns `true` if this driver committed the marker, `false` if already marked or lease lost. - pub async fn execute_ban_with_marker( - &self, - action_id: uuid::Uuid, - lease_token: uuid::Uuid, - community_id: CommunityId, - target_pubkey: &[u8], - actor_pubkey: &[u8], - reason: Option<&str>, - ) -> Result { - relay_admin_actions::execute_ban_with_marker( - &self.pool, - action_id, - lease_token, - community_id, - target_pubkey, - actor_pubkey, - reason, - ) - .await - } - - /// Atomically execute a timeout mutation and commit the step marker. - /// Returns `true` if this driver committed the marker, `false` if already marked or lease lost. - #[allow(clippy::too_many_arguments)] - pub async fn execute_timeout_with_marker( - &self, - action_id: uuid::Uuid, - lease_token: uuid::Uuid, - community_id: CommunityId, - target_pubkey: &[u8], - actor_pubkey: &[u8], - until: chrono::DateTime, - reason: Option<&str>, - ) -> Result { - relay_admin_actions::execute_timeout_with_marker( - &self.pool, - action_id, - lease_token, - community_id, - target_pubkey, - actor_pubkey, - until, - reason, - ) - .await - } - - /// Atomically execute a kick mutation and commit the step marker. - /// Returns `Removed` (member was present), `AlreadyGone` (absent before this action), - /// or `AlreadyMarked` (marker already committed by another driver or lease lost). - pub async fn execute_kick_with_marker( - &self, - action_id: uuid::Uuid, - lease_token: uuid::Uuid, - community_id: CommunityId, - channel_id: uuid::Uuid, - target_pubkey: &[u8], - actor_pubkey: &[u8], - ) -> Result { - relay_admin_actions::execute_kick_with_marker( - &self.pool, - action_id, - lease_token, - community_id, - channel_id, - target_pubkey, - actor_pubkey, - ) - .await - } - - /// Atomically execute a soft-delete mutation and commit the step marker. - /// Returns `true` if this driver committed the marker, `false` if already marked or lease lost. - pub async fn execute_delete_with_marker( - &self, - action_id: uuid::Uuid, - lease_token: uuid::Uuid, - community_id: CommunityId, - target_event_id: &[u8], - parent_event_id: Option<&[u8]>, - root_event_id: Option<&[u8]>, - ) -> Result { - relay_admin_actions::execute_delete_with_marker( - &self.pool, - action_id, - lease_token, - community_id, - target_event_id, - parent_event_id, - root_event_id, - ) - .await - } - - /// Acquire the action mutation lease (prevents concurrent double-mutation). - pub async fn acquire_admin_action_lease( - &self, - action_id: uuid::Uuid, - lease_until: chrono::DateTime, - ) -> Result { - relay_admin_actions::acquire_action_lease(&self.pool, action_id, lease_until).await - } - - /// Release the action mutation lease. No-op if caller no longer holds the token. - pub async fn release_admin_action_lease( - &self, - action_id: uuid::Uuid, - lease_token: uuid::Uuid, - ) -> Result<()> { - relay_admin_actions::release_action_lease(&self.pool, action_id, lease_token).await - } - - /// Claim a batch of stranded `relay_admin_actions` for the action recovery worker. - pub async fn claim_stranded_admin_action_batch( - &self, - worker_id: &str, - lease_until: chrono::DateTime, - batch_size: i64, - ) -> Result> { - relay_admin_actions::claim_stranded_action_batch( - &self.pool, - worker_id, - lease_until, - batch_size, - ) - .await - } - - /// Record a pre-mutation enforcement failure (keeps report in 'processing'). - pub async fn record_action_failure( - &self, - action_id: uuid::Uuid, - lease_token: uuid::Uuid, - error: &str, - ) -> Result { - relay_admin_actions::record_failure(&self.pool, action_id, lease_token, error).await - } - - /// Cancel a pre-mutation failed action (returns report to 'open'), - /// attributing the cancel to `cancelled_by`. - pub async fn cancel_admin_action( - &self, - action_id: uuid::Uuid, - community_id: CommunityId, - report_id: uuid::Uuid, - cancelled_by: &[u8], - ) -> Result { - relay_admin_actions::cancel_action( - &self.pool, - action_id, - community_id, - report_id, - cancelled_by, - ) - .await - } - - /// Reopen a terminal report (resolved|dismissed|escalated → open) with a - /// durable `reopen` audit row, keyed idempotent on `request_id`. - pub async fn reopen_report( - &self, - community_id: CommunityId, - report_id: uuid::Uuid, - request_id: uuid::Uuid, - actor_pubkey: &[u8], - actor_role: &str, - reason: Option<&str>, - ) -> Result { - relay_admin_actions::reopen_report( - &self.pool, - community_id, - report_id, - request_id, - actor_pubkey, - actor_role, - reason, - ) - .await - } - - /// Fetch an action record by ID. - pub async fn get_admin_action( - &self, - action_id: uuid::Uuid, - ) -> Result> { - relay_admin_actions::get_action(&self.pool, action_id).await - } - - /// Enqueue an outbox artifact/notice delivery command. - pub async fn enqueue_admin_outbox( - &self, - action_id: uuid::Uuid, - task_type: &str, - payload: serde_json::Value, - dedup_key: &str, - ) -> Result<()> { - relay_admin_actions::enqueue_outbox(&self.pool, action_id, task_type, payload, dedup_key) - .await - } - - /// Mark an outbox record as delivered, fenced by the claim token. - /// Returns `true` if updated, `false` if ownership was already lost. - pub async fn mark_admin_outbox_delivered( - &self, - outbox_id: uuid::Uuid, - claim_token: uuid::Uuid, - ) -> Result { - relay_admin_actions::mark_outbox_delivered(&self.pool, outbox_id, claim_token).await - } - - /// Mark an outbox record as failed, fenced by the claim token. - /// Returns `true` if updated, `false` if ownership was already lost. - pub async fn fail_admin_outbox_row( - &self, - outbox_id: uuid::Uuid, - claim_token: uuid::Uuid, - error: &str, - ) -> Result { - relay_admin_actions::fail_outbox_row(&self.pool, outbox_id, claim_token, error).await - } - - /// Claim a batch of pending outbox rows for the given worker pod. - pub async fn claim_pending_admin_outbox_batch( - &self, - worker_id: &str, - lease_until: chrono::DateTime, - batch_size: i64, - ) -> Result> { - relay_admin_actions::claim_pending_outbox_batch( - &self.pool, - worker_id, - lease_until, - batch_size, - ) - .await - } - - /// List pending outbox records for an action. - pub async fn list_pending_admin_outbox( - &self, - action_id: uuid::Uuid, - ) -> Result> { - relay_admin_actions::list_pending_outbox(&self.pool, action_id).await - } - - /// Deployment-authority kick: remove a member without requiring tenant owner/admin actor. - pub async fn deploy_kick_member( - &self, - community_id: CommunityId, - channel_id: uuid::Uuid, - target_pubkey: &[u8], - actor_pubkey: &[u8], - ) -> Result { - relay_admin_actions::deploy_kick_member( - &self.pool, - community_id, - channel_id, - target_pubkey, - actor_pubkey, - ) - .await - } - - /// Update product_feedback status (operator-managed lifecycle). - pub async fn update_feedback_status(&self, id: uuid::Uuid, status: &str) -> Result { - relay_admin_actions::update_feedback_status(&self.pool, id, status).await - } - - /// Mints a v2 use-limited relay invite. The plaintext code is returned - /// exactly once; only its SHA-256 hash is persisted. - /// - /// `max_uses` is `None` for unlimited or `Some(1..=10000)`. - /// `ttl_secs` must be in the shared invite lifetime range. - #[datastore_span(name = "mint_relay_invite", system = "postgresql")] - pub async fn mint_relay_invite( - &self, - community: CommunityId, - created_by: &str, - ttl_secs: u64, - max_uses: Option, - ) -> Result { - relay_invite::mint_relay_invite(&self.pool, community, created_by, ttl_secs, max_uses).await - } - - /// Delete one bounded batch of invites expired before `cutoff`. - #[datastore_span(name = "reap_expired_relay_invites", system = "postgresql")] - pub async fn reap_expired_relay_invites( - &self, - cutoff: chrono::DateTime, - ) -> Result { - relay_invite::reap_expired_relay_invites(&self.pool, cutoff).await - } - - /// Atomically claims a v2 relay invite. The full redemption (membership - /// insert, policy evidence, use_count increment) runs in one PostgreSQL - /// transaction with `FOR UPDATE` on the invite row. - /// - /// `token_hash` is the SHA-256 of the presented v2 code (32 bytes). - #[datastore_span(name = "claim_relay_invite", system = "postgresql")] - pub async fn claim_relay_invite( - &self, - community: CommunityId, - token_hash: &[u8; 32], - claimer_pubkey: &str, - policy_version: Option<&str>, - ) -> Result { - relay_invite::claim_relay_invite( - &self.pool, - community, - token_hash, - claimer_pubkey, - policy_version, - ) - .await - } - - /// Sidecar an accepted product-feedback event, idempotent by event id. - #[datastore_span(name = "insert_product_feedback", system = "postgresql")] - pub async fn insert_product_feedback( - &self, - community: CommunityId, - feedback: product_feedback::NewProductFeedback<'_>, - ) -> Result { - product_feedback::insert(&self.pool, community, feedback).await - } - - /// List product feedback across the deployment, newest first. - #[datastore_span(name = "list_product_feedback", system = "postgresql")] - pub async fn list_product_feedback( - &self, - limit: i64, - ) -> Result> { - product_feedback::list(&self.pool, limit).await - } - - /// Insert a tenant-scoped NIP-56 report row, idempotent by report event id. - #[datastore_span(name = "insert_moderation_report", system = "postgresql")] - pub async fn insert_moderation_report( - &self, - community: CommunityId, - report: moderation::NewReport<'_>, - ) -> Result { - moderation::insert_report(&self.pool, community, report).await - } - - /// List moderation reports for a community, newest first. - #[datastore_span(name = "list_moderation_reports", system = "postgresql")] - pub async fn list_moderation_reports( - &self, - community: CommunityId, - status: Option<&str>, - limit: i64, - ) -> Result> { - moderation::list_reports(&self.pool, community, status, limit).await - } - - /// Fetch one moderation report by row id. - #[datastore_span(name = "get_moderation_report", system = "postgresql")] - pub async fn get_moderation_report( - &self, - community: CommunityId, - report_id: Uuid, - ) -> Result> { - moderation::get_report(&self.pool, community, report_id).await - } - - /// Fetch one moderation report by signed NIP-56 report event id. - #[datastore_span(name = "get_moderation_report_by_event", system = "postgresql")] - pub async fn get_moderation_report_by_event( - &self, - community: CommunityId, - report_event_id: &[u8], - ) -> Result> { - moderation::get_report_by_event(&self.pool, community, report_event_id).await - } - - /// Resolve, dismiss, or escalate an open moderation report. - #[datastore_span(name = "resolve_moderation_report", system = "postgresql")] - pub async fn resolve_moderation_report( - &self, - community: CommunityId, - report_id: Uuid, - status: &str, - resolved_by: &[u8], - action_id: Option, - ) -> Result { - moderation::resolve_report( - &self.pool, - community, - report_id, - status, - resolved_by, - action_id, - ) - .await - } - - /// Upsert a community ban for a member pubkey. - #[datastore_span(name = "ban_community_member", system = "postgresql")] - pub async fn ban_community_member( - &self, - community: CommunityId, - pubkey: &[u8], - actor: &[u8], - reason: Option<&str>, - expires_at: Option>, - ) -> Result<()> { - moderation::ban_member(&self.pool, community, pubkey, actor, reason, expires_at).await - } - - /// Lift a community ban for a member pubkey. - #[datastore_span(name = "unban_community_member", system = "postgresql")] - pub async fn unban_community_member( - &self, - community: CommunityId, - pubkey: &[u8], - actor: &[u8], - ) -> Result { - moderation::unban_member(&self.pool, community, pubkey, actor).await - } - - /// Upsert a community timeout/write-block for a member pubkey. - #[datastore_span(name = "timeout_community_member", system = "postgresql")] - pub async fn timeout_community_member( - &self, - community: CommunityId, - pubkey: &[u8], - actor: &[u8], - muted_until: DateTime, - reason: Option<&str>, - ) -> Result<()> { - moderation::timeout_member(&self.pool, community, pubkey, actor, muted_until, reason).await - } - - /// Clear a community timeout/write-block for a member pubkey. - #[datastore_span(name = "untimeout_community_member", system = "postgresql")] - pub async fn untimeout_community_member( - &self, - community: CommunityId, - pubkey: &[u8], - actor: &[u8], - ) -> Result { - moderation::untimeout_member(&self.pool, community, pubkey, actor).await - } - - /// Fetch the active ban/timeout restriction state for enforcement hot paths. - #[datastore_span(name = "moderation_restriction_state", system = "postgresql")] - pub async fn moderation_restriction_state( - &self, - community: CommunityId, - pubkey: &[u8], - ) -> Result { - moderation::restriction_state(&self.pool, community, pubkey).await - } - - /// Fetch the full ban/timeout row for a member pubkey. - #[datastore_span(name = "get_community_ban", system = "postgresql")] - pub async fn get_community_ban( - &self, - community: CommunityId, - pubkey: &[u8], - ) -> Result> { - moderation::get_ban(&self.pool, community, pubkey).await - } - - /// List currently restricted members in a community. - #[datastore_span(name = "list_community_restrictions", system = "postgresql")] - pub async fn list_community_restrictions( - &self, - community: CommunityId, - ) -> Result> { - moderation::list_restricted(&self.pool, community).await - } - - /// Insert a moderation audit action row. - #[datastore_span(name = "insert_moderation_action", system = "postgresql")] - pub async fn insert_moderation_action( - &self, - community: CommunityId, - action: moderation::NewAction<'_>, - ) -> Result { - moderation::insert_action(&self.pool, community, action).await - } - - /// List moderation audit action rows, newest first. - #[datastore_span(name = "list_moderation_actions", system = "postgresql")] - pub async fn list_moderation_actions( - &self, - community: CommunityId, - limit: i64, - ) -> Result> { - moderation::list_actions(&self.pool, community, limit).await - } - - /// Return the current owner of git repo name `repo_id` in `community`, or - /// `None` if unreserved. See [`git_repo::repo_name_owner`]. - #[datastore_span(name = "repo_name_owner", system = "postgresql")] - pub async fn repo_name_owner( - &self, - community: CommunityId, - repo_id: &str, - ) -> Result> { - git_repo::repo_name_owner(&self.pool, community, repo_id).await - } - - /// Reserve a git repo name for `owner_pubkey` in `community` (NIP-34). - /// - /// See [`git_repo::reserve_repo_name`] for the outcome semantics. The - /// per-pubkey quota is enforced by the caller against `count_repos_for_owner`. - #[datastore_span(name = "reserve_repo_name", system = "postgresql")] - pub async fn reserve_repo_name( - &self, - community: CommunityId, - repo_id: &str, - owner_pubkey: &str, - ) -> Result { - git_repo::reserve_repo_name(&self.pool, community, repo_id, owner_pubkey).await - } - - /// Count git repos reserved by `owner_pubkey` in `community` (quota check). - #[datastore_span(name = "count_repos_for_owner", system = "postgresql")] - pub async fn count_repos_for_owner( - &self, - community: CommunityId, - owner_pubkey: &str, - ) -> Result { - git_repo::count_repos_for_owner(&self.pool, community, owner_pubkey).await - } - - /// Release a git repo name reservation held by `owner_pubkey` (rollback). - /// - /// Returns the number of rows removed (0 or 1). See [`git_repo::release_repo_name`]. - #[datastore_span(name = "release_repo_name", system = "postgresql")] - pub async fn release_repo_name( - &self, - community: CommunityId, - repo_id: &str, - owner_pubkey: &str, - ) -> Result { - git_repo::release_repo_name(&self.pool, community, repo_id, owner_pubkey).await - } - - /// Returns `true` if `pubkey` (64-char hex) is archived in `community_id`. - #[datastore_span(name = "is_archived", system = "postgresql")] - pub async fn is_archived(&self, community_id: CommunityId, pubkey: &str) -> Result { - archived_identities::is_archived(&self.pool, community_id, pubkey).await - } - - /// Archives an identity in `community_id`. Returns `true` if inserted, `false` if already archived. - #[allow(clippy::too_many_arguments)] - #[datastore_span(name = "archive", system = "postgresql")] - pub async fn archive( - &self, - community_id: CommunityId, - pubkey: &str, - consent_path: &str, - actor: &str, - reason: Option<&str>, - replaced_by: Option<&str>, - request_event_id: &str, - ) -> Result { - archived_identities::archive( - &self.pool, - community_id, - pubkey, - consent_path, - actor, - reason, - replaced_by, - request_event_id, - ) - .await - } - - /// Unarchives an identity from `community_id`. Returns `true` if deleted, `false` if absent. - #[datastore_span(name = "unarchive", system = "postgresql")] - pub async fn unarchive(&self, community_id: CommunityId, pubkey: &str) -> Result { - archived_identities::unarchive(&self.pool, community_id, pubkey).await - } - - /// Returns all identities archived in `community_id`, ordered by archive time ascending. - #[datastore_span(name = "list_archived", system = "postgresql")] - pub async fn list_archived( - &self, - community_id: CommunityId, - ) -> Result> { - archived_identities::list_archived(&self.pool, community_id).await - } - - /// Soft-delete NIP-29 discovery events for a channel created by a specific relay pubkey. - #[datastore_span(name = "soft_delete_discovery_events", system = "postgresql")] - pub async fn soft_delete_discovery_events( - &self, - community_id: CommunityId, - channel_id: Uuid, - relay_pubkey: &[u8], - ) -> Result { - let result = sqlx::query( - "UPDATE events SET deleted_at = NOW() \ - WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3 AND deleted_at IS NULL AND kind IN (39000, 39001, 39002)", - ) - .bind(community_id.as_uuid()) - .bind(channel_id) - .bind(relay_pubkey) - .execute(&self.pool) - .await?; - Ok(result.rows_affected()) - } - - /// Returns whether the relay-authored NIP-43 snapshot is absent or differs - /// from the canonical membership rows for `community_id`. - /// - /// Snapshot and canonical rows are compared directly rather than by - /// timestamp: relay membership events use whole-second Nostr timestamps, - /// and multiple mutations within one second must still be repaired. - #[datastore_span( - name = "nip43_membership_snapshot_needs_reconciliation", - system = "postgresql" - )] - pub async fn nip43_membership_snapshot_needs_reconciliation( - &self, - community_id: CommunityId, - relay_pubkey: &nostr::PublicKey, - ) -> Result { - let snapshot = self - .query_events(&crate::event::EventQuery { - kinds: Some(vec![buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32]), - pubkey: Some(relay_pubkey.to_bytes().to_vec()), - global_only: true, - limit: Some(1), - ..crate::event::EventQuery::for_community(community_id) - }) - .await? - .into_iter() - .next(); - let members = self.list_relay_members(community_id).await?; - - let Some(snapshot) = snapshot else { - return Ok(true); - }; - let mut snapshot_members = snapshot - .event - .tags - .iter() - .filter_map(|tag| { - let parts = tag.as_slice(); - (parts.first().map(String::as_str) == Some("member") && parts.len() >= 3) - .then(|| (parts[1].to_ascii_lowercase(), parts[2].clone())) - }) - .collect::>(); - let mut canonical_members = members - .into_iter() - .map(|member| (member.pubkey.to_ascii_lowercase(), member.role)) - .collect::>(); - snapshot_members.sort_unstable(); - canonical_members.sort_unstable(); - - Ok(snapshot_members != canonical_members) - } - - /// Atomically publish a NIP-43 membership snapshot under a single - /// transaction-scoped advisory lock. - /// - /// This method acquires the per-community snapshot lock, reads the - /// current membership, builds the event, and replaces the prior snapshot - /// — all inside one transaction on one database connection. This - /// prevents the stale-snapshot race where a concurrent publication reads - /// older state and overwrites a newer snapshot by arrival order. - /// - #[datastore_span(name = "publish_nip43_membership_locked", system = "postgresql")] - pub async fn publish_nip43_membership_locked( - &self, - community_id: CommunityId, - relay_keypair: &nostr::Keys, - ) -> Result<(StoredEvent, bool, usize)> { - use nostr::{EventBuilder, Kind, Tag}; - - let kind_i32 = buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32; - let pubkey_bytes = relay_keypair.public_key().to_bytes(); - - let lock_key = replaceable::event_replacement_lock_key( - community_id, - kind_i32, - pubkey_bytes.as_slice(), - None, - ); - - let (mut tx, transaction_timer) = observability::begin_transaction( - &self.pool, - observability::TransactionOperation::PublishNip43MembershipLocked, - ) - .await?; - let (event, received_at, was_inserted, member_count) = transaction_timer - .observe(async { - - // Acquire the per-community snapshot lock BEFORE reading members. - // This serializes the entire read-build-write cycle: a concurrent - // publication will block here until our transaction commits, then - // read the updated membership state. - observability::observe_advisory_lock( - observability::LockType::Membership, - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(lock_key) - .execute(&mut *tx), - ) - .await?; - - // Read current members inside the locked transaction. - let rows = sqlx::query( - "SELECT pubkey, role FROM relay_members \ - WHERE community_id = $1 ORDER BY created_at ASC", - ) - .bind(community_id.as_uuid()) - .fetch_all(&mut *tx) - .await?; - - let member_count = rows.len(); - - // Build the NIP-43 event from the locked member rows. - let mut tags: Vec = Vec::with_capacity(member_count + 1); - // NIP-70 protected-event marker. - tags.push(Tag::parse(["-"]).map_err(|e| { - crate::error::DbError::InvalidData(format!("failed to build '-' tag: {e}")) - })?); - for row in &rows { - let pubkey: String = row.try_get("pubkey")?; - let role: String = row.try_get("role")?; - tags.push(Tag::parse(["member", &pubkey, &role]).map_err(|e| { - crate::error::DbError::InvalidData(format!("failed to build member tag: {e}")) - })?); - } - - let event = EventBuilder::new(Kind::Custom(kind_i32 as u16), "") - .tags(tags) - .sign_with_keys(relay_keypair) - .map_err(|e| { - crate::error::DbError::InvalidData(format!("failed to sign kind:13534: {e}")) - })?; - - let created_at_secs = event.created_at.as_secs() as i64; - let created_at = chrono::DateTime::from_timestamp(created_at_secs, 0) - .ok_or(DbError::InvalidTimestamp(created_at_secs))?; - let sig_bytes = event.sig.serialize(); - let tags_json = serde_json::to_value(&event.tags)?; - let received_at = chrono::Utc::now(); - let d_tag = crate::event::extract_d_tag(&event); - - // Soft-delete prior snapshots — unconditional, the relay is authoritative. - sqlx::query( - "UPDATE events SET deleted_at = NOW() \ - WHERE community_id = $1 AND kind = $2 AND pubkey = $3 \ - AND channel_id IS NULL \ - AND deleted_at IS NULL", - ) - .bind(community_id.as_uuid()) - .bind(kind_i32) - .bind(pubkey_bytes.as_slice()) - .execute(&mut *tx) - .await?; - - let insert_result = sqlx::query( - "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \ - ON CONFLICT DO NOTHING", - ) - .bind(community_id.as_uuid()) - .bind(event.id.as_bytes().as_slice()) - .bind(pubkey_bytes.as_slice()) - .bind(created_at) - .bind(kind_i32) - .bind(&tags_json) - .bind(&event.content) - .bind(sig_bytes.as_slice()) - .bind(received_at) - .bind::>(None) - .bind(d_tag.as_deref()) - .execute(&mut *tx) - .await?; - - let was_inserted = insert_result.rows_affected() > 0; - if was_inserted { - tx.commit().await?; - } else { - tx.rollback().await?; - } - Ok::<_, DbError>((event, received_at, was_inserted, member_count)) - }) - .await?; - - if was_inserted { - if let Err(e) = crate::insert_mentions(&self.pool, community_id, &event, None).await { - tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); - } - } - - Ok(( - StoredEvent::with_received_at(event, received_at, None, was_inserted), - was_inserted, - member_count, - )) - } -} - -/// A full API token record. -#[derive(Debug, Clone)] -pub struct ApiTokenRecord { - /// Unique token identifier. - pub id: Uuid, - /// SHA-256 hash of the raw token value. - pub token_hash: Vec, - /// Compressed public key bytes of the token owner. - pub owner_pubkey: Vec, - /// Human-readable token name. - pub name: String, - /// Permission scopes granted to this token. - pub scopes: Vec, - /// Optional channel ID restrictions. - pub channel_ids: Option>, - /// When the token was created. - pub created_at: DateTime, - /// Optional expiry timestamp. - pub expires_at: Option>, - /// When the token was last used. - pub last_used_at: Option>, - /// When the token was revoked. - pub revoked_at: Option>, -} - -/// An entry in the pubkey allowlist. -#[derive(Debug, Clone)] -pub struct AllowlistEntry { - /// The allowed pubkey. - pub pubkey: Vec, - /// Who added this entry. - pub added_by: Vec, - /// When the entry was added. - pub added_at: DateTime, - /// Optional note. - pub note: Option, -} - -fn parse_api_token_row(row: sqlx::postgres::PgRow) -> Result { - let id: Uuid = row.try_get("id")?; - - let scopes_json: serde_json::Value = row.try_get("scopes")?; - let scopes: Vec = serde_json::from_value(scopes_json) - .map_err(|e| DbError::InvalidData(format!("scopes JSON: {e}")))?; - - let channel_ids: Option> = { - let raw: Option = row.try_get("channel_ids")?; - match raw { - None => None, - Some(v) => { - let strings: Vec = serde_json::from_value(v) - .map_err(|e| DbError::InvalidData(format!("channel_ids JSON: {e}")))?; - let uuids: std::result::Result, _> = - strings.iter().map(|s| s.parse::()).collect(); - Some(uuids.map_err(|e| DbError::InvalidData(format!("channel_ids UUID: {e}")))?) - } - } - }; - - Ok(ApiTokenRecord { - id, - token_hash: row.try_get("token_hash")?, - owner_pubkey: row.try_get("owner_pubkey")?, - name: row.try_get("name")?, - scopes, - channel_ids, - created_at: row.try_get("created_at")?, - expires_at: row.try_get("expires_at")?, - last_used_at: row.try_get("last_used_at")?, - revoked_at: row.try_get("revoked_at")?, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use buzz_core::CommunityId; - use sqlx::postgres::PgPoolOptions; - use sqlx::PgPool; - use uuid::Uuid; - - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; - - async fn setup_db() -> Db { - let database_url = - std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); - let pool = PgPool::connect(&database_url) - .await - .expect("connect to test DB"); - Db::from_pool(pool) - } - - async fn make_community(pool: &PgPool) -> Uuid { - let id = Uuid::new_v4(); - let host = format!("communities-of-channels-{}.example", id.simple()); - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(id) - .bind(host) - .execute(pool) - .await - .expect("insert community"); - id - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn coordinate_delete_spares_head_newer_than_the_deletion() { - use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; - - let db = setup_db().await; - let community = CommunityId::from_uuid(make_community(&db.pool).await); - let keys = Keys::generate(); - let kind = buzz_core::kind::KIND_PROJECT as i32; - let d_tag = "stale-tombstone-project"; - let pubkey = keys.public_key().to_bytes().to_vec(); - let base = Timestamp::now().as_secs(); - - let version = |content: &str, offset: u64| { - EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), content) - .tags(vec![Tag::parse(["d", d_tag]).expect("d tag")]) - .custom_created_at(Timestamp::from(base + offset)) - .sign_with_keys(&keys) - .expect("sign project version") - }; - - for (content, offset) in [("v1", 0), ("v2", 100)] { - assert!( - db.replace_parameterized_event(community, &version(content, offset), d_tag, None) - .await - .expect("store project version") - .1 - ); - } - - // Tombstone timestamped between V1 and V2: it authorizes deleting V1, - // never the newer head that replaced it. - let stale_deleted = db - .soft_delete_by_coordinate(community, kind, &pubkey, d_tag, (base + 50) as i64) - .await - .expect("stale coordinate delete"); - assert!( - !stale_deleted, - "a tombstone older than the live head must delete nothing" - ); - - let live_content: Option = sqlx::query_scalar( - "SELECT content FROM events \ - WHERE community_id=$1 AND kind=$2 AND pubkey=$3 AND d_tag=$4 AND deleted_at IS NULL", - ) - .bind(community.as_uuid()) - .bind(kind) - .bind(&pubkey) - .bind(d_tag) - .fetch_optional(&db.pool) - .await - .expect("read live head"); - assert_eq!( - live_content.as_deref(), - Some("v2"), - "the newer head must survive a stale tombstone" - ); - - // A tombstone at or after the head's own timestamp still deletes it. - let current_deleted = db - .soft_delete_by_coordinate(community, kind, &pubkey, d_tag, (base + 100) as i64) - .await - .expect("current coordinate delete"); - assert!( - current_deleted, - "a tombstone at the head's timestamp must delete it (NIP-09 is at-or-before)" - ); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn database_guard_covers_legacy_writer_and_nip09_deletion() { - use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; - - let db = setup_db().await; - let community = CommunityId::from_uuid(make_community(&db.pool).await); - let keys = Keys::generate(); - let d_tag = format!("read-state:{}", "b".repeat(32)); - let tags = vec![ - Tag::parse(["d", d_tag.as_str()]).expect("d tag"), - Tag::parse(["t", "read-state"]).expect("t tag"), - ]; - let base = Timestamp::now().as_secs(); - let a = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "A") - .tags(tags.clone()) - .custom_created_at(Timestamp::from(base)) - .sign_with_keys(&keys) - .expect("sign A"); - let x = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "X") - .tags(tags.clone()) - .custom_created_at(Timestamp::from(base + 1)) - .sign_with_keys(&keys) - .expect("sign X"); - let b = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "B") - .tags(tags.clone()) - .custom_created_at(Timestamp::from(base + 2)) - .sign_with_keys(&keys) - .expect("sign B"); - let c = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "C") - .tags(tags) - .custom_created_at(Timestamp::from(base + 3)) - .sign_with_keys(&keys) - .expect("sign C"); - - async fn legacy_insert( - pool: &PgPool, - community: CommunityId, - event: &nostr::Event, - d_tag: &str, - ) -> std::result::Result { - sqlx::query( - "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, d_tag) \ - VALUES ($1, $2, $3, to_timestamp($4), $5, $6, $7, $8, NOW(), $9) ON CONFLICT DO NOTHING", - ) - .bind(community.as_uuid()) - .bind(event.id.as_bytes().as_slice()) - .bind(event.pubkey.to_bytes()) - .bind(event.created_at.as_secs() as f64) - .bind(buzz_core::kind::KIND_READ_STATE as i32) - .bind(serde_json::to_value(&event.tags).expect("serialize tags")) - .bind(&event.content) - .bind(event.sig.serialize().as_slice()) - .bind(d_tag) - .execute(pool) - .await - } - - legacy_insert(&db.pool, community, &a, &d_tag) - .await - .expect("legacy insert A"); - let duplicate = legacy_insert(&db.pool, community, &a, &d_tag) - .await - .expect("legacy duplicate A remains idempotent"); - assert_eq!(duplicate.rows_affected(), 0); - - sqlx::query( - "INSERT INTO event_mentions \ - (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ - VALUES ($1, $2, $3, to_timestamp($4), 30078)", - ) - .bind(community.as_uuid()) - .bind("c".repeat(64)) - .bind(a.id.as_bytes().as_slice()) - .bind(a.created_at.as_secs() as f64) - .execute(&db.pool) - .await - .expect("insert live mention"); - - // Emulate the pre-PR replacement path after migration 0007: soft-delete - // the live row, then insert B without any application watermark write. - sqlx::query( - "UPDATE events SET deleted_at=NOW() \ - WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL", - ) - .bind(community.as_uuid()) - .bind(keys.public_key().to_bytes()) - .bind(&d_tag) - .execute(&db.pool) - .await - .expect("legacy soft-delete A"); - let mentions_after_delete: i64 = sqlx::query_scalar( - "SELECT count(*) FROM event_mentions WHERE community_id=$1 AND event_id=$2", - ) - .bind(community.as_uuid()) - .bind(a.id.as_bytes().as_slice()) - .fetch_one(&db.pool) - .await - .expect("count mentions after delete"); - assert_eq!(mentions_after_delete, 0); - - let stale_mention = sqlx::query( - "INSERT INTO event_mentions \ - (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ - VALUES ($1, $2, $3, to_timestamp($4), 30078)", - ) - .bind(community.as_uuid()) - .bind("d".repeat(64)) - .bind(a.id.as_bytes().as_slice()) - .bind(a.created_at.as_secs() as f64) - .execute(&db.pool) - .await - .expect("stale post-commit mention is skipped"); - assert_eq!(stale_mention.rows_affected(), 0); - - legacy_insert(&db.pool, community, &b, &d_tag) - .await - .expect("legacy insert B"); - let duplicate_b = legacy_insert(&db.pool, community, &b, &d_tag) - .await - .expect("live duplicate B is skipped"); - assert_eq!(duplicate_b.rows_affected(), 0); - - sqlx::query( - "INSERT INTO event_mentions \ - (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ - VALUES ($1, $2, $3, to_timestamp($4), 30078)", - ) - .bind(community.as_uuid()) - .bind("e".repeat(64)) - .bind(b.id.as_bytes().as_slice()) - .bind(b.created_at.as_secs() as f64) - .execute(&db.pool) - .await - .expect("insert B mention"); - - // Exercise the new Rust hard-delete path independently. An in-flight - // mention holds KEY SHARE on B, so replacement by C must block, then - // complete after the mention commits and remove both B and its mention. - let mut rust_mention_tx = db - .pool - .begin() - .await - .expect("begin Rust mention transaction"); - sqlx::query( - "INSERT INTO event_mentions \ - (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ - VALUES ($1, $2, $3, to_timestamp($4), 30078) ON CONFLICT DO NOTHING", - ) - .bind(community.as_uuid()) - .bind("e".repeat(64)) - .bind(b.id.as_bytes().as_slice()) - .bind(b.created_at.as_secs() as f64) - .execute(&mut *rust_mention_tx) - .await - .expect("hold B live-event key-share lock"); - - let replace_db = db.clone(); - let replace_d_tag = d_tag.clone(); - let replace_c = c.clone(); - let replace_task = tokio::spawn(async move { - replace_db - .replace_parameterized_event(community, &replace_c, &replace_d_tag, None) - .await - }); - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - assert!( - !replace_task.is_finished(), - "Rust hard delete should wait for mention lock" - ); - rust_mention_tx - .commit() - .await - .expect("release Rust mention lock"); - let replaced = tokio::time::timeout(std::time::Duration::from_secs(2), replace_task) - .await - .expect("Rust hard delete deadlocked with mention insert") - .expect("replacement task panicked") - .expect("replace B with C"); - assert!(replaced.1, "C must replace B"); - let b_mentions: i64 = sqlx::query_scalar( - "SELECT count(*) FROM event_mentions WHERE community_id=$1 AND event_id=$2", - ) - .bind(community.as_uuid()) - .bind(b.id.as_bytes().as_slice()) - .fetch_one(&db.pool) - .await - .expect("count B mentions after Rust replacement"); - assert_eq!(b_mentions, 0); - - sqlx::query( - "INSERT INTO event_mentions \ - (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ - VALUES ($1, $2, $3, to_timestamp($4), 30078)", - ) - .bind(community.as_uuid()) - .bind("f".repeat(64)) - .bind(c.id.as_bytes().as_slice()) - .bind(c.created_at.as_secs() as f64) - .execute(&db.pool) - .await - .expect("insert C mention"); - - // Exercise legacy UPDATE-trigger deletion with the same barrier. While - // deletion waits on C's KEY SHARE lock, an exact replay must already be - // a zero-row trigger no-op; it must not wait for deletion or resurrect C. - let mut legacy_mention_tx = db - .pool - .begin() - .await - .expect("begin legacy mention transaction"); - sqlx::query( - "INSERT INTO event_mentions \ - (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ - VALUES ($1, $2, $3, to_timestamp($4), 30078) ON CONFLICT DO NOTHING", - ) - .bind(community.as_uuid()) - .bind("f".repeat(64)) - .bind(c.id.as_bytes().as_slice()) - .bind(c.created_at.as_secs() as f64) - .execute(&mut *legacy_mention_tx) - .await - .expect("hold C live-event key-share lock"); - - let delete_pool = db.pool.clone(); - let delete_pubkey = keys.public_key().to_bytes(); - let delete_d_tag = d_tag.clone(); - let delete_task = tokio::spawn(async move { - sqlx::query( - "UPDATE events SET deleted_at=NOW() \ - WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL", - ) - .bind(community.as_uuid()) - .bind(delete_pubkey) - .bind(delete_d_tag) - .execute(&delete_pool) - .await - }); - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - assert!( - !delete_task.is_finished(), - "legacy delete should wait for mention lock" - ); - - let replay_while_delete_waits = legacy_insert(&db.pool, community, &c, &d_tag) - .await - .expect("concurrent exact C replay is skipped"); - assert_eq!(replay_while_delete_waits.rows_affected(), 0); - - legacy_mention_tx - .commit() - .await - .expect("release legacy mention lock"); - tokio::time::timeout(std::time::Duration::from_secs(2), delete_task) - .await - .expect("legacy delete deadlocked with mention insert") - .expect("delete task panicked") - .expect("legacy NIP-09 delete C"); - - let payloads: i64 = sqlx::query_scalar( - "SELECT count(*) FROM events WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", - ) - .bind(community.as_uuid()) - .bind(keys.public_key().to_bytes()) - .bind(&d_tag) - .fetch_one(&db.pool) - .await - .expect("count retained payloads"); - assert_eq!( - payloads, 0, - "legacy soft deletes must not retain NIP-RS payloads" - ); - - // Opposite commit order: deletion has committed before exact replay. - // Equality remains an observable zero-row no-op, never a resurrection. - let replay_c = legacy_insert(&db.pool, community, &c, &d_tag) - .await - .expect("post-delete exact C replay is skipped"); - assert_eq!(replay_c.rows_affected(), 0); - let payloads_after_exact_replay: i64 = sqlx::query_scalar( - "SELECT count(*) FROM events WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", - ) - .bind(community.as_uuid()) - .bind(keys.public_key().to_bytes()) - .bind(&d_tag) - .fetch_one(&db.pool) - .await - .expect("count payloads after exact replay"); - assert_eq!(payloads_after_exact_replay, 0); - - let replay = legacy_insert(&db.pool, community, &x, &d_tag).await; - assert!( - replay.is_err(), - "database guard must reject A < X < C replay" - ); - - let watermark: (chrono::DateTime, Vec) = sqlx::query_as( - "SELECT created_at, event_id FROM parameterized_event_watermarks \ - WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", - ) - .bind(community.as_uuid()) - .bind(keys.public_key().to_bytes()) - .bind(&d_tag) - .fetch_one(&db.pool) - .await - .expect("read C watermark"); - assert_eq!(watermark.0.timestamp(), base as i64 + 3); - assert_eq!(watermark.1, c.id.as_bytes().as_slice()); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn test_usage_metrics_lock_has_single_owner_and_releases_on_drop() { - // Use a private scratch database — not the shared TEST_DATABASE_URL. - // Postgres advisory locks are per-database; hardcoding the production - // USAGE_METRICS_LOCK_KEY (0x4255_5A5A_4D45_5452) on the shared test DB - // races any live buzz-relay on the same database (see #3619). - let admin_url = std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); - let admin = PgPoolOptions::new() - .max_connections(1) - .connect(&admin_url) - .await - .expect("connect admin to create scratch db"); - let (pool, scratch_name) = create_scratch_db(&admin, "usage_metrics_lock").await; - let first = Db::from_pool(pool.clone()); - let second = Db::from_pool(pool.clone()); - // Same key as production (`buzz-relay` USAGE_METRICS_LOCK_KEY) — safe here - // because the scratch DB is empty of other holders. - let key = 0x4255_5A5A_4D45_5452; - - let mut leader = first - .try_lock_usage_metrics(key) - .await - .expect("first lock attempt") - .expect("first database handle becomes leader"); - assert!(leader.is_live().await, "lock owner remains reachable"); - assert!( - second - .try_lock_usage_metrics(key) - .await - .expect("second lock attempt") - .is_none(), - "another session cannot become leader while the guard exists" - ); - - drop(leader); - assert!( - second - .try_lock_usage_metrics(key) - .await - .expect("lock attempt after leader drop") - .is_some(), - "dropping the detached session releases its advisory lock" - ); - - // Release any remaining session state before DROP DATABASE. - drop(first); - drop(second); - drop_scratch_db(&admin, pool, &scratch_name).await; - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn allowlist_is_scoped_to_community() { - let db = setup_db().await; - let community_a = CommunityId::from_uuid(make_community(&db.pool).await); - let community_b = CommunityId::from_uuid(make_community(&db.pool).await); - let pubkey = [7u8; 32]; - let added_by = [9u8; 32]; - - assert!(db - .add_to_allowlist(community_a, &pubkey, &added_by, Some("a-only")) - .await - .expect("add allowlist row")); - assert!(!db - .add_to_allowlist(community_a, &pubkey, &added_by, Some("duplicate")) - .await - .expect("duplicate allowlist row is idempotent")); - - assert!( - db.is_pubkey_allowed(community_a, &pubkey) - .await - .expect("allowlist check A"), - "pubkey added to A must be allowed in A" - ); - assert!( - !db.is_pubkey_allowed(community_b, &pubkey) - .await - .expect("allowlist check B"), - "pubkey added only to A must not be allowed in B" - ); - assert!(db - .has_allowlist_entries(community_a) - .await - .expect("A has entries")); - assert!(!db - .has_allowlist_entries(community_b) - .await - .expect("B has no entries")); - - let listed = db - .list_allowlist(community_a) - .await - .expect("list A allowlist"); - assert_eq!(listed.len(), 1); - assert_eq!(listed[0].pubkey, pubkey); - - assert!( - !db.remove_from_allowlist(community_b, &pubkey) - .await - .expect("remove from B is no-op"), - "removing from B must not delete A's row" - ); - assert!(db - .is_pubkey_allowed(community_a, &pubkey) - .await - .expect("A still allowed after B remove")); - assert!(db - .remove_from_allowlist(community_a, &pubkey) - .await - .expect("remove from A")); - assert!(!db - .is_pubkey_allowed(community_a, &pubkey) - .await - .expect("A not allowed after remove")); - } - - /// BUG-5 regression: the `reactions` table is community-scoped - /// (`PK (community_id, event_created_at, event_id, pubkey, emoji)`), so a - /// reaction added under community A must be invisible and unremovable from - /// community B — even for the *identical* `(event_id, pubkey, emoji)` shape. - /// Before the fix, `add_reaction` omitted `community_id` (NOT NULL → 500) and - /// every read/remove filtered `event_id` only (latent cross-tenant bleed). - #[tokio::test] - #[ignore = "requires Postgres"] - async fn reactions_are_scoped_to_community() { - let db = setup_db().await; - let community_a = CommunityId::from_uuid(make_community(&db.pool).await); - let community_b = CommunityId::from_uuid(make_community(&db.pool).await); - - // Identical referenced-event shape across both tenants. - let event_id = [0xABu8; 32]; - let event_created_at = Utc::now(); - let pubkey = [7u8; 32]; - let emoji = "👍"; - - // (1) Add succeeds under A (this INSERT 500'd before the fix). - assert!( - db.add_reaction( - community_a, - &event_id, - event_created_at, - &pubkey, - emoji, - None - ) - .await - .expect("add reaction under A"), - "first reaction under A must be inserted" - ); - // Idempotent: re-adding the same active reaction is a no-op. - assert!( - !db.add_reaction( - community_a, - &event_id, - event_created_at, - &pubkey, - emoji, - None - ) - .await - .expect("duplicate reaction under A"), - "active duplicate under A must not re-insert" - ); - - // (2) Visible on A, invisible on B (grouped read path). - let groups_a = db - .get_reactions(community_a, &event_id, event_created_at, 100, None) - .await - .expect("get reactions A"); - assert_eq!(groups_a.len(), 1, "A must see its own reaction group"); - assert_eq!(groups_a[0].emoji, emoji); - assert_eq!(groups_a[0].count, 1); - - let groups_b = db - .get_reactions(community_b, &event_id, event_created_at, 100, None) - .await - .expect("get reactions B"); - assert!( - groups_b.is_empty(), - "B must NOT see A's reaction for the same event shape, got {groups_b:?}" - ); - - // (3) Active-record lookup is scoped: present on A, absent on B. - assert!( - db.get_active_reaction_record(community_a, &event_id, event_created_at, &pubkey, emoji) - .await - .expect("active record A") - .is_some(), - "A's active reaction record must be present" - ); - assert!( - db.get_active_reaction_record(community_b, &event_id, event_created_at, &pubkey, emoji) - .await - .expect("active record B") - .is_none(), - "B must not find A's active reaction record" - ); - - // (4) B can add the identical shape independently (no PK collision). - assert!( - db.add_reaction( - community_b, - &event_id, - event_created_at, - &pubkey, - emoji, - None - ) - .await - .expect("add reaction under B"), - "B must be able to add the same shape as its own scoped row" - ); - - // (5) Removing from B does not touch A's row. - assert!( - db.remove_reaction(community_b, &event_id, event_created_at, &pubkey, emoji) - .await - .expect("remove under B"), - "B remove must affect B's own row" - ); - assert!( - db.get_active_reaction_record(community_a, &event_id, event_created_at, &pubkey, emoji) - .await - .expect("active record A after B remove") - .is_some(), - "A's reaction must survive a B-side removal" - ); - - // (6) A remove affects only A; A's read now empty. - assert!( - db.remove_reaction(community_a, &event_id, event_created_at, &pubkey, emoji) - .await - .expect("remove under A"), - "A remove must affect A's row" - ); - let groups_a_after = db - .get_reactions(community_a, &event_id, event_created_at, 100, None) - .await - .expect("get reactions A after remove"); - assert!( - groups_a_after.is_empty(), - "A's reaction must be gone after A removes it" - ); - } - - // ---- Read-replica routing ------------------------------------------------ - // - // These tests pin the routing contract of `Db::read()` and the two routed - // methods. A second scratch database stands in for the replica; the - // fixtures are deliberately DIVERGENT (rows that exist in only one of the - // two databases) so every assertion observes which pool actually served - // the query instead of trusting the routing code's word for it. - - async fn admin_url() -> String { - std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()) - } - - /// Create a fresh scratch database on the same server and optionally run migrations. - async fn create_scratch_db_through( - admin: &PgPool, - prefix: &str, - target: Option, - ) -> (PgPool, String) { - let name = format!("{}_{}", prefix, Uuid::new_v4().simple()); - sqlx::query(sqlx::AssertSqlSafe(format!("CREATE DATABASE {name}"))) - .execute(admin) - .await - .expect("create scratch db"); - let base = admin_url().await; - // Swap the database path segment of the admin URL for the scratch name. - let scratch_url = { - let idx = base.rfind('/').expect("db url has a path segment"); - format!("{}/{}", &base[..idx], name) - }; - let pool = PgPool::connect(&scratch_url) - .await - .expect("connect scratch db"); - match target { - Some(target) => migration::run_migrations_through(&pool, target) - .await - .expect("migrate scratch db through target"), - None => migration::run_migrations(&pool) - .await - .expect("migrate scratch db"), - } - (pool, name) - } - - /// Create a fresh scratch database on the same server and run all migrations. - /// Returns (pool, db_name); callers should `drop_scratch_db` when done. - async fn create_scratch_db(admin: &PgPool, prefix: &str) -> (PgPool, String) { - create_scratch_db_through(admin, prefix, None).await - } - - async fn drop_scratch_db(admin: &PgPool, pool: PgPool, name: &str) { - pool.close().await; - let _ = sqlx::query(sqlx::AssertSqlSafe(format!( - "DROP DATABASE IF EXISTS {name} WITH (FORCE)" - ))) - .execute(admin) - .await; - } - - /// Insert identical community + channel rows into a database so the same - /// (community, channel) ids resolve in both writer and replica. - async fn seed_community_channel( - pool: &PgPool, - community: Uuid, - channel: Uuid, - author: &nostr::Keys, - ) { - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community) - .bind(format!("replica-routing-{}.example", community.simple())) - .execute(pool) - .await - .expect("insert community"); - crate::channel::create_channel_with_id( - pool, - CommunityId::from_uuid(community), - channel, - &format!("replica-routing-{channel}"), - crate::channel::ChannelType::Stream, - crate::channel::ChannelVisibility::Open, - None, - author.public_key().to_bytes().as_slice(), - None, - ) - .await - .expect("create channel"); - } - - fn signed_event_at(keys: &nostr::Keys, content: &str, secs: u64) -> nostr::Event { - nostr::EventBuilder::new(nostr::Kind::Custom(9), content) - .custom_created_at(nostr::Timestamp::from(secs)) - .sign_with_keys(keys) - .expect("sign event") - } - - async fn insert_top_level(pool: &PgPool, community: Uuid, channel: Uuid, ev: &nostr::Event) { - let ts = - chrono::DateTime::from_timestamp(ev.created_at.as_secs() as i64, 0).expect("valid ts"); - event::insert_event_with_thread_metadata( - pool, - CommunityId::from_uuid(community), - ev, - Some(channel), - Some(event::ThreadMetadataParams { - event_id: ev.id.as_bytes(), - event_created_at: ts, - channel_id: channel, - parent_event_id: None, - parent_event_created_at: None, - root_event_id: None, - root_event_created_at: None, - depth: 0, - broadcast: true, - }), - ) - .await - .expect("insert top-level event"); - } - - async fn insert_thread_reply( - pool: &PgPool, - community: Uuid, - channel: Uuid, - root: &nostr::Event, - reply: &nostr::Event, - ) { - let reply_ts = chrono::DateTime::from_timestamp(reply.created_at.as_secs() as i64, 0) - .expect("valid ts"); - let root_ts = chrono::DateTime::from_timestamp(root.created_at.as_secs() as i64, 0) - .expect("valid ts"); - event::insert_event_with_thread_metadata( - pool, - CommunityId::from_uuid(community), - reply, - Some(channel), - Some(event::ThreadMetadataParams { - event_id: reply.id.as_bytes(), - event_created_at: reply_ts, - channel_id: channel, - parent_event_id: Some(root.id.as_bytes()), - parent_event_created_at: Some(root_ts), - root_event_id: Some(root.id.as_bytes()), - root_event_created_at: Some(root_ts), - depth: 1, - broadcast: false, - }), - ) - .await - .expect("insert reply"); - } - - /// Composite thread cursor: 8-byte BE seconds + raw event id. - fn thread_cursor(reply: &crate::thread::ThreadReply) -> Vec { - let mut cur = reply.created_at.timestamp().to_be_bytes().to_vec(); - cur.extend_from_slice(&reply.event_id); - cur - } - - #[tokio::test] - async fn read_falls_back_to_writer_when_no_replica_configured() { - // Pure wiring test — connect_lazy never touches the network. - let pool = sqlx::PgPool::connect_lazy(TEST_DB_URL).expect("lazy pool"); - let db = Db::from_pool(pool); - assert!(!db.has_read_pool()); - assert!( - std::ptr::eq(db.read(), &db.pool), - "read() must be the writer pool when no replica is configured" - ); - assert!(db.read_pool_stats().is_none()); - } - - #[test] - fn read_budget_zero_disables_and_large_values_clamp_to_staleness() { - assert_eq!(read_budget_from_ms(0), None, "0 = bounded routing off"); - assert_eq!( - read_budget_from_ms(1000), - Some(std::time::Duration::from_millis(1000)) - ); - assert_eq!( - read_budget_from_ms(10_000_000), - Some(replica_fence::FENCE_STALENESS), - "budgets above the staleness gate clamp to it" - ); - } - - /// Truth table for [`RoutePredicate::for_query`]: the strongest sound - /// predicate per query shape, and — the deploy-day default row — that - /// `routing_enabled = false` (BUZZ_REPLICA_READ_MAX_AGE_MS unset) - /// forces `Bounded` even for covered-eligible shapes, so the zero - /// budget fails the new seams closed (Dawn's covered-at-zero-budget - /// catch, design doc rev 5). - #[test] - fn for_query_predicate_truth_table() { - let community = CommunityId::from_uuid(Uuid::new_v4()); - let channel = Uuid::new_v4(); - let until = chrono::Utc::now(); - - let pinned_with_until = { - let mut q = event::EventQuery::for_community(community); - q.channel_id = Some(channel); - q.until = Some(until); - q - }; - let pinned_no_until = { - let mut q = event::EventQuery::for_community(community); - q.channel_id = Some(channel); - q - }; - let unpinned_with_until = { - let mut q = event::EventQuery::for_community(community); - q.until = Some(until); - q - }; - let global_only = { - let mut q = event::EventQuery::for_community(community); - q.global_only = true; - q.until = Some(until); - q - }; - - // Deploy-day default: budget unset ⇒ Bounded regardless of shape. - // The zero budget then fails Bounded closed, so the new seams - // record writer/disabled — merging with no env var set is a no-op. - assert!( - matches!( - RoutePredicate::for_query(&pinned_with_until, false), - RoutePredicate::Bounded - ), - "budget unset must not reach the covered arm even when eligible" - ); - - // Budget set + channel pin + until ⇒ the strongest predicate. - assert!(matches!( - RoutePredicate::for_query(&pinned_with_until, true), - RoutePredicate::BoundedOrCovered { .. } - )); - - // Missing either covered precondition ⇒ Bounded. - assert!(matches!( - RoutePredicate::for_query(&pinned_no_until, true), - RoutePredicate::Bounded - )); - assert!(matches!( - RoutePredicate::for_query(&unpinned_with_until, true), - RoutePredicate::Bounded - )); - // global_only implies `channel_id = None`, so the channel-pin - // precondition fails and no covered arm is possible — `for_query` - // never inspects `global_only` itself; the row holds because - // constructor 1 (channel pin) returns None for an unpinned query. - assert!(matches!( - RoutePredicate::for_query(&global_only, true), - RoutePredicate::Bounded - )); - } - - /// The pre-existing cursor paths are NOT budget-gated: a channel-window - /// cursor page still derives `Covered` with no `routing_enabled` input - /// at all — at B=0 today it routes covered, and that status quo is - /// intentionally unchanged by the `for_query` gate (Max's matrix row: - /// old paths route at budget-unset; only the new seams go dark). - #[test] - fn channel_cursor_predicate_is_not_budget_gated() { - let channel = Uuid::new_v4(); - let cursor = Some((chrono::Utc::now(), vec![1u8; 32])); - assert!(matches!( - RoutePredicate::from_channel_cursor(channel, &cursor), - RoutePredicate::Covered { .. } - )); - // Head fetch (no cursor) is bounded — gated by the budget. - assert!(matches!( - RoutePredicate::from_channel_cursor(channel, &None), - RoutePredicate::Bounded - )); - } - - /// D5 wiring: `read_pool_stats().max` must be the READER pool's own - /// ceiling, not the writer's — `buzz_db_read_pool_active / _max` is the - /// operator's utilisation signal and inheriting the writer's max hides - /// reader saturation by exactly the sizing ratio. Pure wiring test: - /// `connect_lazy` never touches the network, but it does spawn the - /// pool reaper task, which needs a Tokio runtime — hence - /// `#[tokio::test]` despite the test body itself never awaiting. - #[tokio::test] - async fn read_pool_stats_reports_reader_ceiling_not_writer() { - let writer = sqlx::postgres::PgPoolOptions::new() - .max_connections(20) - .connect_lazy(TEST_DB_URL) - .expect("lazy writer pool"); - let reader = sqlx::postgres::PgPoolOptions::new() - .max_connections(40) - .connect_lazy(TEST_DB_URL) - .expect("lazy reader pool"); - let db = Db::from_pools(writer, reader); - assert_eq!(db.pool_stats().max, 20); - assert_eq!( - db.read_pool_stats().expect("read pool configured").max, - 40, - "reader gauge must report the reader's own ceiling" - ); - } - - /// D4 wiring: the reader pool is built lazily with `min_connections(0)` - /// and the short reader acquire timeout — construction must succeed - /// with no replica listening (reader-down at boot must not crash the - /// relay), and `read_max_connections` must honour - /// `DbConfig::read_max_connections` over the writer sizing. - /// `#[tokio::test]` because `connect_lazy` spawns the pool reaper task, - /// which needs a Tokio runtime even though nothing is dialed. - #[tokio::test] - async fn connect_read_pool_is_lazy_and_independently_sized() { - let config = DbConfig { - max_connections: 20, - read_max_connections: Some(7), - ..DbConfig::default() - }; - // Unroutable per RFC 5737 TEST-NET-1: proves nothing is dialed at - // construction time. - let pool = Db::connect_read_pool(&config, "postgres://user:pw@192.0.2.1:5432/none", 7) - .expect("lazy construction must not dial the replica"); - assert_eq!(pool.options().get_max_connections(), 7); - assert_eq!(pool.options().get_min_connections(), 0); - assert_eq!( - pool.options().get_acquire_timeout(), - Db::READER_ACQUIRE_TIMEOUT - ); - } - - /// Channel window: head fetch (no cursor) reads the WRITER; cursor pages - /// read the REPLICA. Divergent fixtures prove which pool served each. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn channel_window_routes_head_to_writer_and_cursor_pages_to_replica() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "routing_w").await; - let (replica, rname) = create_scratch_db(&admin, "routing_r").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&writer, community, channel, &author).await; - seed_community_channel(&replica, community, channel, &author).await; - - // Shared history (both databases): m1 < m2 < m3. - let base = 1_700_000_000u64; - let m1 = signed_event_at(&author, "m1", base); - let m2 = signed_event_at(&author, "m2", base + 10); - let m3 = signed_event_at(&author, "m3", base + 20); - for pool in [&writer, &replica] { - for ev in [&m1, &m2, &m3] { - insert_top_level(pool, community, channel, ev).await; - } - } - // Lag: the newest event exists only on the writer. - let fresh = signed_event_at(&author, "fresh-writer-only", base + 30); - insert_top_level(&writer, community, channel, &fresh).await; - // Marker: exists only on the "replica" (unphysical for a real replica, - // but it makes replica-served pages unambiguous). - let marker = signed_event_at(&author, "replica-only-marker", base + 5); - insert_top_level(&replica, community, channel, &marker).await; - - let db = Db::from_pools(writer.clone(), replica.clone()); - // Open the fence through "now": the fixture's history is far in the - // past, so every cursor falls below the fence and routing is - // eligible. Fence-gating itself is pinned by the fence tests below. - db.fence().force_open_for_tests(chrono::Utc::now()); - let cid = CommunityId::from_uuid(community); - - // Head fetch (cursor: None) → writer: sees `fresh`, never `marker`. - let head = db - .get_channel_window(cid, channel, 2, None, None) - .await - .expect("head window"); - let head_contents: Vec = head - .rows - .iter() - .map(|r| r.stored_event.event.content.clone()) - .collect(); - assert_eq!( - head_contents, - vec!["fresh-writer-only".to_string(), "m3".to_string()], - "head fetch must be served by the writer" - ); - - // Cursor page → replica: sees `marker`, never `fresh`. - let cursor = head.next_cursor.expect("has_more implies next_cursor"); - let page2 = db - .get_channel_window(cid, channel, 10, Some(cursor), None) - .await - .expect("cursor window"); - let page2_contents: Vec = page2 - .rows - .iter() - .map(|r| r.stored_event.event.content.clone()) - .collect(); - assert_eq!( - page2_contents, - vec![ - "m2".to_string(), - "replica-only-marker".to_string(), - "m1".to_string() - ], - "cursor page must be served by the replica" - ); - - drop_scratch_db(&admin, replica, &rname).await; - drop_scratch_db(&admin, writer, &wname).await; - } - - /// Fail-closed on a mid-request replica failure (Dawn, review of - /// 1b0aa0dfa): a replica-routed page whose query errors *after* the - /// proof (the live shape is a hot-standby recovery conflict — 40001 / - /// 25P02 — cancelling the held snapshot under `max_standby_streaming_delay`) - /// must be re-run on the writer and served, never surfaced as an error - /// the writer could have answered. Degraded capacity, never holes. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn replica_window_failure_falls_back_to_writer() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "fb_w").await; - let (replica, rname) = create_scratch_db(&admin, "fb_r").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&writer, community, channel, &author).await; - seed_community_channel(&replica, community, channel, &author).await; - - let base = 1_700_000_000u64; - let m1 = signed_event_at(&author, "m1", base); - let m2 = signed_event_at(&author, "m2", base + 10); - let m3 = signed_event_at(&author, "m3", base + 20); - for pool in [&writer, &replica] { - for ev in [&m1, &m2, &m3] { - insert_top_level(pool, community, channel, ev).await; - } - } - let marker = signed_event_at(&author, "replica-only-marker", base + 5); - insert_top_level(&replica, community, channel, &marker).await; - - let db = Db::from_pools(writer.clone(), replica.clone()); - db.fence().force_open_for_tests(chrono::Utc::now()); - let cid = CommunityId::from_uuid(community); - - let head = db - .get_channel_window(cid, channel, 1, None, None) - .await - .expect("head window"); - let cursor = head.next_cursor.expect("has_more implies next_cursor"); - - // Guard against a vacuous pass: the cursor page must actually be - // replica-eligible before we break the replica. - let healthy = db - .get_channel_window(cid, channel, 10, Some(cursor.clone()), None) - .await - .expect("healthy cursor window"); - assert!( - healthy - .rows - .iter() - .any(|r| r.stored_event.event.content == "replica-only-marker"), - "fixture must route the cursor page to the replica while healthy" - ); - - // Break the replica AFTER the proof point: the heartbeat table stays - // intact (the observation succeeds), the page query then fails. - sqlx::query("DROP TABLE events CASCADE") - .execute(&replica) - .await - .expect("drop replica events"); - - let page = db - .get_channel_window(cid, channel, 10, Some(cursor), None) - .await - .expect("replica failure must fall back to the writer, not error"); - let contents: Vec<&str> = page - .rows - .iter() - .map(|r| r.stored_event.event.content.as_str()) - .collect(); - assert_eq!( - contents, - vec!["m2", "m1"], - "fallback page must be the writer's answer (no replica marker)" - ); - - drop_scratch_db(&admin, replica, &rname).await; - drop_scratch_db(&admin, writer, &wname).await; - } - - /// [`replica_window_failure_falls_back_to_writer`] for the thread-replies - /// path: a replica-routed thread page whose query errors after the proof - /// re-runs on the writer instead of surfacing an error. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn replica_thread_failure_falls_back_to_writer() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "fbt_w").await; - let (replica, rname) = create_scratch_db(&admin, "fbt_r").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&writer, community, channel, &author).await; - seed_community_channel(&replica, community, channel, &author).await; - - let base = 1_700_000_000u64; - let root = signed_event_at(&author, "root", base); - for pool in [&writer, &replica] { - insert_top_level(pool, community, channel, &root).await; - } - let replies: Vec = (1..=3) - .map(|i| signed_event_at(&author, &format!("r{i}"), base + 10 * i as u64)) - .collect(); - for pool in [&writer, &replica] { - for reply in &replies { - insert_thread_reply(pool, community, channel, &root, reply).await; - } - } - // Replica-only divergent reply between r2 and r3 marks replica serves. - let ghost = signed_event_at(&author, "replica-only-ghost", base + 25); - insert_thread_reply(&replica, community, channel, &root, &ghost).await; - - let db = Db::from_pools(writer.clone(), replica.clone()); - db.fence().force_open_for_tests(chrono::Utc::now()); - let cid = CommunityId::from_uuid(community); - - let page1 = db - .get_thread_replies(cid, root.id.as_bytes(), Some(10), 2, None) - .await - .expect("head page"); - let cur = thread_cursor(page1.last().expect("page 1 non-empty")); - - // Healthy: the full page after r2 is the replica's [ghost]. - let healthy = db - .get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur)) - .await - .expect("healthy replica page"); - assert_eq!( - healthy[0].stored_event.event.content, "replica-only-ghost", - "fixture must route the cursor page to the replica while healthy" - ); - - sqlx::query("DROP TABLE events CASCADE") - .execute(&replica) - .await - .expect("drop replica events"); - - let page = db - .get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur)) - .await - .expect("replica failure must fall back to the writer, not error"); - assert_eq!( - page[0].stored_event.event.content, "r3", - "fallback page must be the writer's answer" - ); - - drop_scratch_db(&admin, replica, &rname).await; - drop_scratch_db(&admin, writer, &wname).await; - } - - /// Mid-request degradation of the held session (Dawn, review of - /// 1b0aa0dfa): when the proved replica transaction dies between the page - /// and an aux follow-up (stand-in: `pg_terminate_backend` on the reader - /// connection, the same tx-fatal shape as a recovery-conflict cancel), - /// [`ReadSession::query_events`] must re-run the query on the writer and - /// permanently degrade the session instead of surfacing the error. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn read_session_degrades_to_writer_when_replica_connection_dies() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "deg_w").await; - let (replica, rname) = create_scratch_db(&admin, "deg_r").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&writer, community, channel, &author).await; - seed_community_channel(&replica, community, channel, &author).await; - - let base = 1_700_000_000u64; - let m1 = signed_event_at(&author, "m1", base); - let m2 = signed_event_at(&author, "m2", base + 10); - for pool in [&writer, &replica] { - for ev in [&m1, &m2] { - insert_top_level(pool, community, channel, ev).await; - } - } - // Writer-only row proves the degraded aux ran on the writer. - let fresh = signed_event_at(&author, "fresh-writer-only", base + 20); - insert_top_level(&writer, community, channel, &fresh).await; - - let db = Db::from_pools(writer.clone(), replica.clone()); - db.fence().force_open_for_tests(chrono::Utc::now()); - let cid = CommunityId::from_uuid(community); - - let head = db - .get_channel_window(cid, channel, 1, None, None) - .await - .expect("head window"); - let cursor = head.next_cursor.expect("has_more implies next_cursor"); - let (_window, mut session) = db - .get_channel_window_with_session(cid, channel, 10, Some(cursor), None) - .await - .expect("routed cursor window"); - assert!( - session.is_replica(), - "fixture must route this page to the replica" - ); - - // Kill the reader's backend out from under the held transaction. - sqlx::query( - "SELECT pg_terminate_backend(pid) FROM pg_stat_activity \ - WHERE datname = $1 AND pid <> pg_backend_pid()", - ) - .bind(&rname) - .execute(&admin) - .await - .expect("terminate replica backends"); - - let mut aux = EventQuery::for_community(cid); - aux.channel_id = Some(channel); - let rows = session - .query_events(&aux) - .await - .expect("session must degrade to the writer, not error"); - assert!( - rows.iter() - .any(|se| se.event.content == "fresh-writer-only"), - "degraded aux must be served by the writer" - ); - assert!( - !session.is_replica(), - "the session must be permanently degraded to the writer" - ); - - drop(session); - drop_scratch_db(&admin, replica, &rname).await; - drop_scratch_db(&admin, writer, &wname).await; - } - - /// Snapshot continuity (Wren, review of 17ea2ff6a): the routed request - /// runs inside ONE `REPEATABLE READ, READ ONLY` transaction whose first - /// statement was the heartbeat observation — so a row committed on the - /// replica *after* the proof must be invisible to every follow-up - /// statement in the same request (page, participants, aux). This - /// distinguishes the transaction contract from mere connection reuse: - /// autocommit statements on the same backend advance their snapshot - /// per statement and WOULD see the mid-request row. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn routed_request_holds_one_snapshot_across_page_and_aux() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "snap_w").await; - let (replica, rname) = create_scratch_db(&admin, "snap_r").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&writer, community, channel, &author).await; - seed_community_channel(&replica, community, channel, &author).await; - - let base = 1_700_000_000u64; - let m1 = signed_event_at(&author, "m1", base); - let m2 = signed_event_at(&author, "m2", base + 10); - for pool in [&writer, &replica] { - for ev in [&m1, &m2] { - insert_top_level(pool, community, channel, ev).await; - } - } - - let db = Db::from_pools(writer.clone(), replica.clone()); - db.fence().force_open_for_tests(chrono::Utc::now()); - let cid = CommunityId::from_uuid(community); - - // Head page on the writer yields the cursor for a replica-routed page. - let head = db - .get_channel_window(cid, channel, 1, None, None) - .await - .expect("head window"); - let cursor = head.next_cursor.expect("has_more implies next_cursor"); - - // Route the cursor page to the replica and HOLD the session. - let (window, mut session) = db - .get_channel_window_with_session(cid, channel, 10, Some(cursor), None) - .await - .expect("routed cursor window"); - assert!( - session.is_replica(), - "fixture must route this page to the replica" - ); - assert_eq!(window.rows.len(), 1, "page after m2 is [m1]"); - - // Mid-request: a new event commits on the replica (stands in for - // replay advancing between the page and the aux closure). - let mid = signed_event_at(&author, "mid-request-commit", base + 5); - insert_top_level(&replica, community, channel, &mid).await; - - // A fresh autocommit statement on ANOTHER session sees it — the row - // is really there (control for the assertion below). - let mut control = EventQuery::for_community(cid); - control.channel_id = Some(channel); - let visible_elsewhere = event::query_events(&replica, &control) - .await - .expect("control query"); - assert!( - visible_elsewhere - .iter() - .any(|se| se.event.content == "mid-request-commit"), - "control: the mid-request row must be committed and visible to a new snapshot" - ); - - // The held request session must NOT see it: its snapshot was - // anchored by the heartbeat observation, before the commit. - let mut aux = EventQuery::for_community(cid); - aux.channel_id = Some(channel); - let in_request = session.query_events(&aux).await.expect("aux query"); - assert!( - !in_request - .iter() - .any(|se| se.event.content == "mid-request-commit"), - "request transaction must hold the proof-time snapshot; a \ - mid-request commit leaking in means the aux ran outside the \ - request transaction (autocommit connection reuse)" - ); - // Rows from the proof-time snapshot are still served. - assert!( - in_request.iter().any(|se| se.event.content == "m1"), - "proof-time rows must remain visible in the request snapshot" - ); - - drop(session); - drop_scratch_db(&admin, replica, &rname).await; - drop_scratch_db(&admin, writer, &wname).await; - } - - /// Head gate (Predicate A): with the budget unset, a head fetch reads - /// the writer even over an open fence; with a budget set and a fresh - /// proved entry, the head page is served by the replica session - /// (bounded staleness accepted); with a budget the fence entry exceeds, - /// the head page falls back to the writer. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn head_fetch_routes_by_configured_budget() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "head_w").await; - let (replica, rname) = create_scratch_db(&admin, "head_r").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&writer, community, channel, &author).await; - seed_community_channel(&replica, community, channel, &author).await; - - let base = 1_700_000_000u64; - let shared = signed_event_at(&author, "shared", base); - for pool in [&writer, &replica] { - insert_top_level(pool, community, channel, &shared).await; - } - // Divergent heads prove which pool served the fetch. - let fresh = signed_event_at(&author, "fresh-writer-only", base + 30); - insert_top_level(&writer, community, channel, &fresh).await; - let marker = signed_event_at(&author, "replica-only-marker", base + 20); - insert_top_level(&replica, community, channel, &marker).await; - - let mut db = Db::from_pools(writer.clone(), replica.clone()); - db.fence().force_open_for_tests(chrono::Utc::now()); - let cid = CommunityId::from_uuid(community); - let head_contents = |w: &thread::ChannelWindow| -> Vec { - w.rows - .iter() - .map(|r| r.stored_event.event.content.clone()) - .collect() - }; - - // Budget unset (rollout default): head → writer, fence open or not. - let head = db - .get_channel_window(cid, channel, 2, None, None) - .await - .expect("head, gate off"); - assert_eq!( - head_contents(&head), - vec!["fresh-writer-only".to_string(), "shared".to_string()], - "head routing must default off" - ); - - // Budget set, entry fresh (just recorded): head → replica. - db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); - let head = db - .get_channel_window(cid, channel, 2, None, None) - .await - .expect("head, gate on"); - assert_eq!( - head_contents(&head), - vec!["replica-only-marker".to_string(), "shared".to_string()], - "a fresh proved entry within budget must serve the head from the replica" - ); - - // Entry older than the budget: head falls back to the writer. - db.fence().close(); - db.fence().force_open_for_tests_at( - chrono::Utc::now(), - std::time::Instant::now() - std::time::Duration::from_secs(10), - ); - let head = db - .get_channel_window(cid, channel, 2, None, None) - .await - .expect("head, entry too old"); - assert_eq!( - head_contents(&head), - vec!["fresh-writer-only".to_string(), "shared".to_string()], - "an over-budget entry must fail the head gate closed" - ); - - drop_scratch_db(&admin, replica, &rname).await; - drop_scratch_db(&admin, writer, &wname).await; - } - - /// End-to-end deploy-default proof for the NEW routed seams: with the - /// budget unset, a covered-eligible query (channel-pinned + `until`) - /// through [`Db::query_events_routed`] is served by the WRITER — the - /// `for_query` gate keeps the covered arm dark (rev 5). With the budget - /// set and a fresh proved entry, the same query routes to the replica. - /// Divergent fixtures prove which pool served each read. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn query_events_routed_defaults_dark_and_routes_covered_when_enabled() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "qer_w").await; - let (replica, rname) = create_scratch_db(&admin, "qer_r").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&writer, community, channel, &author).await; - seed_community_channel(&replica, community, channel, &author).await; - - let base = 1_700_000_000u64; - let shared = signed_event_at(&author, "shared", base); - for pool in [&writer, &replica] { - insert_top_level(pool, community, channel, &shared).await; - } - let writer_only = signed_event_at(&author, "writer-only", base + 10); - insert_top_level(&writer, community, channel, &writer_only).await; - let replica_only = signed_event_at(&author, "replica-only", base + 20); - insert_top_level(&replica, community, channel, &replica_only).await; - - let mut db = Db::from_pools(writer.clone(), replica.clone()); - db.fence().force_open_for_tests(chrono::Utc::now()); - let cid = CommunityId::from_uuid(community); - - // Covered-eligible shape: channel-pinned with an `until` upper - // bound below the (now) fence wall. - let q = { - let mut q = EventQuery::for_community(cid); - q.channel_id = Some(channel); - q.until = chrono::DateTime::from_timestamp((base + 60) as i64, 0); - q - }; - let contents = |evs: &[StoredEvent]| -> std::collections::BTreeSet { - evs.iter().map(|e| e.event.content.clone()).collect() - }; - - // Deploy default: budget unset ⇒ writer, even though the shape is - // covered-eligible and the fence is open. - let rows = db - .query_events_routed("test_routed", &q) - .await - .expect("routed query, gate off"); - assert!( - contents(&rows).contains("writer-only"), - "budget unset must serve the writer" - ); - assert!( - !contents(&rows).contains("replica-only"), - "budget unset must not reach the replica via the covered arm" - ); - - // Budget set ⇒ the covered arm serves it from the replica. - db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); - let rows = db - .query_events_routed("test_routed", &q) - .await - .expect("routed query, gate on"); - assert!( - contents(&rows).contains("replica-only"), - "budget set + covered-eligible must route to the replica" - ); - assert!(!contents(&rows).contains("writer-only")); - - drop_scratch_db(&admin, replica, &rname).await; - drop_scratch_db(&admin, writer, &wname).await; - } - - /// COUNT is bounded-only (rev 5 deletion-visibility rule): a - /// covered-eligible shape must NOT let a count take the covered arm. - /// With the budget unset the count reads the WRITER even with an open - /// fence; with the budget set and a fresh entry it reads the replica - /// under the bounded arm. Divergent row counts prove the serving pool. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn count_events_routed_is_bounded_only() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "cnt_w").await; - let (replica, rname) = create_scratch_db(&admin, "cnt_r").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&writer, community, channel, &author).await; - seed_community_channel(&replica, community, channel, &author).await; - - let base = 1_700_000_000u64; - // Writer: 2 rows. Replica: 1 row. - for (i, content) in ["a", "b"].iter().enumerate() { - let ev = signed_event_at(&author, content, base + i as u64); - insert_top_level(&writer, community, channel, &ev).await; - } - let ev = signed_event_at(&author, "c", base); - insert_top_level(&replica, community, channel, &ev).await; - - let mut db = Db::from_pools(writer.clone(), replica.clone()); - db.fence().force_open_for_tests(chrono::Utc::now()); - let cid = CommunityId::from_uuid(community); - - // Covered-eligible shape on purpose: pinned + until. A count must - // ignore that eligibility. - let q = { - let mut q = EventQuery::for_community(cid); - q.channel_id = Some(channel); - q.until = chrono::DateTime::from_timestamp((base + 60) as i64, 0); - q - }; - - // Budget unset ⇒ bounded arm disabled ⇒ writer. - let n = db - .count_events_routed("test_count", &q) - .await - .expect("count, gate off"); - assert_eq!(n, 2, "budget unset must count on the writer"); - - // Budget set + fresh entry ⇒ bounded arm ⇒ replica. - db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); - let n = db - .count_events_routed("test_count", &q) - .await - .expect("count, gate on"); - assert_eq!(n, 1, "budget set must count on the replica (bounded)"); - - // Entry older than the budget ⇒ bounded fails ⇒ writer. Covered - // would still hold here (upper <= wall) — proving count never - // consults it. - db.fence().close(); - db.fence().force_open_for_tests_at( - chrono::Utc::now(), - std::time::Instant::now() - std::time::Duration::from_secs(10), - ); - let n = db - .count_events_routed("test_count", &q) - .await - .expect("count, entry too old"); - assert_eq!( - n, 2, - "an over-budget entry must fail the count closed to the writer, \ - even when the covered arm would admit the shape" - ); - - drop_scratch_db(&admin, replica, &rname).await; - drop_scratch_db(&admin, writer, &wname).await; - } - - /// Routed relay-membership check: budget unset ⇒ writer; budget set + - /// fresh proved entry ⇒ replica (bounded arm); over-budget entry ⇒ - /// writer. Divergent membership rows prove which pool answered. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn is_relay_member_is_bounded_routed_and_fails_closed() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "mem_w").await; - let (replica, rname) = create_scratch_db(&admin, "mem_r").await; - - let community = Uuid::new_v4(); - for pool in [&writer, &replica] { - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community) - .bind(format!("member-routing-{}.example", community.simple())) - .execute(pool) - .await - .expect("insert community"); - } - let cid = CommunityId::from_uuid(community); - let writer_only = "aa".repeat(32); - let replica_only = "bb".repeat(32); - relay_members::add_relay_member(&writer, cid, &writer_only, "member", None) - .await - .expect("seed writer member"); - relay_members::add_relay_member(&replica, cid, &replica_only, "member", None) - .await - .expect("seed replica member"); - - let mut db = Db::from_pools(writer.clone(), replica.clone()); - db.fence().force_open_for_tests(chrono::Utc::now()); - - // Budget unset ⇒ bounded arm disabled ⇒ writer. - assert!( - db.is_relay_member(cid, &writer_only) - .await - .expect("gate off"), - "budget unset must answer from the writer" - ); - assert!(!db.is_relay_member(cid, &replica_only).await.unwrap()); - - // Budget set + fresh entry ⇒ replica. - db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); - assert!( - db.is_relay_member(cid, &replica_only) - .await - .expect("gate on"), - "budget set must answer from the replica" - ); - assert!(!db.is_relay_member(cid, &writer_only).await.unwrap()); - - // Entry older than the budget ⇒ fail closed to the writer. Close - // first so no prior fresh entry can be the one proved (matches the - // count test; today `force_open_for_tests_at` also clears the ring). - db.fence().close(); - db.fence().force_open_for_tests_at( - chrono::Utc::now(), - std::time::Instant::now() - std::time::Duration::from_secs(10), - ); - assert!( - db.is_relay_member(cid, &writer_only) - .await - .expect("entry too old"), - "an over-budget entry must fail closed to the writer" - ); - - drop_scratch_db(&admin, replica, &rname).await; - drop_scratch_db(&admin, writer, &wname).await; - } - - /// Community separation across every routed seam, verified on - /// REPLICA-SERVED reads. - /// - /// The pre-existing feed/event scoping tests prove the shared SQL - /// builders confine rows to one community, but they exercise those - /// builders through the WRITER wrapper. `_on` variants are - /// executor-only refactors, so scoping *should* be identical — this - /// test refuses to take that on faith and re-proves it through the - /// routed executor, on a snapshot the replica actually served. - /// - /// Construction: two communities A and B exist in BOTH databases with - /// the same ids. The replica additionally holds a `replica-only` row in - /// each — divergent fixtures, so any row bearing that content proves - /// the replica (not the writer) served the read. Every assertion - /// requests A and demands B's rows never appear, including B's - /// `replica-only` row, which is the one a leaky predicate would surface. - /// The routed fallback must cost ONE reader acquire budget, even when the - /// Aurora capability cache is cold. - /// - /// Regression test for a stacked-budget bug found at `9fa3c9c0b`: the - /// capability probe used to `acquire()` from the pool itself and return - /// `false` *uncached* on `PoolTimedOut`, so the routed read then spent a - /// SECOND `READER_ACQUIRE_TIMEOUT` inside `begin`. Measured 302ms against - /// a ~150ms documented bound. Boot priming - /// ([`Db::spawn_read_pool_boot_ping`]) hid it only when the boot ping - /// SUCCEEDED — and a reader that is unavailable at boot is exactly the - /// case the bound is specified for, so the two failures are correlated. - /// - /// The fixture reproduces that state deliberately: a size-1 reader whose - /// sole connection is established and then HELD (so every further acquire - /// must time out), with `reader_aurora_identity` asserted cold. It routes - /// through `count_events_routed` rather than calling `proved_reader` - /// directly, because `buzz_db_route_decision` is emitted by `route_read` - /// — a direct call would prove the timing but never emit the label. - /// - /// Timing uses an upper bound of 2x the budget minus a margin: it must - /// fail for two stacked budgets (~300ms) while tolerating scheduler - /// jitter on one (~150ms). Asserting a lower bound too would pin the - /// budget's own value, which `reader_acquire_timeout_is_the_documented_budget` - /// already covers. - #[tokio::test(flavor = "current_thread")] - #[ignore = "requires Postgres"] - async fn routed_fallback_spends_one_acquire_budget_when_aurora_cache_is_cold() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (seed, wname) = create_scratch_db(&admin, "one_budget").await; - seed.close().await; - let base = admin_url().await; - let scratch_url = { - let idx = base.rfind('/').expect("db url has a path segment"); - format!("{}/{}", &base[..idx], wname) - }; - - // `Db::new` so the writer arms the floor guard and the reader is the - // real lazy `connect_read_pool` pool (min_connections=0, 150ms - // acquire timeout). Reader is sized 1 so holding one connection - // saturates it. - let mut db = Db::new(&DbConfig { - database_url: scratch_url.clone(), - read_database_url: Some(scratch_url), - max_connections: 4, - read_max_connections: Some(1), - ..DbConfig::default() - }) - .await - .expect("connect armed Db with size-1 lazy reader"); - db.fence().force_open_for_tests(chrono::Utc::now()); - db.set_replica_read_max_age_for_tests(Some(Duration::from_secs(5))); - - let read_pool = db.read_pool.clone().expect("reader pool configured"); - // Establish and hold the reader's only connection: saturated. - let held = read_pool - .acquire() - .await - .expect("establish the reader's sole connection"); - assert_eq!( - db.read_max_connections, 1, - "reader max must report 1 for this fixture to test saturation" - ); - assert_eq!( - read_pool.size(), - 1, - "the sole reader connection is established and held" - ); - // The bug is only observable with the capability cache cold; if a - // future change primes it here, this fixture would silently stop - // discriminating. - assert!( - db.reader_aurora_identity.get().is_none(), - "Aurora capability must be UNPRIMED (post-boot-ping-failure state)" - ); - - let recorder = metrics_util::debugging::DebuggingRecorder::new(); - let snapshotter = recorder.snapshotter(); - let query = EventQuery::for_community(CommunityId::from_uuid(Uuid::new_v4())); - - // The recorder is installed thread-locally, so it must stay installed - // across the `.await` — hence the guard form rather than - // `with_local_recorder`, whose closure cannot host an await. The - // `current_thread` flavor keeps the route decision on this thread; on - // a multi-thread runtime the emit could land on a worker where no - // local recorder is installed and the label assertions would vacuously - // see an empty snapshot. - let start = std::time::Instant::now(); - let count = { - let _guard = metrics::set_default_local_recorder(&recorder); - db.count_events_routed("one_budget_probe", &query).await - } - .expect("writer fallback still answers the read"); - let elapsed = start.elapsed(); - - assert_eq!(count, 0, "writer answered on an empty scratch database"); - assert!( - elapsed < Duration::from_millis(250), - "routed fallback must spend ONE {}ms acquire budget, not two; took {}ms", - Db::READER_ACQUIRE_TIMEOUT.as_millis(), - elapsed.as_millis() - ); - - let reasons: std::collections::HashMap<(String, String), u64> = snapshotter - .snapshot() - .into_vec() - .into_iter() - .filter(|(key, ..)| key.key().name() == "buzz_db_route_decision") - .map(|(key, _, _, value)| { - let metrics_util::debugging::DebugValue::Counter(n) = value else { - panic!("buzz_db_route_decision must be a counter"); - }; - let labels: Vec<_> = key.key().labels().collect(); - let get = |name: &str| { - labels - .iter() - .find(|l| l.key() == name) - .map(|l| l.value().to_owned()) - .unwrap_or_default() - }; - ((get("decision"), get("reason")), n) - }) - .collect(); - - assert_eq!( - reasons.get(&("writer".to_owned(), "reader_acquire_timeout".to_owned())), - Some(&1), - "saturated reader must fall back as writer/reader_acquire_timeout; got {reasons:?}" - ); - // `reader_validation_error` would mean we misclassified a timeout as a - // broken reader, and `pool_busy` is the retired name — neither may - // appear in ANY emitted label. - assert!( - !reasons - .keys() - .any(|(_, reason)| reason == "reader_validation_error" || reason == "pool_busy"), - "no reader_validation_error or retired pool_busy label may be emitted; got {reasons:?}" - ); - - drop(held); - drop_scratch_db(&admin, db.pool.clone(), &wname).await; - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn routed_reads_are_confined_to_the_requested_community() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "sep_w").await; - let (replica, rname) = create_scratch_db(&admin, "sep_r").await; - - let author = nostr::Keys::generate(); - let (comm_a, chan_a) = (Uuid::new_v4(), Uuid::new_v4()); - let (comm_b, chan_b) = (Uuid::new_v4(), Uuid::new_v4()); - for pool in [&writer, &replica] { - seed_community_channel(pool, comm_a, chan_a, &author).await; - seed_community_channel(pool, comm_b, chan_b, &author).await; - } - - // A p-tag mention is what makes a row eligible for the mentions and - // needs-action feeds. Kind 9 satisfies mentions + activity; - // needs-action admits only approval/reminder kinds, so each - // community also gets a kind-46010 row. - let mentioned = nostr::Keys::generate(); - let mentioned_hex = mentioned.public_key().to_hex(); - let mentioned_bytes = mentioned.public_key().to_bytes(); - let tagged_kind = |kind: u16, content: &str, secs: u64| { - nostr::EventBuilder::new(nostr::Kind::Custom(kind), content) - .tags([nostr::Tag::parse(["p", mentioned_hex.as_str()]).expect("p tag")]) - .custom_created_at(nostr::Timestamp::from(secs)) - .sign_with_keys(&author) - .expect("sign event") - }; - let tagged = |content: &str, secs: u64| tagged_kind(9, content, secs); - - let base = 1_700_000_000u64; - // Shared rows (both DBs) + replica-only rows (divergence) per community. - let a_shared = tagged("a-shared", base); - let b_shared = tagged("b-shared", base + 1); - for pool in [&writer, &replica] { - insert_top_level(pool, comm_a, chan_a, &a_shared).await; - insert_mentions( - pool, - CommunityId::from_uuid(comm_a), - &a_shared, - Some(chan_a), - ) - .await - .expect("mentions a-shared"); - insert_top_level(pool, comm_b, chan_b, &b_shared).await; - insert_mentions( - pool, - CommunityId::from_uuid(comm_b), - &b_shared, - Some(chan_b), - ) - .await - .expect("mentions b-shared"); - } - let a_replica_only = tagged("a-replica-only", base + 10); - let b_replica_only = tagged("b-replica-only", base + 11); - insert_top_level(&replica, comm_a, chan_a, &a_replica_only).await; - insert_mentions( - &replica, - CommunityId::from_uuid(comm_a), - &a_replica_only, - Some(chan_a), - ) - .await - .expect("mentions a-replica-only"); - insert_top_level(&replica, comm_b, chan_b, &b_replica_only).await; - insert_mentions( - &replica, - CommunityId::from_uuid(comm_b), - &b_replica_only, - Some(chan_b), - ) - .await - .expect("mentions b-replica-only"); - - // Needs-action fixtures: approval kind, replica-only in BOTH - // communities, so the assertion below is replica-served on A and - // must still not see B's. - let a_approval = tagged_kind(46010, "a-approval-replica-only", base + 20); - let b_approval = tagged_kind(46010, "b-approval-replica-only", base + 21); - insert_top_level(&replica, comm_a, chan_a, &a_approval).await; - insert_mentions( - &replica, - CommunityId::from_uuid(comm_a), - &a_approval, - Some(chan_a), - ) - .await - .expect("mentions a-approval"); - insert_top_level(&replica, comm_b, chan_b, &b_approval).await; - insert_mentions( - &replica, - CommunityId::from_uuid(comm_b), - &b_approval, - Some(chan_b), - ) - .await - .expect("mentions b-approval"); - - let mut db = Db::from_pools(writer.clone(), replica.clone()); - db.fence().force_open_for_tests(chrono::Utc::now()); - db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); - let cid_a = CommunityId::from_uuid(comm_a); - - let contents = |evs: &[StoredEvent]| -> std::collections::BTreeSet { - evs.iter().map(|e| e.event.content.clone()).collect() - }; - // Every routed seam must (a) have been served by the replica — - // proven by a divergent row absent from the writer — and (b) contain - // no row belonging to community B. All B fixtures are named `b-*`, - // so the leak check is a single prefix scan. - let assert_a_only = |rows: &[StoredEvent], marker: &str, seam: &str| { - let got = contents(rows); - assert!( - got.contains(marker), - "{seam}: must be replica-served (divergent row `{marker}` absent from writer); got {got:?}" - ); - assert!( - !got.iter().any(|c| c.starts_with("b-")), - "{seam}: community B rows leaked into a community A read; got {got:?}" - ); - }; - - // 1. Generic query — covered arm (channel-pinned + `until`). - let mut q = EventQuery::for_community(cid_a); - q.channel_id = Some(chan_a); - q.until = chrono::DateTime::from_timestamp((base + 60) as i64, 0); - let rows = db - .query_events_routed("sep_query", &q) - .await - .expect("routed query"); - assert_a_only(&rows, "a-replica-only", "query_events_routed"); - - // 2. Generic query — bounded arm (no channel pin at all, so a - // missing community predicate could not be masked by the pin). - let unpinned = EventQuery::for_community(cid_a); - let rows = db - .query_events_routed_bounded("sep_query_bounded", &unpinned) - .await - .expect("routed bounded query"); - assert_a_only(&rows, "a-replica-only", "query_events_routed_bounded"); - - // 3. COUNT — bounded-only. Community A holds 3 rows on the replica - // (shared + replica-only + approval) but only 1 on the writer, - // and 3 more exist in community B. Exactly 3 proves the read was - // both replica-served and community-confined. - let count = db - .count_events_routed("sep_count", &unpinned) - .await - .expect("routed count"); - assert_eq!( - count, 3, - "count must see A's three replica rows only — not B's, not the writer's one" - ); - - // 4. By-ID hydration — ids carry no channel pin, and B's ids are - // requested alongside A's. Only A's may hydrate. - let ids: Vec<&[u8]> = vec![ - a_shared.id.as_bytes(), - a_replica_only.id.as_bytes(), - b_shared.id.as_bytes(), - b_replica_only.id.as_bytes(), - ]; - let rows = db - .get_events_by_ids_routed("sep_by_ids", cid_a, &ids) - .await - .expect("routed by-ids"); - assert_a_only(&rows, "a-replica-only", "get_events_by_ids_routed"); - - // 5-7. All three feed builders, each given BOTH channels as - // accessible — so only the community predicate can exclude B. - let both = [chan_a, chan_b]; - let rows = db - .query_feed_mentions_routed("sep_feed", cid_a, &mentioned_bytes, &both, None, 50) - .await - .expect("routed mentions"); - assert_a_only(&rows, "a-replica-only", "query_feed_mentions_routed"); - - let rows = db - .query_feed_needs_action_routed("sep_feed", cid_a, &mentioned_bytes, &both, None, 50) - .await - .expect("routed needs action"); - assert_a_only( - &rows, - "a-approval-replica-only", - "query_feed_needs_action_routed", - ); - - let rows = db - .query_feed_activity_routed("sep_feed", cid_a, &both, None, 50) - .await - .expect("routed activity"); - assert_a_only(&rows, "a-replica-only", "query_feed_activity_routed"); - - drop_scratch_db(&admin, replica, &rname).await; - drop_scratch_db(&admin, writer, &wname).await; - } - - /// D4: a LAZY reader pool (connect_lazy, min_connections=0, never yet - /// used) must still let [`Db::spawn_fence_probe`] verify the writer's - /// floor guard and spawn — reader-down or reader-idle at boot must not - /// disable fence probing. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn lazy_reader_pool_still_spawns_fence_probe() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (seed, wname) = create_scratch_db(&admin, "lazy_w").await; - seed.close().await; - - let writer_url = { - let base = admin_url().await; - let idx = base.rfind('/').expect("db url has a path segment"); - format!("{}/{}", &base[..idx], wname) - }; - // `Db::new` (not `from_pools`) so the WRITER pool arms the - // `buzz.created_at_floor` GUC — `spawn_fence_probe` verifies the - // floor guard on a writer connection, and `create_scratch_db`'s - // plain `PgPool::connect` never arms it. The reader is still the - // lazy `connect_read_pool` pool this test is about. - let db = Db::new(&DbConfig { - database_url: writer_url.clone(), - read_database_url: Some(writer_url), - max_connections: 2, - ..DbConfig::default() - }) - .await - .expect("connect armed Db with lazy reader"); - - let spawned = db - .spawn_fence_probe() - .await - .expect("floor-guard verification must pass on the migrated writer"); - assert!(spawned, "a configured (lazy) reader must spawn the probe"); - - drop_scratch_db(&admin, db.pool.clone(), &wname).await; - } - - /// Thread replies: head fetch reads the writer; a FULL cursor page is - /// served by the replica; an UNDER-limit cursor page (candidate terminal - /// page) is re-run on the writer so a lagged replica can never truncate - /// the tail into a false EOF. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn thread_replies_cursor_pages_route_to_replica_with_writer_terminal_verification() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "routing_tw").await; - let (replica, rname) = create_scratch_db(&admin, "routing_tr").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&writer, community, channel, &author).await; - seed_community_channel(&replica, community, channel, &author).await; - - let base = 1_700_000_000u64; - let root = signed_event_at(&author, "root", base); - for pool in [&writer, &replica] { - insert_top_level(pool, community, channel, &root).await; - } - - // Writer holds replies r1..r5; the lagged replica only has r1..r3. - let replies: Vec = (1..=5) - .map(|i| signed_event_at(&author, &format!("r{i}"), base + 10 * i as u64)) - .collect(); - for reply in &replies { - insert_thread_reply(&writer, community, channel, &root, reply).await; - } - for reply in &replies[..3] { - insert_thread_reply(&replica, community, channel, &root, reply).await; - } - - let db = Db::from_pools(writer.clone(), replica.clone()); - // Open the fence through "now" — fixture history is far in the past. - db.fence().force_open_for_tests(chrono::Utc::now()); - let cid = CommunityId::from_uuid(community); - - // Page 1 (no cursor) → writer. - let page1 = db - .get_thread_replies(cid, root.id.as_bytes(), Some(10), 2, None) - .await - .expect("page 1"); - let contents: Vec<&str> = page1 - .iter() - .map(|r| r.stored_event.event.content.as_str()) - .collect(); - assert_eq!(contents, vec!["r1", "r2"], "head page from writer"); - - // Page 2: replica serves a FULL page (r3 exists there) — but wait: - // replica has r1..r3, page after r2 with limit 2 returns only [r3] - // (under limit) → terminal-verification re-runs on the writer, which - // returns [r3, r4]. A lag-truncated EOF must never surface. - let cur2 = thread_cursor(page1.last().expect("page 1 non-empty")); - let page2 = db - .get_thread_replies(cid, root.id.as_bytes(), Some(10), 2, Some(&cur2)) - .await - .expect("page 2"); - let contents: Vec<&str> = page2 - .iter() - .map(|r| r.stored_event.event.content.as_str()) - .collect(); - assert_eq!( - contents, - vec!["r3", "r4"], - "under-limit replica page must be re-verified on the writer" - ); - - // Full-page replica serve: with limit 1, the page after r2 is [r3] — - // exactly `limit` rows, so the replica result stands. Prove it came - // from the replica with a replica-only divergent reply. - let ghost = signed_event_at(&author, "replica-only-ghost", base + 25); - insert_thread_reply(&replica, community, channel, &root, &ghost).await; - let page_replica = db - .get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur2)) - .await - .expect("full replica page"); - let contents: Vec<&str> = page_replica - .iter() - .map(|r| r.stored_event.event.content.as_str()) - .collect(); - assert_eq!( - contents, - vec!["replica-only-ghost"], - "a full cursor page must be served by the replica" - ); - - // Same query with no replica configured reads the writer and cannot - // see the ghost. - let db_writer_only = Db::from_pool(writer.clone()); - let page_writer = db_writer_only - .get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur2)) - .await - .expect("writer-only page"); - let contents: Vec<&str> = page_writer - .iter() - .map(|r| r.stored_event.event.content.as_str()) - .collect(); - assert_eq!(contents, vec!["r3"], "unset replica falls back to writer"); - - drop_scratch_db(&admin, replica, &rname).await; - drop_scratch_db(&admin, writer, &wname).await; - } - - /// Channel DESC scrollback, out-of-order commit adversary: the replica is - /// missing a MIDDLE row (`m2`) because a transaction with an older - /// client-signed `created_at` committed late and has not replayed yet. - /// The replica's cursor page would be `[m1]` — silently skipping `m2` - /// forever, since the next cursor advances past it. The fence must route - /// any cursor above it to the writer. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn channel_cursor_above_fence_stays_on_writer_preventing_middle_hole() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "fence_cw").await; - let (replica, rname) = create_scratch_db(&admin, "fence_cr").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&writer, community, channel, &author).await; - seed_community_channel(&replica, community, channel, &author).await; - - let base = 1_700_000_000u64; - let m1 = signed_event_at(&author, "m1", base); - let m2 = signed_event_at(&author, "m2-late-commit", base + 10); - let m3 = signed_event_at(&author, "m3", base + 20); - let m4 = signed_event_at(&author, "m4", base + 30); - for ev in [&m1, &m2, &m3, &m4] { - insert_top_level(&writer, community, channel, ev).await; - } - // Replica replayed everything EXCEPT the late-committed m2. - for ev in [&m1, &m3, &m4] { - insert_top_level(&replica, community, channel, ev).await; - } - - let db = Db::from_pools(writer.clone(), replica.clone()); - let cid = CommunityId::from_uuid(community); - - // Head page (writer): [m4, m3]; cursor lands on m3 (base+20). - let head = db - .get_channel_window(cid, channel, 2, None, None) - .await - .expect("head window"); - let cursor = head.next_cursor.expect("has_more implies next_cursor"); - - // Fence closed → cursor page must come from the writer: m2 present. - let contents = |w: &thread::ChannelWindow| -> Vec { - w.rows - .iter() - .map(|r| r.stored_event.event.content.clone()) - .collect() - }; - let page_closed = db - .get_channel_window(cid, channel, 10, Some(cursor.clone()), None) - .await - .expect("cursor page, fence closed"); - assert_eq!( - contents(&page_closed), - vec!["m2-late-commit".to_string(), "m1".to_string()], - "fence closed: cursor pages route to the writer" - ); - - // Fence open but BELOW the cursor timestamp (covers base+5 only): - // the cursor (base+20) is not covered → writer again. - db.fence().force_open_for_tests( - chrono::DateTime::from_timestamp(base as i64 + 5, 0).expect("ts"), - ); - let page_below = db - .get_channel_window(cid, channel, 10, Some(cursor.clone()), None) - .await - .expect("cursor page, fence below cursor"); - assert_eq!( - contents(&page_below), - vec!["m2-late-commit".to_string(), "m1".to_string()], - "cursor above the fence must stay on the writer" - ); - - // Counterfactual pinning the hazard: were the fence (wrongly) open - // through now, the replica would serve the page WITHOUT m2 — the - // permanent-skip hole this fence exists to prevent. - db.fence().force_open_for_tests(chrono::Utc::now()); - let page_hazard = db - .get_channel_window(cid, channel, 10, Some(cursor), None) - .await - .expect("cursor page, fence wrongly open"); - assert_eq!( - contents(&page_hazard), - vec!["m1".to_string()], - "fixture models the inversion: an over-open fence would skip m2" - ); - - drop_scratch_db(&admin, replica, &rname).await; - drop_scratch_db(&admin, writer, &wname).await; - } - - /// Thread ASC pagination, out-of-order commit adversary: the replica - /// holds a FULL page whose newest row (`r4`) has a later key than a - /// not-yet-replayed row (`r3`). The old under-limit check alone would - /// serve `[r4]` and the client cursor would advance past `r3` forever. - /// The fence rule (full AND tail ≤ fence) must send that page to the - /// writer instead. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn thread_full_replica_page_above_fence_is_reverified_on_writer() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "fence_tw").await; - let (replica, rname) = create_scratch_db(&admin, "fence_tr").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&writer, community, channel, &author).await; - seed_community_channel(&replica, community, channel, &author).await; - - let base = 1_700_000_000u64; - let root = signed_event_at(&author, "root", base); - for pool in [&writer, &replica] { - insert_top_level(pool, community, channel, &root).await; - } - let replies: Vec = (1..=4) - .map(|i| signed_event_at(&author, &format!("r{i}"), base + 10 * i as u64)) - .collect(); - for reply in &replies { - insert_thread_reply(&writer, community, channel, &root, reply).await; - } - // Replica replayed r1, r2, r4 — the late-committed r3 is missing. - for reply in [&replies[0], &replies[1], &replies[3]] { - insert_thread_reply(&replica, community, channel, &root, reply).await; - } - - let db = Db::from_pools(writer.clone(), replica.clone()); - let cid = CommunityId::from_uuid(community); - - // Fence covers r2 (base+20) but not r3/r4. - db.fence().force_open_for_tests( - chrono::DateTime::from_timestamp(base as i64 + 20, 0).expect("ts"), - ); - - // Page after r2 with limit 1: the replica would return the FULL page - // [r4] — but its tail is above the fence, so the writer re-runs it - // and returns [r3]. No skip. - let page1 = db - .get_thread_replies(cid, root.id.as_bytes(), Some(10), 2, None) - .await - .expect("head page"); - let cur = thread_cursor(page1.last().expect("head page non-empty")); - let page = db - .get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur)) - .await - .expect("cursor page"); - let contents: Vec<&str> = page - .iter() - .map(|r| r.stored_event.event.content.as_str()) - .collect(); - assert_eq!( - contents, - vec!["r3"], - "a full replica page above the fence must be re-run on the writer" - ); - - // Counterfactual: an over-open fence would serve the replica's [r4], - // skipping r3 permanently. - db.fence().force_open_for_tests(chrono::Utc::now()); - let hazard = db - .get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur)) - .await - .expect("hazard page"); - let contents: Vec<&str> = hazard - .iter() - .map(|r| r.stored_event.event.content.as_str()) - .collect(); - assert_eq!( - contents, - vec!["r4"], - "fixture models the inversion: an over-open fence would skip r3" - ); - - drop_scratch_db(&admin, replica, &rname).await; - drop_scratch_db(&admin, writer, &wname).await; - } - - /// Commit-time floor guard (migration 0021), exact held-transaction - /// adversary: a channel-bearing row whose `created_at` is older than the - /// floor at COMMIT time must abort the transaction — the guard runs - /// inside commit processing with `clock_timestamp()`, so holding the - /// transaction open cannot outrun it. channel_id-NULL rows are - /// structurally exempt, and sessions without the GUC are unaffected. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn created_at_floor_guard_aborts_old_channel_rows_at_commit() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (pool, name) = create_scratch_db(&admin, "floor_guard").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&pool, community, channel, &author).await; - - let insert_raw = |ev: nostr::Event, channel_id: Option| { - let pool = pool.clone(); - async move { - let mut tx = pool.begin().await.expect("begin"); - // Arm the guard for this transaction only (the relay's - // writer pool arms it per connection; tests are explicit). - sqlx::query("SELECT set_config('buzz.created_at_floor', $1, true)") - .bind(crate::replica_fence::CREATED_AT_FLOOR_SECS.to_string()) - .execute(&mut *tx) - .await - .expect("arm guard"); - sqlx::query( - "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, \ - content, sig, received_at, channel_id) \ - VALUES ($1, $2, $3, to_timestamp($4), 9, '[]', $5, $6, NOW(), $7)", - ) - .bind(community) - .bind(ev.id.as_bytes().as_slice()) - .bind(ev.pubkey.to_bytes().as_slice()) - .bind(ev.created_at.as_secs() as f64) - .bind(&ev.content) - .bind(ev.sig.serialize().as_slice()) - .bind(channel_id) - .execute(&mut *tx) - .await - .expect("insert inside tx (guard is deferred to commit)"); - // Hold the transaction "open" past the insert, then commit — - // the deferred guard must still see the stale created_at. - sqlx::query("SELECT pg_sleep(0.05)") - .execute(&mut *tx) - .await - .expect("hold tx"); - tx.commit().await - } - }; - - let now_secs = chrono::Utc::now().timestamp() as u64; - let floor = crate::replica_fence::CREATED_AT_FLOOR_SECS as u64; - - // Old channel-bearing row → COMMIT aborts with check_violation. - let old = signed_event_at(&author, "old-held-tx", now_secs - floor - 60); - let err = insert_raw(old, Some(channel)) - .await - .expect_err("below-floor channel row must abort at COMMIT"); - let code = match &err { - sqlx::Error::Database(db_err) => db_err.code().map(|c| c.to_string()), - other => panic!("expected database error, got {other:?}"), - }; - assert_eq!( - code.as_deref(), - Some("23514"), - "guard raises check_violation" - ); - - // Fresh channel-bearing row → commits. - let fresh = signed_event_at(&author, "fresh", now_secs); - insert_raw(fresh, Some(channel)) - .await - .expect("fresh row commits under the armed guard"); - - // Old row WITHOUT a channel (push lease / profile shapes) → - // structurally exempt, commits. - let old_global = signed_event_at(&author, "old-global", now_secs - floor - 60); - insert_raw(old_global, None) - .await - .expect("channel_id-NULL rows are exempt from the floor"); - - // Unarmed session (no GUC) → guard inert; backfills stay possible - // (and must hold the fence closed, per the migration header). - let old_backfill = signed_event_at(&author, "old-backfill", now_secs - floor - 60); - insert_top_level(&pool, community, channel, &old_backfill).await; - - drop_scratch_db(&admin, pool, &name).await; - } - - #[test] - fn writer_pool_safety_hook_is_single_and_composed() { - let source = include_str!("lib.rs"); - let connect_pool = source - .split("async fn connect_pool") - .nth(1) - .and_then(|tail| tail.split("const READER_ACQUIRE_TIMEOUT").next()) - .expect("connect_pool source block"); - assert_eq!( - connect_pool.matches(".after_connect(").count(), - 1, - "SQLx replaces after_connect hooks; writer safety must use exactly one" - ); - assert!(connect_pool.contains("buzz.created_at_floor")); - assert!(connect_pool.contains("SHOW transaction_isolation")); - assert!(!connect_pool.contains("arm_floor_guard")); - assert!(!connect_pool.contains("_arm_floor_guard")); - assert!(!connect_pool.contains("allow(unused_variables)")); - - let reader_doc = source - .split("fn connect_read_pool") - .next() - .and_then(|prefix| prefix.rsplit("/// Connect the read-replica").next()) - .expect("reader pool documentation"); - assert!(reader_doc.contains("replica sessions are")); - assert!(reader_doc.contains("read-only")); - assert!(!reader_doc.contains("Db::connect_pool")); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn writer_pool_rejects_non_read_committed_database_default() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (seed_pool, name) = create_scratch_db(&admin, "writer_isolation").await; - sqlx::query(sqlx::AssertSqlSafe(format!( - "ALTER DATABASE {name} SET default_transaction_isolation = 'repeatable read'" - ))) - .execute(&admin) - .await - .expect("set unsafe database default"); - seed_pool.close().await; - - let base = admin_url().await; - let idx = base.rfind('/').expect("db url has a path segment"); - let scratch_url = format!("{}/{}", &base[..idx], name); - let error = Db::new(&DbConfig { - database_url: scratch_url, - max_connections: 1, - min_connections: 1, - acquire_timeout_secs: 1, - ..DbConfig::default() - }) - .await - .expect_err("writer pool must reject pinned-snapshot database defaults"); - assert!( - error.to_string().contains("requires READ COMMITTED") - || error.to_string().contains("pool timed out"), - "unexpected isolation rejection: {error}" - ); - - sqlx::query(sqlx::AssertSqlSafe(format!( - "DROP DATABASE {name} WITH (FORCE)" - ))) - .execute(&admin) - .await - .expect("drop isolation test database"); - } - - /// The armed writer pool (`Db::new`) must enforce the floor end-to-end - /// through the public insert APIs, and the session GUC must be verifiably - /// set on pooled connections. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn armed_pool_rejects_old_channel_inserts_through_public_api() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (seed_pool, name) = create_scratch_db(&admin, "floor_pool").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&seed_pool, community, channel, &author).await; - - // Connect a Db the production way: after_connect arms the guard. - let base = admin_url().await; - let idx = base.rfind('/').expect("db url has a path segment"); - let scratch_url = format!("{}/{}", &base[..idx], name); - let db = Db::new(&DbConfig { - database_url: scratch_url, - max_connections: 2, - ..DbConfig::default() - }) - .await - .expect("connect armed Db"); - let cid = CommunityId::from_uuid(community); - - // Perci nit: assert the effective session value, not the intent. - let effective: String = sqlx::query_scalar("SHOW buzz.created_at_floor") - .fetch_one(&db.pool) - .await - .expect("SHOW guard GUC"); - assert_eq!( - effective, - crate::replica_fence::CREATED_AT_FLOOR_SECS.to_string(), - "writer pool must arm the floor guard on every connection" - ); - let isolation: String = sqlx::query_scalar("SHOW transaction_isolation") - .fetch_one(&db.pool) - .await - .expect("SHOW writer isolation"); - assert_eq!( - isolation, "read committed", - "the same writer after_connect hook must enforce the isolation premise" - ); - - let now_secs = chrono::Utc::now().timestamp() as u64; - let floor = crate::replica_fence::CREATED_AT_FLOOR_SECS as u64; - - // insert_event (single INSERT, autocommit): old channel row rejected. - let old = signed_event_at(&author, "old-direct", now_secs - floor - 60); - let err = event::insert_event(&db.pool, cid, &old, Some(channel)) - .await - .expect_err("armed pool must reject below-floor channel inserts"); - assert!( - err.to_string().contains("below the replica-fence floor"), - "unexpected error: {err}" - ); - - // insert_event_with_thread_metadata (multi-statement tx): same. - let old2 = signed_event_at(&author, "old-thread-meta", now_secs - floor - 90); - let ts = chrono::DateTime::from_timestamp(old2.created_at.as_secs() as i64, 0) - .expect("valid ts"); - let err = event::insert_event_with_thread_metadata( - &db.pool, - cid, - &old2, - Some(channel), - Some(event::ThreadMetadataParams { - event_id: old2.id.as_bytes(), - event_created_at: ts, - channel_id: channel, - parent_event_id: None, - parent_event_created_at: None, - root_event_id: None, - root_event_created_at: None, - depth: 0, - broadcast: true, - }), - ) - .await - .expect_err("armed pool must reject below-floor thread-metadata inserts"); - assert!( - err.to_string().contains("below the replica-fence floor"), - "unexpected error: {err}" - ); - - // Fresh events pass through both APIs. - let fresh = signed_event_at(&author, "fresh-direct", now_secs); - event::insert_event(&db.pool, cid, &fresh, Some(channel)) - .await - .expect("fresh insert passes the armed guard"); - - drop_scratch_db(&admin, seed_pool, &name).await; - // db pool still holds connections to the dropped DB; close it. - db.pool.close().await; - } - - /// `spawn_fence_probe` must verify the floor guard before letting the - /// probe run — catalog shape AND observed behavior — and refuse on - /// sabotage. This is the production gate for a relay running with - /// `BUZZ_AUTO_MIGRATE` off: an armed GUC with no enforcing trigger must - /// never yield an open fence. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn fence_probe_refuses_to_start_without_verified_floor_guard() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (seed_pool, wname) = create_scratch_db(&admin, "fence_gate_w").await; - let (replica_pool, rname) = create_scratch_db(&admin, "fence_gate_r").await; - seed_pool.close().await; - replica_pool.close().await; - - let base = admin_url().await; - let idx = base.rfind('/').expect("db url has a path segment"); - let writer_url = format!("{}/{}", &base[..idx], wname); - let replica_url = format!("{}/{}", &base[..idx], rname); - - // Healthy schema: verification passes, probe starts. A SEPARATE Db - // instance, because its background probe legitimately opens its own - // fence (the heartbeat probe is writer-side only) — the refusal - // assertions below must run against a fence whose spawns were all - // refused. - let db_healthy = Db::new(&DbConfig { - database_url: writer_url.clone(), - read_database_url: Some(replica_url.clone()), - max_connections: 2, - ..DbConfig::default() - }) - .await - .expect("connect armed Db with replica"); - assert!( - db_healthy - .spawn_fence_probe() - .await - .expect("verification passes"), - "probe must start on a verified schema" - ); - - let db = Db::new(&DbConfig { - database_url: writer_url, - read_database_url: Some(replica_url), - max_connections: 2, - ..DbConfig::default() - }) - .await - .expect("connect armed Db with replica"); - - // Sabotage A: catalog-shaped no-op — same trigger, gutted function - // body. Catalog check alone would pass; behavior check must refuse. - sqlx::query( - "CREATE OR REPLACE FUNCTION events_created_at_floor_guard() RETURNS trigger \ - LANGUAGE plpgsql AS $$ BEGIN RETURN NULL; END $$", - ) - .execute(&db.pool) - .await - .expect("gut the guard function"); - let err = db - .spawn_fence_probe() - .await - .expect_err("inert guard body must refuse the probe"); - assert!( - err.to_string().contains("floor guard is inert"), - "unexpected error: {err}" - ); - - // Sabotage B: trigger dropped entirely (the BUZZ_AUTO_MIGRATE=off / - // 0021-unapplied shape). Catalog check must refuse. - sqlx::query("DROP TRIGGER events_created_at_floor ON events") - .execute(&db.pool) - .await - .expect("drop the guard trigger"); - let err = db - .spawn_fence_probe() - .await - .expect_err("missing trigger must refuse the probe"); - assert!( - err.to_string().contains("missing or mis-shaped"), - "unexpected error: {err}" - ); - - // In both refusal states the fence never opened. - assert!( - db.fence().verified_through().is_none(), - "fence must remain closed when verification refuses the probe" - ); - - db_healthy.pool.close().await; - if let Some(rp) = &db_healthy.read_pool { - rp.close().await; - } - db.pool.close().await; - if let Some(rp) = &db.read_pool { - rp.close().await; - } - let _ = sqlx::query(sqlx::AssertSqlSafe(format!( - "DROP DATABASE IF EXISTS {wname} WITH (FORCE)" - ))) - .execute(&admin) - .await; - let _ = sqlx::query(sqlx::AssertSqlSafe(format!( - "DROP DATABASE IF EXISTS {rname} WITH (FORCE)" - ))) - .execute(&admin) - .await; - } - - /// The `UPDATE OF` arm of the floor guard (Perci's second structural - /// hole): an old row legitimately admitted with `channel_id` NULL must - /// not be movable into keyset windows, and a channel row's `created_at` - /// must not be movable below the fence — through raw SQL, at COMMIT. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn floor_guard_blocks_updates_that_move_rows_below_the_fence() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (pool, name) = create_scratch_db(&admin, "floor_upd").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&pool, community, channel, &author).await; - - let now_secs = chrono::Utc::now().timestamp() as u64; - let floor = crate::replica_fence::CREATED_AT_FLOOR_SECS as u64; - - // Seed via unarmed session: one old channel-NULL row, one fresh - // channel row. - let old_null = signed_event_at(&author, "old-null", now_secs - floor - 120); - insert_top_level(&pool, community, channel, &old_null).await; - sqlx::query("UPDATE events SET channel_id = NULL WHERE community_id = $1 AND id = $2") - .bind(community) - .bind(old_null.id.as_bytes().as_slice()) - .execute(&pool) - .await - .expect("detach channel (unarmed seed)"); - let fresh = signed_event_at(&author, "fresh-row", now_secs); - insert_top_level(&pool, community, channel, &fresh).await; - - // Armed transaction, deferred to COMMIT (the production shape). - let run_armed_update = |sql: &'static str, id: Vec, age: Option| { - let pool = pool.clone(); - async move { - let mut tx = pool.begin().await.expect("begin"); - sqlx::query("SELECT set_config('buzz.created_at_floor', $1, true)") - .bind(crate::replica_fence::CREATED_AT_FLOOR_SECS.to_string()) - .execute(&mut *tx) - .await - .expect("arm guard"); - let q = sqlx::query(sql).bind(community).bind(id); - let q = match age { - Some(a) => q.bind(a as f64), - None => q, - }; - q.execute(&mut *tx) - .await - .expect("update inside tx (deferred)"); - tx.commit().await - } - }; - - // channel-NULL → channel-bearing on an old row: COMMIT must abort. - let err = run_armed_update( - "UPDATE events SET channel_id = community_id WHERE community_id = $1 AND id = $2", - old_null.id.as_bytes().to_vec(), - None, - ) - .await - .expect_err("moving an old channel-NULL row into a channel must abort at COMMIT"); - assert!( - matches!(&err, sqlx::Error::Database(e) if e.code().as_deref() == Some("23514")), - "unexpected error: {err}" - ); - - // created_at rewrite below the floor on a channel row: COMMIT must abort. - let err = run_armed_update( - "UPDATE events SET created_at = clock_timestamp() - make_interval(secs => $3::double precision) \ - WHERE community_id = $1 AND id = $2", - fresh.id.as_bytes().to_vec(), - Some(floor + 120), - ) - .await - .expect_err("rewriting created_at below the floor must abort at COMMIT"); - assert!( - matches!(&err, sqlx::Error::Database(e) if e.code().as_deref() == Some("23514")), - "unexpected error: {err}" - ); +pub use event::{EventQuery, DEFAULT_MAX_PAGE_LIMIT}; +pub use reaction::ReactionEventInsertOutcome; +pub use reminder::DueReminder; +pub use usage::UsageMetricsLeader; - drop_scratch_db(&admin, pool, &name).await; - } -} +use buzz_core::CommunityId; diff --git a/crates/buzz-db/src/reaction.rs b/crates/buzz-db/src/reaction.rs deleted file mode 100644 index 9e285051dc..0000000000 --- a/crates/buzz-db/src/reaction.rs +++ /dev/null @@ -1,418 +0,0 @@ -//! Reaction persistence. -//! -//! One reaction per user per emoji per event. Soft-delete via removed_at. - -use chrono::{DateTime, Utc}; -use sqlx::{PgPool, Postgres, Row, Transaction}; - -use crate::error::Result; -use crate::CommunityId; - -// -- Public structs ----------------------------------------------------------- - -/// A grouped set of reactions for a single emoji on an event. -#[derive(Debug, Clone)] -pub struct ReactionGroup { - /// The emoji character or shortcode used in this reaction group. - pub emoji: String, - /// Total number of active reactions with this emoji. - pub count: i64, - /// Individual users who reacted with this emoji. - pub users: Vec, -} - -/// A single user who reacted with a given emoji. -#[derive(Debug, Clone)] -pub struct ReactionUser { - /// Compressed 33-byte public key of the reacting user. - pub pubkey: Vec, - /// Optional display name resolved from the users table. - pub display_name: Option, - /// Nostr event ID of the kind:7 reaction event (raw bytes), if present. - /// Clients use this to build signed kind:5 deletion events for reaction removal. - pub reaction_event_id: Option>, -} - -/// Bulk reaction entry for embedding in message lists. -#[derive(Debug, Clone)] -pub struct BulkReactionEntry { - /// The event this reaction entry belongs to. - pub event_id: Vec, - /// Partition key timestamp for the event. - pub event_created_at: DateTime, - /// Emoji + count summaries for this event. - pub reactions: Vec, -} - -/// Emoji + count summary (no user list) for bulk fetches. -#[derive(Debug, Clone)] -pub struct ReactionSummary { - /// The emoji character or shortcode. - pub emoji: String, - /// Number of active reactions with this emoji. - pub count: i64, -} - -/// Active reaction row metadata for a specific actor + emoji + target tuple. -#[derive(Debug, Clone)] -pub struct ActiveReactionRecord { - /// Nostr event ID of the reaction event, if this row came from a real kind:7 event. - pub reaction_event_id: Option>, -} - -// -- Write operations --------------------------------------------------------- - -const ADD_REACTION_SQL: &str = r#" - INSERT INTO reactions (community_id, event_created_at, event_id, pubkey, emoji, reaction_event_id) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (community_id, event_created_at, event_id, pubkey, emoji) DO UPDATE SET - created_at = NOW(), - removed_at = NULL, - reaction_event_id = COALESCE(EXCLUDED.reaction_event_id, reactions.reaction_event_id) - WHERE reactions.removed_at IS NOT NULL - "#; - -/// Add (or re-activate) a reaction. -/// -/// Returns `Ok(true)` if the reaction was added or re-activated, `Ok(false)` if -/// the reaction is already active (duplicate, no change made). -/// -/// Uses `INSERT ... ON CONFLICT DO UPDATE` to eliminate the TOCTOU race where -/// two concurrent adds both see no existing row and then race to INSERT. -pub async fn add_reaction( - pool: &PgPool, - community: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - pubkey: &[u8], - emoji: &str, - reaction_event_id: Option<&[u8]>, -) -> Result { - let result = sqlx::query(ADD_REACTION_SQL) - .bind(community.as_uuid()) - .bind(event_created_at) - .bind(event_id) - .bind(pubkey) - .bind(emoji) - .bind(reaction_event_id) - .execute(pool) - .await?; - - // Three cases: - // (a) New reaction (no existing row): INSERT succeeds → rows_affected = 1 → true. - // (b) Reactivating (row exists, removed_at IS NOT NULL): WHERE matches → UPDATE fires - // → rows_affected = 1 → true. - // (c) Active duplicate (row exists, removed_at IS NULL): WHERE fails → no UPDATE - // → rows_affected = 0 → false. Caller should short-circuit and not store the event. - Ok(result.rows_affected() != 0) -} - -/// Add (or re-activate) a reaction inside an existing transaction. -/// -/// Uses the same `INSERT ... ON CONFLICT DO UPDATE ... WHERE removed_at IS NOT NULL` -/// statement as [`add_reaction`], preserving the new / re-activate / active-duplicate -/// semantics while letting callers atomically couple the reaction row to other writes. -pub(crate) async fn add_reaction_tx( - tx: &mut Transaction<'_, Postgres>, - community: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - pubkey: &[u8], - emoji: &str, - reaction_event_id: Option<&[u8]>, -) -> Result { - let result = sqlx::query(ADD_REACTION_SQL) - .bind(community.as_uuid()) - .bind(event_created_at) - .bind(event_id) - .bind(pubkey) - .bind(emoji) - .bind(reaction_event_id) - .execute(&mut **tx) - .await?; - - Ok(result.rows_affected() != 0) -} - -/// Soft-delete a reaction by setting `removed_at`. -/// -/// Returns `true` if a row was updated, `false` if not found or already removed. -pub async fn remove_reaction( - pool: &PgPool, - community: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - pubkey: &[u8], - emoji: &str, -) -> Result { - let result = sqlx::query( - r#" - UPDATE reactions - SET removed_at = NOW() - WHERE community_id = $1 - AND event_created_at = $2 - AND event_id = $3 - AND pubkey = $4 - AND emoji = $5 - AND removed_at IS NULL - "#, - ) - .bind(community.as_uuid()) - .bind(event_created_at) - .bind(event_id) - .bind(pubkey) - .bind(emoji) - .execute(pool) - .await?; - - Ok(result.rows_affected() > 0) -} - -/// Soft-delete a reaction by the reaction event's own ID. -/// -/// Returns `true` if a row was updated, `false` if not found or already removed. -pub async fn remove_reaction_by_source_event_id( - pool: &PgPool, - community: CommunityId, - reaction_event_id: &[u8], -) -> Result { - let result = sqlx::query( - r#" - UPDATE reactions - SET removed_at = NOW() - WHERE community_id = $1 - AND reaction_event_id = $2 - AND removed_at IS NULL - "#, - ) - .bind(community.as_uuid()) - .bind(reaction_event_id) - .execute(pool) - .await?; - - Ok(result.rows_affected() > 0) -} - -/// Look up the active reaction row for one actor + emoji + target tuple. -pub async fn get_active_reaction_record( - pool: &PgPool, - community: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - pubkey: &[u8], - emoji: &str, -) -> Result> { - let row = sqlx::query( - r#" - SELECT reaction_event_id - FROM reactions - WHERE community_id = $1 - AND event_id = $2 - AND event_created_at = $3 - AND pubkey = $4 - AND emoji = $5 - AND removed_at IS NULL - LIMIT 1 - "#, - ) - .bind(community.as_uuid()) - .bind(event_id) - .bind(event_created_at) - .bind(pubkey) - .bind(emoji) - .fetch_optional(pool) - .await?; - - row.map(|row| -> Result { - Ok(ActiveReactionRecord { - reaction_event_id: row.try_get("reaction_event_id")?, - }) - }) - .transpose() -} - -/// Backfill the source event ID on an active reaction row. -/// -/// Called after the kind:7 event is created and stored, to link the -/// reaction row to its source event. Returns `true` if the row was updated. -pub async fn set_reaction_event_id( - pool: &PgPool, - community: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - pubkey: &[u8], - emoji: &str, - reaction_event_id: &[u8], -) -> Result { - let result = sqlx::query( - r#" - UPDATE reactions - SET reaction_event_id = $1 - WHERE community_id = $2 - AND event_created_at = $3 - AND event_id = $4 - AND pubkey = $5 - AND emoji = $6 - AND removed_at IS NULL - "#, - ) - .bind(reaction_event_id) - .bind(community.as_uuid()) - .bind(event_created_at) - .bind(event_id) - .bind(pubkey) - .bind(emoji) - .execute(pool) - .await?; - - Ok(result.rows_affected() > 0) -} - -// -- Read operations ---------------------------------------------------------- - -/// Get all active reactions for an event, grouped by emoji. -/// -/// Returns one [`ReactionGroup`] per emoji, each containing the list of reacting -/// user pubkeys. Display names are NOT resolved here -- callers should enrich via -/// scoped user lookups if needed. -/// -/// `cursor` is reserved for future keyset pagination (currently unused). -pub async fn get_reactions( - pool: &PgPool, - community: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - limit: u32, - _cursor: Option<&str>, -) -> Result> { - // Two-step query: first get the limited set of distinct emoji groups, - // then fetch all rows for those groups. This ensures `limit` applies to - // emoji groups (the API contract), not raw rows — so one busy emoji - // cannot consume the entire page and hide other groups. - let rows = sqlx::query( - r#" - SELECT r.emoji, r.pubkey, r.reaction_event_id - FROM reactions r - INNER JOIN ( - SELECT DISTINCT emoji - FROM reactions - WHERE community_id = $1 - AND event_id = $2 - AND event_created_at = $3 - AND removed_at IS NULL - ORDER BY emoji - LIMIT $4 - ) g ON g.emoji = r.emoji - WHERE r.community_id = $1 - AND r.event_id = $2 - AND r.event_created_at = $3 - AND r.removed_at IS NULL - ORDER BY r.emoji, r.created_at - "#, - ) - .bind(community.as_uuid()) - .bind(event_id) - .bind(event_created_at) - .bind(limit as i64) - .fetch_all(pool) - .await?; - - // Group individual rows by emoji in Rust. - let mut groups: Vec = Vec::new(); - let mut current_emoji: Option = None; - let mut current_users: Vec = Vec::new(); - - for row in &rows { - let emoji: String = row.try_get("emoji")?; - let pubkey: Vec = row.try_get("pubkey")?; - let reaction_event_id: Option> = row.try_get("reaction_event_id")?; - - if current_emoji.as_ref() != Some(&emoji) { - if let Some(prev_emoji) = current_emoji.take() { - let count = current_users.len() as i64; - groups.push(ReactionGroup { - emoji: prev_emoji, - count, - users: std::mem::take(&mut current_users), - }); - } - current_emoji = Some(emoji); - } - - current_users.push(ReactionUser { - pubkey, - display_name: None, - reaction_event_id, - }); - } - - // Flush the final group. - if let Some(emoji) = current_emoji { - let count = current_users.len() as i64; - groups.push(ReactionGroup { - emoji, - count, - users: current_users, - }); - } - - Ok(groups) -} - -/// Batch-fetch emoji counts for a set of (event_id, event_created_at) pairs. -/// -/// Returns one [`BulkReactionEntry`] per input pair that has at least one -/// active reaction. Pairs with no reactions are omitted. -pub async fn get_reactions_bulk( - pool: &PgPool, - community: CommunityId, - event_ids: &[(&[u8], DateTime)], -) -> Result> { - if event_ids.is_empty() { - return Ok(Vec::new()); - } - - // Run one query per event. For typical message-list sizes (<=100 events) - // this is acceptable; a single-query approach with dynamic IN clauses over - // composite keys can be added later if needed. - let mut entries = Vec::new(); - - for (event_id, event_created_at) in event_ids { - let rows = sqlx::query( - r#" - SELECT emoji, COUNT(*) AS count - FROM reactions - WHERE community_id = $1 - AND event_id = $2 - AND event_created_at = $3 - AND removed_at IS NULL - GROUP BY emoji - ORDER BY emoji - "#, - ) - .bind(community.as_uuid()) - .bind(*event_id) - .bind(event_created_at) - .fetch_all(pool) - .await?; - - if rows.is_empty() { - continue; - } - - let mut reactions = Vec::with_capacity(rows.len()); - for row in rows { - let emoji: String = row.try_get("emoji")?; - let count: i64 = row.try_get("count")?; - reactions.push(ReactionSummary { emoji, count }); - } - - entries.push(BulkReactionEntry { - event_id: event_id.to_vec(), - event_created_at: *event_created_at, - reactions, - }); - } - - Ok(entries) -} diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/runtime/migration.rs similarity index 99% rename from crates/buzz-db/src/migration.rs rename to crates/buzz-db/src/runtime/migration.rs index 464201adf9..f258fa6441 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -897,7 +897,7 @@ mod tests { assert!(migrations[32].sql.as_str().contains("kind = 30179")); assert!(migrations[32].sql.as_str().contains("search_tsv")); assert!(!migrations[0].sql.as_str().contains("30179")); - assert!(include_str!("../../../schema/schema.sql") + assert!(include_str!("../../../../schema/schema.sql") .contains("kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200)")); // Public push-gateway authority is intentionally deployment-global and @@ -1040,7 +1040,7 @@ mod tests { .contains("CREATE INDEX relay_invites_expires_at_idx ON relay_invites (expires_at)")); assert!(!relay_invites.contains("_operator_global_tables")); - let desired_schema = include_str!("../../../schema/schema.sql"); + let desired_schema = include_str!("../../../../schema/schema.sql"); assert!( desired_schema.contains("CREATE TABLE join_policy_acceptances"), "desired-state schema must include join-policy evidence used by invite claims", @@ -1156,7 +1156,7 @@ mod tests { // parameters. Its post-apply reconciliation must restore and verify // both parts of the live heartbeat contract for fresh bootstraps. let pgschema_reconciliation = - include_str!("../../../scripts/reconcile-schema-after-pgschema.sql"); + include_str!("../../../../scripts/reconcile-schema-after-pgschema.sql"); assert!(pgschema_reconciliation .contains("ALTER TABLE replica_heartbeat SET (vacuum_truncate = false)")); assert!(pgschema_reconciliation.contains("INSERT INTO replica_heartbeat (id) VALUES (1)")); @@ -1309,7 +1309,7 @@ mod tests { .sql .as_str() .contains("error_code")); - assert!(include_str!("../../../schema/schema.sql").contains("error_code TEXT")); + assert!(include_str!("../../../../schema/schema.sql").contains("error_code TEXT")); } #[test] @@ -1480,7 +1480,7 @@ mod tests { let migrator_run_to = ["MIGRATOR", ".run_to("].concat(); let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); - let this_file = manifest_dir.join("src/migration.rs"); + let this_file = manifest_dir.join("src/runtime/migration.rs"); let crates_dir = manifest_dir.parent().expect("workspace crates dir"); // The push gateway migrates its own dedicated authority database; it // never holds relay tenant tables, so it is exempt from the relay diff --git a/crates/buzz-db/src/runtime/mod.rs b/crates/buzz-db/src/runtime/mod.rs new file mode 100644 index 0000000000..29eef88402 --- /dev/null +++ b/crates/buzz-db/src/runtime/mod.rs @@ -0,0 +1,1044 @@ +pub mod migration; +pub(crate) mod observability; +pub mod replica_fence; + +use crate::{deletion, event, DbError, EventQuery, Result}; +use buzz_datastore_tracing::datastore_span; +use chrono::{DateTime, Utc}; +use sqlx::postgres::PgPoolOptions; +use sqlx::{PgPool, QueryBuilder}; +use std::time::Duration; +use uuid::Uuid; + +use buzz_core::{CommunityId, StoredEvent}; + +/// Extract p-tag mentions from an event and insert into the `event_mentions` table. +/// +/// This pool-owning wrapper propagates failures to its caller. Replacement writes +/// use the transaction-bound helper below so event storage and mention indexing +/// commit or roll back together. Duplicate inserts are silently skipped with +/// `INSERT ... ON CONFLICT DO NOTHING`. +pub async fn insert_mentions( + pool: &PgPool, + community_id: CommunityId, + event: &nostr::Event, + channel_id: Option, +) -> Result<()> { + let mut tx = pool.begin().await?; + insert_mentions_in_transaction(&mut tx, community_id, event, channel_id).await?; + tx.commit().await?; + Ok(()) +} + +/// Insert mention rows on the caller's transaction. Replacement writes use +/// this so the authoritative event and its discovery index commit or roll back +/// as one unit. +pub(crate) async fn insert_mentions_in_transaction( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community_id: CommunityId, + event: &nostr::Event, + channel_id: Option, +) -> Result<()> { + let p_tags: Vec<&str> = event + .tags + .iter() + .filter_map(|tag| { + let tag_vec = tag.as_slice(); + if tag_vec.len() >= 2 && tag_vec[0] == "p" { + Some(tag_vec[1].as_str()) + } else { + None + } + }) + .collect(); + + if p_tags.is_empty() { + return Ok(()); + } + + let event_id_bytes = event.id.as_bytes(); + let created_at_secs = event.created_at.as_secs() as i64; + let created_at = DateTime::from_timestamp(created_at_secs, 0) + .ok_or(crate::error::DbError::InvalidTimestamp(created_at_secs))?; + let kind = event.kind.as_u16() as u32; + + // Validate and normalize pubkeys, logging any malformed ones. + let valid_pubkeys: Vec = p_tags + .into_iter() + .filter(|pk| { + if pk.len() != 64 || !pk.chars().all(|c| c.is_ascii_hexdigit()) { + tracing::debug!( + event_id = %event.id, + invalid_ptag = pk, + "skipping malformed p-tag in insert_mentions" + ); + false + } else { + true + } + }) + .map(|pk| pk.to_ascii_lowercase()) + .collect(); + + if valid_pubkeys.is_empty() { + return Ok(()); + } + + // Multi-row INSERT ... ON CONFLICT DO NOTHING, chunked to stay under + // Postgres's 65,535 bind-parameter statement cap (6 binds per row caps a + // single statement at ~10.9k rows). Relay-signed kind 39002 rosters carry + // one p-tag per channel member and can exceed that. The caller owns the + // transaction so all chunks share its commit boundary. + const MENTION_INSERT_CHUNK_ROWS: usize = 5_000; + for chunk in valid_pubkeys.chunks(MENTION_INSERT_CHUNK_ROWS) { + let mut qb: QueryBuilder = QueryBuilder::new( + "INSERT INTO event_mentions \ + (community_id, pubkey_hex, event_id, event_created_at, channel_id, event_kind) ", + ); + + qb.push_values(chunk, |mut b, pubkey| { + b.push_bind(community_id.as_uuid()) + .push_bind(pubkey.as_str()) + .push_bind(event_id_bytes.as_slice()) + .push_bind(created_at) + .push_bind(channel_id) + .push_bind(kind as i32); + }); + + qb.push(" ON CONFLICT DO NOTHING"); + + qb.build().execute(&mut **tx).await?; + } + Ok(()) +} + +/// Database handle. Clone is cheap (Arc-backed pool). +#[derive(Clone, Debug)] +pub struct Db { + pub(crate) pool: PgPool, + /// Maximum connections configured for this pool (from [`DbConfig::max_connections`]). + pub(crate) max_connections: u32, + /// Optional read-replica pool (from [`DbConfig::read_database_url`]). + /// + /// `None` means no replica is configured and every read routes to the + /// writer pool — the pre-replica behavior. Only lag-tolerant reads may + /// route here (see [`Db::read`]); locks, transactions, and anything + /// consistency-critical stays on `pool`. + pub(crate) read_pool: Option, + /// Maximum connections configured for the read-replica pool (from + /// [`DbConfig::read_max_connections`], defaulting to the writer's + /// sizing). Kept separately from `max_connections` so + /// [`Db::read_pool_stats`] reports the reader's own ceiling — a + /// utilisation gauge derived from the writer's max would understate + /// reader saturation by exactly the ratio of the two pool sizes. + pub(crate) read_max_connections: u32, + /// Freshness fence gating cursor-page routing to the replica. + /// + /// Starts closed; a background probe ([`replica_fence::run_probe`]) + /// commits heartbeat tokens and retains proof entries. Routing proves + /// coverage per request on the serving reader session; when the ring is + /// empty or stale, every routed read stays on the writer. + pub(crate) fence: std::sync::Arc, + /// Bounded-staleness routing budget `B`: a read routed under + /// [`RoutePredicate::Bounded`] may be served from a proved replica + /// session only when the proved heartbeat entry is at most this old. + /// `None` disables the bounded arm entirely (the rollout default) — + /// bounded-stale read semantics are a product decision, not an + /// invariant, so the gate ships off. + pub(crate) replica_read_max_age: Option, + /// Whether the reader endpoint supports the Aurora PostgreSQL identity + /// function ([`replica_fence::AURORA_IDENTITY_FN`]) — probed + /// once per process on the first routed read (on a plain autocommit + /// checkout, outside any request transaction) and cached. Unset means + /// not yet probed (or the probe hit a transient error and will retry). + /// Shared across `Db` clones. + pub(crate) reader_aurora_identity: std::sync::Arc>, +} + +/// The session that served (or will serve) a routed read, so follow-up +/// queries in the same request (the channel-window aux closure) run on the +/// **same proved snapshot** — a different pooled reader session may sit at a +/// different replay position, and even the same connection advances its +/// snapshot between autocommit statements. +/// +/// `Replica` holds the request's `REPEATABLE READ, READ ONLY` transaction: +/// the heartbeat observation was its first statement, so the snapshot the +/// proof was taken against is exactly the snapshot every follow-up sees. +/// Dropping the session rolls the read-only transaction back and returns +/// the connection to the pool. +/// +/// `Writer` carries the writer pool: follow-ups there are authoritative by +/// construction and need no session pinning. +pub struct ReadSession { + pub(crate) inner: ReadSessionInner, +} + +pub(crate) enum ReadSessionInner { + /// The proved replica request transaction (snapshot-anchored), plus the + /// writer pool so a mid-request replica failure (e.g. a hot-standby + /// recovery conflict cancelling the held snapshot) degrades the session + /// to the writer instead of surfacing an error: degraded capacity, + /// never holes — and never a 500 the writer could have served. + Replica { + tx: sqlx::Transaction<'static, sqlx::Postgres>, + writer: PgPool, + }, + /// The writer pool (cheap clone; Arc-backed). + Writer(PgPool), +} + +impl ReadSession { + /// Query events on this session (see [`Db::query_events`]). + /// + /// If the proved replica transaction fails mid-request, the session + /// permanently degrades to the writer and the query is re-run there. + /// The writer is always at or ahead of any replica replay position, so + /// the degraded follow-up can only observe *more* than the proof-time + /// snapshot, never less — fresher aux rows, the same failure semantics + /// as a request that routed to the writer to begin with. + #[datastore_span(name = "read_session_query_events", system = "postgresql")] + pub async fn query_events(&mut self, q: &EventQuery) -> Result> { + let degraded = match &mut self.inner { + ReadSessionInner::Replica { tx, writer } => { + match event::query_events_on(tx, q).await { + Ok(rows) => return Ok(rows), + Err(e) => { + tracing::warn!( + error = %e, + "replica session query failed mid-request; degrading to writer" + ); + // Deliberately not a `buzz_db_route_decision` event: + // the page's route was already recorded, and the + // offload metric must stay one-event-per-request. + metrics::counter!("buzz_db_read_session_degraded").increment(1); + writer.clone() + } + } + } + ReadSessionInner::Writer(pool) => return event::query_events(pool, q).await, + }; + // Replacing the inner drops the replica transaction (rolling it + // back and returning the reader connection to its pool). + self.inner = ReadSessionInner::Writer(degraded.clone()); + event::query_events(°raded, q).await + } + + /// Whether this session is a proved replica connection (observability). + pub fn is_replica(&self) -> bool { + matches!(self.inner, ReadSessionInner::Replica { .. }) + } +} + +/// Where one routed read is served (see [`Db::route_read`]). +pub(crate) enum RouteDecision { + /// A reader request transaction whose first-statement heartbeat + /// observation proved this fence entry — the page runs inside it. The + /// `&'static str` is the metric reason (`covered`/`fresh`); the caller + /// records the route only once the page is actually served from the + /// replica, so a post-verification writer re-run or a mid-query replica + /// failure emits exactly one `buzz_db_route_decision` event per request + /// (the offload percentage is read straight off `decision="replica"`). + Replica( + sqlx::Transaction<'static, sqlx::Postgres>, + replica_fence::TokenEntry, + &'static str, + ), + /// Fail closed: serve from the writer pool (already recorded). + Writer, +} + +/// The ONLY place [`route_proof::ChannelScoped`] can be constructed. A +/// crate-root tuple struct would be mintable via `ChannelScoped(())` from +/// every descendant module — tuple-struct field privacy is module-scoped — +/// so the token lives in its own module and E0423 enforces the invariant. +pub(crate) mod route_proof { + use uuid::Uuid; + + /// Proof that a query/page can only return rows with + /// `channel_id IS NOT NULL` — the domain of the commit-time floor guard + /// (migration 0021). `channel_ids` (retains channel-NULL rows) and + /// `global_only = false` are explicitly NOT proofs. + /// + /// Each constructor keys off *how* its path proves channel-bearing-ness: + /// a pinned query filter, a bare `Uuid` argument, or a `NOT NULL` column + /// reached through an inner join. Do not add a universal constructor + /// callers reshape their inputs to fit, and never fabricate a throwaway + /// `EventQuery` purely to mint a token — the proof must be the SQL's + /// shape, not "someone assembled a struct". + #[derive(Clone, Copy)] + pub(crate) struct ChannelScoped(()); + + impl ChannelScoped { + /// Constructor 1: the query pins a single channel + /// (`EventQuery.channel_id = Some(_)`, compiled to a + /// `channel_id = $n` predicate). This proof covers BOTH query + /// builders — the SELECT builder (`event::query_events_on`) and the + /// COUNT builder (`event::count_events`) pin identically; if the + /// two ever drift, this comment is a lie and the routed COUNT seam + /// is unsound. + /// Sound under conjunction: any additional clause (e.g. + /// `channel_ids`, which alone retains channel-NULL rows) is ANDed, + /// and `channel_id = ` never matches NULL — the pin strictly + /// narrows and cannot be widened back out to global rows. + pub(crate) fn from_pinned_channel(q: &crate::event::EventQuery) -> Option { + q.channel_id.map(|_| ChannelScoped(())) + } + + /// Constructor 2 (thread pages): the page is an inner JOIN from + /// `thread_metadata` to `events`, and `thread_metadata.channel_id` + /// is `UUID NOT NULL` — every writer that creates a row passes a + /// concrete channel (`ThreadMetadataParams.channel_id: Uuid`, + /// non-Option). Channel-bearing by construction of the join, not by + /// query predicate. + pub(crate) fn from_thread_metadata_join() -> Self { + ChannelScoped(()) + } + + /// Constructor 3 (channel windows): the channel arrives as a bare + /// `Uuid` argument and the SQL binds it unconditionally + /// (`e.channel_id = $2` in `get_channel_window_on`); every served + /// row is channel-bearing. No `EventQuery` exists on this path. + pub(crate) fn from_channel_id(_channel_id: Uuid) -> Self { + ChannelScoped(()) + } + } +} +use route_proof::ChannelScoped; + +/// The predicate one routed read must satisfy (see [`Db::route_read`]). +/// +/// Discipline: no `Default`, no `Deserialize`, stays non-`pub` — any of +/// those re-opens the [`ChannelScoped`] mint. +pub(crate) enum RoutePredicate { + /// Bounded staleness: the proved entry must be within the configured + /// read budget `B` (default off). Bounds TIME — the page misses at most + /// the freshest `B` of writes. Sound for ANY query shape, including + /// global (channel-NULL) rows: it relies only on heartbeat commit order, + /// not the floor guard. + Bounded, + /// Completeness: the proved wall must cover the page's upper bound. + /// Bounds CONTENT — every row at/below `upper` is present, meaningful + /// even when the cursor is hours old, where `B`-freshness says nothing. + /// Sound ONLY on the floor guard's domain (channel-bearing rows), hence + /// the proof token. `upper` is non-optional: the no-upper-bound + /// post-verifying case is [`RoutePredicate::CoveredPostVerified`]. + /// + /// Bounds INSERT-completeness only — "no missing rows", not "no extra + /// rows". Soft deletes are `UPDATE .. SET deleted_at` commits outside + /// the floor guard and never touch `created_at`, so a covered page can + /// briefly serve a row the writer already excludes; deletion visibility + /// is bounded by replication lag under `FENCE_STALENESS` (30s), not by + /// `upper` or `B`. Do not extend the covered arm to a surface that + /// cannot absorb extra rows (this is why the routed COUNT seam is + /// bounded-only). + Covered { + upper: DateTime, + /// Never read — the field exists so constructing this variant + /// requires minting the token through `route_proof`. + #[allow(dead_code)] + proof: ChannelScoped, + }, + /// Forward-walking thread pages: no upper bound is derivable from the + /// cursor; the caller post-verifies the served rows against the proved + /// wall (full page + tail at/below the wall, else re-run on the writer). + /// Only the thread path constructs this — a general routed caller does + /// no post-verification and must never self-certify. + CoveredPostVerified { + #[allow(dead_code)] + proof: ChannelScoped, + }, + /// Either arm admits, covered tried first (it has no budget dependence). + /// For general routed reads that are channel-pinned AND carry an + /// `until` upper bound. + BoundedOrCovered { + upper: DateTime, + /// Never read — see [`RoutePredicate::Covered::proof`]. + #[allow(dead_code)] + proof: ChannelScoped, + }, +} + +impl RoutePredicate { + /// A channel-window request: cursor pages are covered-only — for deep + /// keyset pages only coverage answers "have all rows below the cursor + /// replayed?" — and a head fetch is bounded. The channel id is the + /// bare-`Uuid` proof that the window SQL pins a channel. + pub(crate) fn from_channel_cursor( + channel_id: Uuid, + cursor: &Option<(DateTime, Vec)>, + ) -> Self { + match cursor { + Some((ts, _)) => RoutePredicate::Covered { + upper: *ts, + proof: ChannelScoped::from_channel_id(channel_id), + }, + None => RoutePredicate::Bounded, + } + } + + /// General entry point for the routed query seams: derives the strongest + /// sound predicate from the query shape. Never produces a covered arm + /// without both a channel-scope proof AND a real upper bound. + /// + /// `routing_enabled` is whether `BUZZ_REPLICA_READ_MAX_AGE_MS` is set + /// (non-zero). When it is NOT, this returns `Bounded` — which the zero + /// budget then fails closed — so the new seams are genuinely dark at + /// the deploy default even for channel-pinned queries carrying `until`. + /// Without this gate, `BoundedOrCovered` would take the covered arm + /// (which has no budget dependence) and route on day one with no env + /// var set and no kill switch short of removing the replica URL + /// (Dawn's covered-at-zero-budget catch). The pre-existing cursor + /// paths (`Covered`/`CoveredPostVerified` from channel windows and + /// thread pages) intentionally still route at B=0 — status quo, + /// unchanged. + pub(crate) fn for_query(q: &event::EventQuery, routing_enabled: bool) -> Self { + if !routing_enabled { + return RoutePredicate::Bounded; + } + match (ChannelScoped::from_pinned_channel(q), q.until) { + (Some(proof), Some(upper)) => RoutePredicate::BoundedOrCovered { upper, proof }, + _ => RoutePredicate::Bounded, + } + } +} + +/// Map the configured read budget (`BUZZ_REPLICA_READ_MAX_AGE_MS`) to the +/// runtime gate: `0` disables bounded-staleness routing; anything above the +/// fence staleness gate is clamped to it (an entry older than the staleness +/// gate never routes anyway, so a larger budget would only misrepresent the +/// config). +fn read_budget_from_ms(ms: u64) -> Option { + match ms { + 0 => None, + ms => Some(Duration::from_millis(ms).min(replica_fence::FENCE_STALENESS)), + } +} + +/// Snapshot of Postgres connection pool utilisation. +#[derive(Debug, Clone, Copy)] +pub struct DbPoolStats { + /// Total connections currently in the pool (idle + active). + pub size: u32, + /// Connections available for immediate reuse. + pub idle: u32, + /// Pool ceiling — the `max_connections` value set at construction. + pub max: u32, +} + +/// Configuration for the Postgres connection pool. +#[derive(Debug, Clone)] +pub struct DbConfig { + /// Postgres connection URL (usually sourced from `DATABASE_URL`). + pub database_url: String, + /// Optional read-replica connection URL (usually sourced from + /// `READ_DATABASE_URL`, e.g. an Aurora `cluster-ro-` endpoint). `None` + /// disables replica routing: [`Db::read`] falls back to the writer pool. + pub read_database_url: Option, + /// Maximum number of connections in the pool. + pub max_connections: u32, + /// Maximum connections in the read-replica pool (env + /// `BUZZ_DB_READ_POOL_SIZE`). `None` inherits [`Self::max_connections`]. + pub read_max_connections: Option, + /// Minimum number of idle connections to maintain. + pub min_connections: u32, + /// Seconds to wait when acquiring a connection before timing out. + pub acquire_timeout_secs: u64, + /// Maximum connection lifetime in seconds before recycling. + pub max_lifetime_secs: u64, + /// Seconds a connection may sit idle before being closed. + pub idle_timeout_secs: u64, + /// Replica read budget `B` in milliseconds (bounded arm, env + /// `BUZZ_REPLICA_READ_MAX_AGE_MS`). `0` disables bounded-staleness + /// routing — the rollout default. Values above + /// [`replica_fence::FENCE_STALENESS`] are clamped to it: an entry older + /// than the staleness gate never routes anyway, so a larger budget + /// would only misrepresent the config. + pub replica_read_max_age_ms: u64, +} + +impl Default for DbConfig { + /// Sized for a single relay pod against PG max_connections=100. + /// Staging measured 51 idle + 1 active out of 50 — most connections sat unused. + /// At 20 main + 5 audit = 25/pod, four relay pods fit within the PG limit. + fn default() -> Self { + Self { + database_url: "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string(), // sadscan:disable np.postgres.1 + read_database_url: None, + max_connections: 20, + read_max_connections: None, + min_connections: 2, + acquire_timeout_secs: 3, + max_lifetime_secs: 1800, + idle_timeout_secs: 600, + replica_read_max_age_ms: 0, + } + } +} + +impl Db { + /// Creates a new `Db` by connecting a Postgres pool with the given config. + /// + /// When `config.read_database_url` is set, a second pool with the same + /// sizing is connected to it for lag-tolerant reads (see [`Db::read`]). + /// + /// The writer pool arms the commit-time `created_at` floor guard + /// (migration 0021) on every connection by setting the + /// `buzz.created_at_floor` GUC — this is what makes the replica fence + /// proof hold for every insert path that goes through this pool. + pub async fn new(config: &DbConfig) -> Result { + let pool = Self::connect_pool(config, &config.database_url).await?; + let read_max_connections = config + .read_max_connections + .unwrap_or(config.max_connections); + let read_pool = match &config.read_database_url { + Some(url) => Some(Self::connect_read_pool(config, url, read_max_connections)?), + None => None, + }; + let replica_read_max_age = read_budget_from_ms(config.replica_read_max_age_ms); + Ok(Self { + pool, + max_connections: config.max_connections, + read_pool, + read_max_connections, + fence: std::sync::Arc::new(replica_fence::ReplicaFence::new()), + replica_read_max_age, + reader_aurora_identity: std::sync::Arc::new(std::sync::OnceLock::new()), + }) + } + + /// Connect the writer pool with all session-level safety premises. + /// + /// SQLx stores one `after_connect` hook, so the floor guard and transaction + /// isolation assertion must remain in this single closure. Registering a + /// second hook replaces the first and silently disarms the floor trigger. + async fn connect_pool(config: &DbConfig, url: &str) -> Result { + let options = PgPoolOptions::new() + .max_connections(config.max_connections) + .min_connections(config.min_connections) + .acquire_timeout(Duration::from_secs(config.acquire_timeout_secs)) + .max_lifetime(Duration::from_secs(config.max_lifetime_secs)) + .idle_timeout(Duration::from_secs(config.idle_timeout_secs)) + .after_connect(|conn, _meta| { + Box::pin(async move { + // `SET` cannot take bind parameters; `set_config` can. + sqlx::query("SELECT set_config('buzz.created_at_floor', $1, false)") + .bind(replica_fence::CREATED_AT_FLOOR_SECS.to_string()) + .execute(&mut *conn) + .await?; + let isolation: String = sqlx::query_scalar("SHOW transaction_isolation") + .fetch_one(&mut *conn) + .await?; + if isolation != "read committed" { + return Err(sqlx::Error::Configuration( + format!( + "writer pool requires READ COMMITTED transaction isolation, got {isolation}" + ) + .into(), + )); + } + Ok(()) + }) + }); + Ok(options.connect(url).await?) + } + + /// Reader acquire timeout — deliberately far below the writer's + /// (seconds-denominated) timeout. Failing closed to the writer must be + /// fast: a saturated reader pool that made routed reads wait the full + /// writer-style timeout would add dead latency during exactly the load + /// spike the offload exists for. A miss here surfaces as + /// `writer/reader_acquire_timeout` (see [`Db::proved_reader`] for why + /// the reason names the mechanism rather than a diagnosis). + const READER_ACQUIRE_TIMEOUT: Duration = Duration::from_millis(150); + + /// Connect the read-replica pool **lazily** — no connection is + /// attempted at construction, so a reader that is down at boot cannot + /// crash the relay (it starts all-writer with the fence closed and + /// recovers when the replica returns). + /// + /// `min_connections` is pinned to 0 explicitly: sqlx's lazy pool still + /// spawns an eager background connect task to satisfy a nonzero + /// minimum, which would reintroduce boot-time reader dial attempts (and + /// their log noise) that "lazy" is meant to avoid. With 0, connections + /// are dialed only on first acquire; the ~10-minute reaper never tops + /// the pool back up, which is fine — routed reads re-fill it on demand. + /// + /// No floor guard or writer-isolation assertion: replica sessions are + /// read-only, so the commit-time trigger from migration 0021 never fires + /// here and the write fence that depends on READ COMMITTED is never reached. + fn connect_read_pool(config: &DbConfig, url: &str, max_connections: u32) -> Result { + Ok(PgPoolOptions::new() + .max_connections(max_connections) + .min_connections(0) + .acquire_timeout(Self::READER_ACQUIRE_TIMEOUT) + .max_lifetime(Duration::from_secs(config.max_lifetime_secs)) + .idle_timeout(Duration::from_secs(config.idle_timeout_secs)) + .connect_lazy(url)?) + } + + /// Spawn a one-shot reader reachability probe that only WARNs. + /// + /// With a lazy pool and `min_connections(0)`, nothing dials the replica + /// until the first routed read — so a misconfigured `READ_DATABASE_URL` + /// would otherwise be invisible until traffic arrives and quietly falls + /// back to the writer. This ping is the only boot-time reader-down + /// visibility; it must never gate startup or [`Db::spawn_fence_probe`]. + /// + /// On success it also primes the Aurora identity capability cache + /// ([`Db::reader_aurora_identity`]) on the connection it already holds, + /// so the first routed read doesn't spend a second acquire (up to + /// another [`Db::READER_ACQUIRE_TIMEOUT`]) inside + /// [`Db::reader_aurora_capability_on`]. Prime failure is fine: the routed + /// path re-probes on the connection it already holds, so a failed prime + /// costs a round trip rather than a second acquire budget. + pub fn spawn_read_pool_boot_ping(&self) { + let Some(read_pool) = self.read_pool.clone() else { + return; + }; + let aurora_identity = self.reader_aurora_identity.clone(); + tokio::spawn(async move { + match observability::acquire(&read_pool, observability::PoolRole::Reader).await { + Ok(mut conn) => { + tracing::info!("read replica reachable at boot"); + match replica_fence::reader_supports_aurora_identity(&mut conn).await { + Ok(supported) => { + let _ = aurora_identity.set(supported); + } + Err(e) => tracing::debug!( + error = %e, + "aurora identity boot prime failed; first routed read will probe" + ), + } + } + Err(e) => tracing::warn!( + "read replica unreachable at boot; serving all-writer until it recovers: {e}" + ), + } + }); + } + + /// Creates a `Db` from an existing `PgPool` (useful in tests). + pub fn from_pool(pool: PgPool) -> Self { + Self { + max_connections: pool.options().get_max_connections(), + read_max_connections: pool.options().get_max_connections(), + pool, + read_pool: None, + fence: std::sync::Arc::new(replica_fence::ReplicaFence::new()), + replica_read_max_age: None, + reader_aurora_identity: std::sync::Arc::new(std::sync::OnceLock::new()), + } + } + + /// Creates a `Db` from distinct writer and read pools (useful in tests, + /// where a second database stands in for a lagged replica). + /// + /// The fence starts closed; tests that want cursor pages served by the + /// fake replica must open it via + /// [`replica_fence::ReplicaFence::force_open_for_tests`] (see + /// [`Db::fence`]). + pub fn from_pools(pool: PgPool, read_pool: PgPool) -> Self { + Self { + max_connections: pool.options().get_max_connections(), + read_max_connections: read_pool.options().get_max_connections(), + pool, + read_pool: Some(read_pool), + fence: std::sync::Arc::new(replica_fence::ReplicaFence::new()), + replica_read_max_age: None, + reader_aurora_identity: std::sync::Arc::new(std::sync::OnceLock::new()), + } + } + + /// Test hook: set the head-fetch routing budget (Predicate A), which + /// [`Db::from_pools`] leaves disabled. + pub fn set_replica_read_max_age_for_tests(&mut self, budget: Option) { + self.replica_read_max_age = budget; + } + + /// The freshness fence gating replica routing (see [`replica_fence`]). + pub fn fence(&self) -> &std::sync::Arc { + &self.fence + } + + /// Verify the floor guard end-to-end, then spawn the background fence + /// probe. Returns `Ok(false)` when no replica is configured. + /// + /// Ordering matters (Perci, PR #2084 review): this must run **after** + /// the migration decision. On a relay with `BUZZ_AUTO_MIGRATE` off, the + /// writer pool arms the GUC regardless, but if migration 0021 has not + /// been applied there is no trigger enforcing it — and a heartbeat probe + /// would open the fence over an unenforced floor. So the probe is gated + /// on an unconditional two-part verification against the live schema: + /// catalog shape ([`replica_fence::verify_floor_guard_catalog`]) and + /// observed semantics through this exact pool + /// ([`replica_fence::verify_floor_guard_behavior`]). + /// + /// On any verification failure the probe is never spawned and the fence + /// stays closed: every cursor page routes to the writer. The relay keeps + /// serving — degraded capacity, never holes. + pub async fn spawn_fence_probe(&self) -> Result { + if self.read_pool.is_none() { + return Ok(false); + } + replica_fence::verify_floor_guard_catalog(&self.pool).await?; + replica_fence::verify_floor_guard_behavior(&self.pool).await?; + tokio::spawn(replica_fence::run_probe( + self.pool.clone(), + std::sync::Arc::clone(&self.fence), + )); + Ok(true) + } + + /// The pool for lag-tolerant reads: the read replica when configured, + /// otherwise the writer pool. + /// + /// Removed as a public escape hatch (Dawn, review of 1b0aa0dfa): the + /// raw replica pool carries **no fence proof**, which is exactly the + /// bug class the routed-read machinery exists to eliminate. All replica + /// reads must go through [`Db::route_read`]-backed entry points; this + /// remains only for the fence's own plumbing tests. + #[cfg(test)] + fn read(&self) -> &PgPool { + self.read_pool.as_ref().unwrap_or(&self.pool) + } + + /// Whether a distinct read-replica pool is configured. + pub fn has_read_pool(&self) -> bool { + self.read_pool.is_some() + } + + /// Open a reader request transaction and complete the connection-local + /// half of the fence proof: `BEGIN ISOLATION LEVEL REPEATABLE READ, READ + /// ONLY`, then observe the heartbeat token/epoch as the transaction's + /// **first statement** — anchoring the snapshot every follow-up + /// statement (page, participants, aux closure) sees to exactly the + /// snapshot the proof was taken against — and resolve it against the + /// retained ring. Returns the open transaction together with the + /// strongest [`replica_fence::TokenEntry`] its observation supports, or + /// the fail-closed reason for route metrics. + /// + /// `REPEATABLE READ` is the strongest isolation a hot standby supports + /// (`SERIALIZABLE` is writer-only); `READ ONLY` documents intent and + /// rejects accidental writes. Everything but `Ok` fails closed — begin + /// failure, missing heartbeat row (migration not yet replayed there), + /// observation error, epoch mismatch, or a token below every retained + /// entry all route the request to the writer. + async fn proved_reader( + &self, + read_pool: &PgPool, + ) -> std::result::Result< + ( + sqlx::Transaction<'static, sqlx::Postgres>, + replica_fence::TokenEntry, + ), + &'static str, + > { + // One checkout per routed read. The Aurora capability probe and the + // read-only transaction share a single `acquire()` so the request path + // spends exactly one READER_ACQUIRE_TIMEOUT budget. Probing through + // `read_pool` separately would spend a second budget whenever the + // capability is uncached — i.e. after a failed boot ping, which is + // precisely the reader-unavailable case the bound must hold for. + let conn = match observability::acquire(read_pool, observability::PoolRole::Reader).await { + Ok(conn) => conn, + Err(sqlx::Error::PoolTimedOut) => { + tracing::warn!("reader pool acquire timed out; routing to writer"); + return Err("reader_acquire_timeout"); + } + Err(e) => { + tracing::warn!(error = %e, "reader connection acquire failed; routing to writer"); + return Err("reader_validation_error"); + } + }; + let mut conn = conn; + let aurora = self.reader_aurora_capability_on(&mut conn).await; + let mut tx = match sqlx::Transaction::begin( + conn, + Some(sqlx::SqlStr::from_static( + "BEGIN ISOLATION LEVEL REPEATABLE READ, READ ONLY", + )), + ) + .await + { + Ok(tx) => tx, + // The acquire miss gets its own reason code: the reader pool's + // short acquire timeout (READER_ACQUIRE_TIMEOUT) makes this the + // fast fail-closed path under load, and + // `buzz_db_route_decision{decision="writer",reason="reader_acquire_timeout"}` + // is the operator's alert signal for a struggling reader pool. + // + // The reason deliberately names the mechanism, not a diagnosis: + // `PoolTimedOut` proves only that no connection was handed out + // within the 150ms budget. That budget includes cold connect + // (TCP+TLS+auth), and sqlx's `size` counts in-flight dials, so + // this fires for slow connection establishment as well as for + // established-connection contention — and neither `size == 0` + // nor `size >= max` recovers the missing causal bit (in-flight + // dials hold a size slot, and a cold burst can push + // `active = size - idle` toward max with zero busy connections). + // Runbook: correlate with `buzz_db_read_pool_active` / `_max` + // and reader connection health/latency; high active suggests + // contention, but this metric alone does not distinguish + // contention from slow connects. Note the gauge is a coarse + // sample (BUZZ_POOL_METRICS_INTERVAL_SECS, default 10s) while + // the event it explains lasts ~150ms — a short burst may fall + // between samples entirely, so absence of elevated active is + // NOT evidence of a cold connect. + Err(sqlx::Error::PoolTimedOut) => { + tracing::warn!("reader pool acquire timed out; routing to writer"); + return Err("reader_acquire_timeout"); + } + Err(e) => { + tracing::warn!(error = %e, "reader transaction begin failed; routing to writer"); + return Err("reader_validation_error"); + } + }; + let obs = match replica_fence::observe_heartbeat(&mut tx, aurora).await { + Ok(Some(observation)) => observation, + Ok(None) => return Err("reader_validation_error"), + Err(e) => { + tracing::warn!(error = %e, "heartbeat observation failed; routing to writer"); + return Err("reader_validation_error"); + } + }; + match self.fence.resolve(obs.token, obs.epoch) { + replica_fence::ResolveOutcome::Proved(entry) => { + tracing::debug!( + token = obs.token, + proved_token = entry.token, + backend = %obs.backend, + "reader snapshot proved fence coverage" + ); + Ok((tx, entry)) + } + replica_fence::ResolveOutcome::EpochMismatch => Err("reader_validation_error"), + replica_fence::ResolveOutcome::TokenBehind => Err("reader_token_behind"), + } + } + + /// Whether the reader endpoint supports the Aurora PostgreSQL identity + /// function ([`replica_fence::AURORA_IDENTITY_FN`]), probed + /// once per process and cached (see [`Db::reader_aurora_identity`]). + /// The probe runs on a plain autocommit checkout — never inside the + /// request transaction, where an undefined-function error would abort + /// it. Probe failure (acquire or transient) degrades to the plain + /// identity tuple for THIS request without caching, so a later request + /// retries; identity is evidence, never a routing gate. + /// Aurora capability on a connection the caller already holds, so the + /// routed path never spends a second acquire budget. + async fn reader_aurora_capability_on( + &self, + conn: &mut sqlx::pool::PoolConnection, + ) -> bool { + if let Some(cached) = self.reader_aurora_identity.get() { + return *cached; + } + match replica_fence::reader_supports_aurora_identity(conn).await { + Ok(supported) => *self.reader_aurora_identity.get_or_init(|| supported), + Err(e) => { + tracing::debug!(error = %e, "aurora identity probe failed; will retry"); + false + } + } + } + + /// Record one route decision (Rev 2 observability): which path, where it + /// went, and why. + pub(crate) fn record_route(path: &'static str, decision: &'static str, reason: &'static str) { + metrics::counter!( + "buzz_db_route_decision", + "path" => path, + "decision" => decision, + "reason" => reason, + ) + .increment(1); + } + + /// Run pending database migrations. + #[datastore_span(name = "migrate", system = "postgresql")] + pub async fn migrate(&self) -> Result<()> { + migration::run_migrations(&self.pool).await + } + + /// Returns `true` if the database is reachable (used by readiness probes). + pub async fn ping(&self) -> bool { + sqlx::query("SELECT 1").execute(&self.pool).await.is_ok() + } + + /// Returns pool utilisation stats for metrics emission. + /// + /// `size` — total connections (idle + active) + /// `idle` — connections available for immediate reuse + /// `max` — pool ceiling set at construction + pub fn pool_stats(&self) -> DbPoolStats { + DbPoolStats { + size: self.pool.size(), + idle: self.pool.num_idle() as u32, + max: self.max_connections, + } + } + + /// Pool utilisation stats for the read-replica pool, when configured. + /// + /// `max` is the **reader's** ceiling ([`Db::read_max_connections`]), not + /// the writer's: `buzz_db_read_pool_active / buzz_db_read_pool_max` is + /// the operator's utilisation signal for tuning `BUZZ_DB_READ_POOL_SIZE`, + /// and deriving it from the writer's max would misreport saturation by + /// exactly the ratio of the two pool sizes — in the direction that hides + /// the problem. + pub fn read_pool_stats(&self) -> Option { + self.read_pool.as_ref().map(|p| DbPoolStats { + size: p.size(), + idle: p.num_idle() as u32, + max: self.read_max_connections, + }) + } + + /// Begin a database transaction for atomic multi-statement operations. + /// + /// Returns a `'static` transaction because `PgPool` is `Arc`-backed internally. + /// The transaction holds an owned pool handle, not a borrow. + pub async fn begin_transaction(&self) -> Result> { + let connection = + observability::acquire(&self.pool, observability::PoolRole::Writer).await?; + sqlx::Transaction::begin(connection, None) + .await + .map_err(Into::into) + } + + /// Insert an event while holding and validating an admitted serving-write + /// lease under the community ordering lock through commit. + /// + /// External side effects use a durable lease rather than one long-lived DB + /// transaction. Their final database mutation presents that exact lease so + /// it may finish during quiescing without admitting any new serving work. + pub async fn insert_event_with_serving_write_guard( + &self, + lease: &deletion::ServingWriteLease, + event: &nostr::Event, + channel_id: Option, + ) -> Result<(StoredEvent, bool)> { + let community_id = lease.community_id; + let kind_u16 = event.kind.as_u16(); + let kind_u32 = u32::from(kind_u16); + if kind_u32 == buzz_core::kind::KIND_AUTH { + return Err(DbError::AuthEventRejected); + } + if buzz_core::kind::is_ephemeral(kind_u32) { + return Err(DbError::EphemeralEventRejected(kind_u16)); + } + + let mut tx = self.pool.begin().await?; + self.deletion_store() + .guard_transaction_with_serving_lease(&mut tx, lease) + .await?; + let result = event::insert_event_with_thread_metadata_tx( + &mut tx, + community_id, + event, + channel_id, + None, + ) + .await?; + tx.commit().await?; + if result.1 { + if let Err(e) = insert_mentions(&self.pool, community_id, event, channel_id).await { + tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); + } + } + Ok(result) + } + + /// Shared route decision for one read: evaluate the predicate against a + /// proved reader session and record the decision. Fail closed to the + /// writer everywhere. + pub(crate) async fn route_read( + &self, + path: &'static str, + predicate: RoutePredicate, + ) -> RouteDecision { + let Some(read_pool) = &self.read_pool else { + Self::record_route(path, "writer", "disabled"); + return RouteDecision::Writer; + }; + // Cheap prechecks on the shared ring before spending a reader + // checkout; the connection-local observation still has to prove it. + let Some(newest) = self.fence.newest() else { + Self::record_route(path, "writer", "uninitialized"); + return RouteDecision::Writer; + }; + // Precheck helpers against the newest shared entry: if the newest + // cannot satisfy an arm, no proved (older-or-equal) entry can. + let bounded_precheck = + |budget: &Option| -> std::result::Result<(), &'static str> { + match budget { + Some(budget) if newest.committed_at.elapsed() <= *budget => Ok(()), + Some(_) => Err("stale"), + None => Err("disabled"), + } + }; + let covered_precheck = |upper: &DateTime| -> std::result::Result<(), &'static str> { + if *upper <= newest.fence_wall { + Ok(()) + } else { + Err("stale") + } + }; + let precheck = match &predicate { + RoutePredicate::Bounded => bounded_precheck(&self.replica_read_max_age), + RoutePredicate::Covered { upper, .. } => covered_precheck(upper), + // No upper bound: the caller post-verifies served rows. + RoutePredicate::CoveredPostVerified { .. } => Ok(()), + // Covered first (no budget dependence), else bounded. + RoutePredicate::BoundedOrCovered { upper, .. } => { + covered_precheck(upper).or_else(|_| bounded_precheck(&self.replica_read_max_age)) + } + }; + if let Err(reason) = precheck { + Self::record_route(path, "writer", reason); + return RouteDecision::Writer; + } + match self.proved_reader(read_pool).await { + Ok((tx, entry)) => { + // Re-evaluate against the entry the session actually proved + // (it may be older than the shared newest). + let bounded_holds = || { + self.replica_read_max_age + .is_some_and(|budget| entry.committed_at.elapsed() <= budget) + }; + let verdict: Option<&'static str> = match &predicate { + RoutePredicate::Bounded => bounded_holds().then_some("fresh"), + RoutePredicate::Covered { upper, .. } => { + (*upper <= entry.fence_wall).then_some("covered") + } + // No upper bound: the caller post-verifies the served + // rows against the proved wall. + RoutePredicate::CoveredPostVerified { .. } => Some("covered"), + RoutePredicate::BoundedOrCovered { upper, .. } => { + if *upper <= entry.fence_wall { + Some("covered") + } else { + bounded_holds().then_some("fresh") + } + } + }; + match verdict { + Some(reason) => RouteDecision::Replica(tx, entry, reason), + None => { + // The session proves an older entry than the + // predicate needs (replication lag) — fail closed. + Self::record_route(path, "writer", "stale"); + RouteDecision::Writer + } + } + } + Err(reason) => { + Self::record_route(path, "writer", reason); + RouteDecision::Writer + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-db/src/observability.rs b/crates/buzz-db/src/runtime/observability.rs similarity index 100% rename from crates/buzz-db/src/observability.rs rename to crates/buzz-db/src/runtime/observability.rs diff --git a/crates/buzz-db/src/replica_fence.rs b/crates/buzz-db/src/runtime/replica_fence.rs similarity index 99% rename from crates/buzz-db/src/replica_fence.rs rename to crates/buzz-db/src/runtime/replica_fence.rs index 83322bea14..cf9b46ddd8 100644 --- a/crates/buzz-db/src/replica_fence.rs +++ b/crates/buzz-db/src/runtime/replica_fence.rs @@ -19,13 +19,13 @@ //! observes `token >= M` on its own connection has, by WAL/storage replay //! order, also replayed every commit that preceded M's commit; every //! transaction then partitions into exactly three buckets: -//! (a) finished before the activity scan — its commit precedes `M`'s -//! commit, so the replica session has replayed it; -//! (b) open at the activity scan — represented by `xact_start`, so it is -//! bounded by the `oldest_xact_start` term; -//! (c) started after the activity scan — its deferred floor guard runs -//! after `S`, so it cannot commit a row with -//! `created_at < S - floor`. +//! (a) finished before the activity scan — its commit precedes `M`'s +//! commit, so the replica session has replayed it; +//! (b) open at the activity scan — represented by `xact_start`, so it is +//! bounded by the `oldest_xact_start` term; +//! (c) started after the activity scan — its deferred floor guard runs +//! after `S`, so it cannot commit a row with +//! `created_at < S - floor`. //! There is no fourth bucket. Each committed token `M` therefore proves a //! **fence wall** of `min(oldest_xact_start, S) - floor - clock_margin`: //! every channel-window row with `created_at <= fence_wall(M)` is present diff --git a/crates/buzz-db/src/runtime/tests.rs b/crates/buzz-db/src/runtime/tests.rs new file mode 100644 index 0000000000..ecdc983a4a --- /dev/null +++ b/crates/buzz-db/src/runtime/tests.rs @@ -0,0 +1,2542 @@ +use super::*; +use crate::{relay_members, thread}; +use buzz_core::CommunityId; +use sqlx::PgPool; +use uuid::Uuid; + +const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + +async fn setup_db() -> Db { + let database_url = std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); + let pool = PgPool::connect(&database_url) + .await + .expect("connect to test DB"); + Db::from_pool(pool) +} + +async fn make_community(pool: &PgPool) -> Uuid { + let id = Uuid::new_v4(); + let host = format!("communities-of-channels-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert community"); + id +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn database_guard_covers_legacy_writer_and_nip09_deletion() { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + let db = setup_db().await; + let community = CommunityId::from_uuid(make_community(&db.pool).await); + let keys = Keys::generate(); + let d_tag = format!("read-state:{}", "b".repeat(32)); + let tags = vec![ + Tag::parse(["d", d_tag.as_str()]).expect("d tag"), + Tag::parse(["t", "read-state"]).expect("t tag"), + ]; + let base = Timestamp::now().as_secs(); + let a = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "A") + .tags(tags.clone()) + .custom_created_at(Timestamp::from(base)) + .sign_with_keys(&keys) + .expect("sign A"); + let x = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "X") + .tags(tags.clone()) + .custom_created_at(Timestamp::from(base + 1)) + .sign_with_keys(&keys) + .expect("sign X"); + let b = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "B") + .tags(tags.clone()) + .custom_created_at(Timestamp::from(base + 2)) + .sign_with_keys(&keys) + .expect("sign B"); + let c = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "C") + .tags(tags) + .custom_created_at(Timestamp::from(base + 3)) + .sign_with_keys(&keys) + .expect("sign C"); + + async fn legacy_insert( + pool: &PgPool, + community: CommunityId, + event: &nostr::Event, + d_tag: &str, + ) -> std::result::Result { + sqlx::query( + "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, d_tag) \ + VALUES ($1, $2, $3, to_timestamp($4), $5, $6, $7, $8, NOW(), $9) ON CONFLICT DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .bind(event.pubkey.to_bytes()) + .bind(event.created_at.as_secs() as f64) + .bind(buzz_core::kind::KIND_READ_STATE as i32) + .bind(serde_json::to_value(&event.tags).expect("serialize tags")) + .bind(&event.content) + .bind(event.sig.serialize().as_slice()) + .bind(d_tag) + .execute(pool) + .await + } + + legacy_insert(&db.pool, community, &a, &d_tag) + .await + .expect("legacy insert A"); + let duplicate = legacy_insert(&db.pool, community, &a, &d_tag) + .await + .expect("legacy duplicate A remains idempotent"); + assert_eq!(duplicate.rows_affected(), 0); + + sqlx::query( + "INSERT INTO event_mentions \ + (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ + VALUES ($1, $2, $3, to_timestamp($4), 30078)", + ) + .bind(community.as_uuid()) + .bind("c".repeat(64)) + .bind(a.id.as_bytes().as_slice()) + .bind(a.created_at.as_secs() as f64) + .execute(&db.pool) + .await + .expect("insert live mention"); + + // Emulate the pre-PR replacement path after migration 0007: soft-delete + // the live row, then insert B without any application watermark write. + sqlx::query( + "UPDATE events SET deleted_at=NOW() \ + WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(&d_tag) + .execute(&db.pool) + .await + .expect("legacy soft-delete A"); + let mentions_after_delete: i64 = sqlx::query_scalar( + "SELECT count(*) FROM event_mentions WHERE community_id=$1 AND event_id=$2", + ) + .bind(community.as_uuid()) + .bind(a.id.as_bytes().as_slice()) + .fetch_one(&db.pool) + .await + .expect("count mentions after delete"); + assert_eq!(mentions_after_delete, 0); + + let stale_mention = sqlx::query( + "INSERT INTO event_mentions \ + (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ + VALUES ($1, $2, $3, to_timestamp($4), 30078)", + ) + .bind(community.as_uuid()) + .bind("d".repeat(64)) + .bind(a.id.as_bytes().as_slice()) + .bind(a.created_at.as_secs() as f64) + .execute(&db.pool) + .await + .expect("stale post-commit mention is skipped"); + assert_eq!(stale_mention.rows_affected(), 0); + + legacy_insert(&db.pool, community, &b, &d_tag) + .await + .expect("legacy insert B"); + let duplicate_b = legacy_insert(&db.pool, community, &b, &d_tag) + .await + .expect("live duplicate B is skipped"); + assert_eq!(duplicate_b.rows_affected(), 0); + + sqlx::query( + "INSERT INTO event_mentions \ + (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ + VALUES ($1, $2, $3, to_timestamp($4), 30078)", + ) + .bind(community.as_uuid()) + .bind("e".repeat(64)) + .bind(b.id.as_bytes().as_slice()) + .bind(b.created_at.as_secs() as f64) + .execute(&db.pool) + .await + .expect("insert B mention"); + + // Exercise the new Rust hard-delete path independently. An in-flight + // mention holds KEY SHARE on B, so replacement by C must block, then + // complete after the mention commits and remove both B and its mention. + let mut rust_mention_tx = db + .pool + .begin() + .await + .expect("begin Rust mention transaction"); + sqlx::query( + "INSERT INTO event_mentions \ + (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ + VALUES ($1, $2, $3, to_timestamp($4), 30078) ON CONFLICT DO NOTHING", + ) + .bind(community.as_uuid()) + .bind("e".repeat(64)) + .bind(b.id.as_bytes().as_slice()) + .bind(b.created_at.as_secs() as f64) + .execute(&mut *rust_mention_tx) + .await + .expect("hold B live-event key-share lock"); + + let replace_db = db.clone(); + let replace_d_tag = d_tag.clone(); + let replace_c = c.clone(); + let replace_task = tokio::spawn(async move { + replace_db + .replace_parameterized_event(community, &replace_c, &replace_d_tag, None) + .await + }); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + assert!( + !replace_task.is_finished(), + "Rust hard delete should wait for mention lock" + ); + rust_mention_tx + .commit() + .await + .expect("release Rust mention lock"); + let replaced = tokio::time::timeout(std::time::Duration::from_secs(2), replace_task) + .await + .expect("Rust hard delete deadlocked with mention insert") + .expect("replacement task panicked") + .expect("replace B with C"); + assert!(replaced.1, "C must replace B"); + let b_mentions: i64 = sqlx::query_scalar( + "SELECT count(*) FROM event_mentions WHERE community_id=$1 AND event_id=$2", + ) + .bind(community.as_uuid()) + .bind(b.id.as_bytes().as_slice()) + .fetch_one(&db.pool) + .await + .expect("count B mentions after Rust replacement"); + assert_eq!(b_mentions, 0); + + sqlx::query( + "INSERT INTO event_mentions \ + (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ + VALUES ($1, $2, $3, to_timestamp($4), 30078)", + ) + .bind(community.as_uuid()) + .bind("f".repeat(64)) + .bind(c.id.as_bytes().as_slice()) + .bind(c.created_at.as_secs() as f64) + .execute(&db.pool) + .await + .expect("insert C mention"); + + // Exercise legacy UPDATE-trigger deletion with the same barrier. While + // deletion waits on C's KEY SHARE lock, an exact replay must already be + // a zero-row trigger no-op; it must not wait for deletion or resurrect C. + let mut legacy_mention_tx = db + .pool + .begin() + .await + .expect("begin legacy mention transaction"); + sqlx::query( + "INSERT INTO event_mentions \ + (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ + VALUES ($1, $2, $3, to_timestamp($4), 30078) ON CONFLICT DO NOTHING", + ) + .bind(community.as_uuid()) + .bind("f".repeat(64)) + .bind(c.id.as_bytes().as_slice()) + .bind(c.created_at.as_secs() as f64) + .execute(&mut *legacy_mention_tx) + .await + .expect("hold C live-event key-share lock"); + + let delete_pool = db.pool.clone(); + let delete_pubkey = keys.public_key().to_bytes(); + let delete_d_tag = d_tag.clone(); + let delete_task = tokio::spawn(async move { + sqlx::query( + "UPDATE events SET deleted_at=NOW() \ + WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(delete_pubkey) + .bind(delete_d_tag) + .execute(&delete_pool) + .await + }); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + assert!( + !delete_task.is_finished(), + "legacy delete should wait for mention lock" + ); + + let replay_while_delete_waits = legacy_insert(&db.pool, community, &c, &d_tag) + .await + .expect("concurrent exact C replay is skipped"); + assert_eq!(replay_while_delete_waits.rows_affected(), 0); + + legacy_mention_tx + .commit() + .await + .expect("release legacy mention lock"); + tokio::time::timeout(std::time::Duration::from_secs(2), delete_task) + .await + .expect("legacy delete deadlocked with mention insert") + .expect("delete task panicked") + .expect("legacy NIP-09 delete C"); + + let payloads: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(&d_tag) + .fetch_one(&db.pool) + .await + .expect("count retained payloads"); + assert_eq!( + payloads, 0, + "legacy soft deletes must not retain NIP-RS payloads" + ); + + // Opposite commit order: deletion has committed before exact replay. + // Equality remains an observable zero-row no-op, never a resurrection. + let replay_c = legacy_insert(&db.pool, community, &c, &d_tag) + .await + .expect("post-delete exact C replay is skipped"); + assert_eq!(replay_c.rows_affected(), 0); + let payloads_after_exact_replay: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(&d_tag) + .fetch_one(&db.pool) + .await + .expect("count payloads after exact replay"); + assert_eq!(payloads_after_exact_replay, 0); + + let replay = legacy_insert(&db.pool, community, &x, &d_tag).await; + assert!( + replay.is_err(), + "database guard must reject A < X < C replay" + ); + + let watermark: (chrono::DateTime, Vec) = sqlx::query_as( + "SELECT created_at, event_id FROM parameterized_event_watermarks \ + WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(&d_tag) + .fetch_one(&db.pool) + .await + .expect("read C watermark"); + assert_eq!(watermark.0.timestamp(), base as i64 + 3); + assert_eq!(watermark.1, c.id.as_bytes().as_slice()); +} + +// ---- Read-replica routing ------------------------------------------------ +// +// These tests pin the routing contract of `Db::read()` and the two routed +// methods. A second scratch database stands in for the replica; the +// fixtures are deliberately DIVERGENT (rows that exist in only one of the +// two databases) so every assertion observes which pool actually served +// the query instead of trusting the routing code's word for it. + +async fn admin_url() -> String { + std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()) +} + +/// Create a fresh scratch database on the same server and optionally run migrations. +async fn create_scratch_db_through( + admin: &PgPool, + prefix: &str, + target: Option, +) -> (PgPool, String) { + let name = format!("{}_{}", prefix, Uuid::new_v4().simple()); + sqlx::query(sqlx::AssertSqlSafe(format!("CREATE DATABASE {name}"))) + .execute(admin) + .await + .expect("create scratch db"); + let base = admin_url().await; + // Swap the database path segment of the admin URL for the scratch name. + let scratch_url = { + let idx = base.rfind('/').expect("db url has a path segment"); + format!("{}/{}", &base[..idx], name) + }; + let pool = PgPool::connect(&scratch_url) + .await + .expect("connect scratch db"); + match target { + Some(target) => migration::run_migrations_through(&pool, target) + .await + .expect("migrate scratch db through target"), + None => migration::run_migrations(&pool) + .await + .expect("migrate scratch db"), + } + (pool, name) +} + +/// Create a fresh scratch database on the same server and run all migrations. +/// Returns (pool, db_name); callers should `drop_scratch_db` when done. +async fn create_scratch_db(admin: &PgPool, prefix: &str) -> (PgPool, String) { + create_scratch_db_through(admin, prefix, None).await +} + +async fn drop_scratch_db(admin: &PgPool, pool: PgPool, name: &str) { + pool.close().await; + let _ = sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP DATABASE IF EXISTS {name} WITH (FORCE)" + ))) + .execute(admin) + .await; +} + +/// Insert identical community + channel rows into a database so the same +/// (community, channel) ids resolve in both writer and replica. +async fn seed_community_channel( + pool: &PgPool, + community: Uuid, + channel: Uuid, + author: &nostr::Keys, +) { + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community) + .bind(format!("replica-routing-{}.example", community.simple())) + .execute(pool) + .await + .expect("insert community"); + crate::channel::create_channel_with_id( + pool, + CommunityId::from_uuid(community), + channel, + &format!("replica-routing-{channel}"), + crate::channel::ChannelType::Stream, + crate::channel::ChannelVisibility::Open, + None, + author.public_key().to_bytes().as_slice(), + None, + ) + .await + .expect("create channel"); +} + +fn signed_event_at(keys: &nostr::Keys, content: &str, secs: u64) -> nostr::Event { + nostr::EventBuilder::new(nostr::Kind::Custom(9), content) + .custom_created_at(nostr::Timestamp::from(secs)) + .sign_with_keys(keys) + .expect("sign event") +} + +async fn insert_top_level(pool: &PgPool, community: Uuid, channel: Uuid, ev: &nostr::Event) { + let ts = chrono::DateTime::from_timestamp(ev.created_at.as_secs() as i64, 0).expect("valid ts"); + event::insert_event_with_thread_metadata( + pool, + CommunityId::from_uuid(community), + ev, + Some(channel), + Some(event::ThreadMetadataParams { + event_id: ev.id.as_bytes(), + event_created_at: ts, + channel_id: channel, + parent_event_id: None, + parent_event_created_at: None, + root_event_id: None, + root_event_created_at: None, + depth: 0, + broadcast: true, + }), + ) + .await + .expect("insert top-level event"); +} + +async fn insert_thread_reply( + pool: &PgPool, + community: Uuid, + channel: Uuid, + root: &nostr::Event, + reply: &nostr::Event, +) { + let reply_ts = + chrono::DateTime::from_timestamp(reply.created_at.as_secs() as i64, 0).expect("valid ts"); + let root_ts = + chrono::DateTime::from_timestamp(root.created_at.as_secs() as i64, 0).expect("valid ts"); + event::insert_event_with_thread_metadata( + pool, + CommunityId::from_uuid(community), + reply, + Some(channel), + Some(event::ThreadMetadataParams { + event_id: reply.id.as_bytes(), + event_created_at: reply_ts, + channel_id: channel, + parent_event_id: Some(root.id.as_bytes()), + parent_event_created_at: Some(root_ts), + root_event_id: Some(root.id.as_bytes()), + root_event_created_at: Some(root_ts), + depth: 1, + broadcast: false, + }), + ) + .await + .expect("insert reply"); +} + +/// Composite thread cursor: 8-byte BE seconds + raw event id. +fn thread_cursor(reply: &crate::thread::ThreadReply) -> Vec { + let mut cur = reply.created_at.timestamp().to_be_bytes().to_vec(); + cur.extend_from_slice(&reply.event_id); + cur +} + +#[tokio::test] +async fn read_falls_back_to_writer_when_no_replica_configured() { + // Pure wiring test — connect_lazy never touches the network. + let pool = sqlx::PgPool::connect_lazy(TEST_DB_URL).expect("lazy pool"); + let db = Db::from_pool(pool); + assert!(!db.has_read_pool()); + assert!( + std::ptr::eq(db.read(), &db.pool), + "read() must be the writer pool when no replica is configured" + ); + assert!(db.read_pool_stats().is_none()); +} + +#[test] +fn read_budget_zero_disables_and_large_values_clamp_to_staleness() { + assert_eq!(read_budget_from_ms(0), None, "0 = bounded routing off"); + assert_eq!( + read_budget_from_ms(1000), + Some(std::time::Duration::from_millis(1000)) + ); + assert_eq!( + read_budget_from_ms(10_000_000), + Some(replica_fence::FENCE_STALENESS), + "budgets above the staleness gate clamp to it" + ); +} + +/// Truth table for [`RoutePredicate::for_query`]: the strongest sound +/// predicate per query shape, and — the deploy-day default row — that +/// `routing_enabled = false` (BUZZ_REPLICA_READ_MAX_AGE_MS unset) +/// forces `Bounded` even for covered-eligible shapes, so the zero +/// budget fails the new seams closed (Dawn's covered-at-zero-budget +/// catch, design doc rev 5). +#[test] +fn for_query_predicate_truth_table() { + let community = CommunityId::from_uuid(Uuid::new_v4()); + let channel = Uuid::new_v4(); + let until = chrono::Utc::now(); + + let pinned_with_until = { + let mut q = event::EventQuery::for_community(community); + q.channel_id = Some(channel); + q.until = Some(until); + q + }; + let pinned_no_until = { + let mut q = event::EventQuery::for_community(community); + q.channel_id = Some(channel); + q + }; + let unpinned_with_until = { + let mut q = event::EventQuery::for_community(community); + q.until = Some(until); + q + }; + let global_only = { + let mut q = event::EventQuery::for_community(community); + q.global_only = true; + q.until = Some(until); + q + }; + + // Deploy-day default: budget unset ⇒ Bounded regardless of shape. + // The zero budget then fails Bounded closed, so the new seams + // record writer/disabled — merging with no env var set is a no-op. + assert!( + matches!( + RoutePredicate::for_query(&pinned_with_until, false), + RoutePredicate::Bounded + ), + "budget unset must not reach the covered arm even when eligible" + ); + + // Budget set + channel pin + until ⇒ the strongest predicate. + assert!(matches!( + RoutePredicate::for_query(&pinned_with_until, true), + RoutePredicate::BoundedOrCovered { .. } + )); + + // Missing either covered precondition ⇒ Bounded. + assert!(matches!( + RoutePredicate::for_query(&pinned_no_until, true), + RoutePredicate::Bounded + )); + assert!(matches!( + RoutePredicate::for_query(&unpinned_with_until, true), + RoutePredicate::Bounded + )); + // global_only implies `channel_id = None`, so the channel-pin + // precondition fails and no covered arm is possible — `for_query` + // never inspects `global_only` itself; the row holds because + // constructor 1 (channel pin) returns None for an unpinned query. + assert!(matches!( + RoutePredicate::for_query(&global_only, true), + RoutePredicate::Bounded + )); +} + +/// The pre-existing cursor paths are NOT budget-gated: a channel-window +/// cursor page still derives `Covered` with no `routing_enabled` input +/// at all — at B=0 today it routes covered, and that status quo is +/// intentionally unchanged by the `for_query` gate (Max's matrix row: +/// old paths route at budget-unset; only the new seams go dark). +#[test] +fn channel_cursor_predicate_is_not_budget_gated() { + let channel = Uuid::new_v4(); + let cursor = Some((chrono::Utc::now(), vec![1u8; 32])); + assert!(matches!( + RoutePredicate::from_channel_cursor(channel, &cursor), + RoutePredicate::Covered { .. } + )); + // Head fetch (no cursor) is bounded — gated by the budget. + assert!(matches!( + RoutePredicate::from_channel_cursor(channel, &None), + RoutePredicate::Bounded + )); +} + +/// D5 wiring: `read_pool_stats().max` must be the READER pool's own +/// ceiling, not the writer's — `buzz_db_read_pool_active / _max` is the +/// operator's utilisation signal and inheriting the writer's max hides +/// reader saturation by exactly the sizing ratio. Pure wiring test: +/// `connect_lazy` never touches the network, but it does spawn the +/// pool reaper task, which needs a Tokio runtime — hence +/// `#[tokio::test]` despite the test body itself never awaiting. +#[tokio::test] +async fn read_pool_stats_reports_reader_ceiling_not_writer() { + let writer = sqlx::postgres::PgPoolOptions::new() + .max_connections(20) + .connect_lazy(TEST_DB_URL) + .expect("lazy writer pool"); + let reader = sqlx::postgres::PgPoolOptions::new() + .max_connections(40) + .connect_lazy(TEST_DB_URL) + .expect("lazy reader pool"); + let db = Db::from_pools(writer, reader); + assert_eq!(db.pool_stats().max, 20); + assert_eq!( + db.read_pool_stats().expect("read pool configured").max, + 40, + "reader gauge must report the reader's own ceiling" + ); +} + +/// D4 wiring: the reader pool is built lazily with `min_connections(0)` +/// and the short reader acquire timeout — construction must succeed +/// with no replica listening (reader-down at boot must not crash the +/// relay), and `read_max_connections` must honour +/// `DbConfig::read_max_connections` over the writer sizing. +/// `#[tokio::test]` because `connect_lazy` spawns the pool reaper task, +/// which needs a Tokio runtime even though nothing is dialed. +#[tokio::test] +async fn connect_read_pool_is_lazy_and_independently_sized() { + let config = DbConfig { + max_connections: 20, + read_max_connections: Some(7), + ..DbConfig::default() + }; + // Unroutable per RFC 5737 TEST-NET-1: proves nothing is dialed at + // construction time. + let pool = Db::connect_read_pool(&config, "postgres://user:pw@192.0.2.1:5432/none", 7) + .expect("lazy construction must not dial the replica"); + assert_eq!(pool.options().get_max_connections(), 7); + assert_eq!(pool.options().get_min_connections(), 0); + assert_eq!( + pool.options().get_acquire_timeout(), + Db::READER_ACQUIRE_TIMEOUT + ); +} + +/// Channel window: head fetch (no cursor) reads the WRITER; cursor pages +/// read the REPLICA. Divergent fixtures prove which pool served each. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn channel_window_routes_head_to_writer_and_cursor_pages_to_replica() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "routing_w").await; + let (replica, rname) = create_scratch_db(&admin, "routing_r").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + // Shared history (both databases): m1 < m2 < m3. + let base = 1_700_000_000u64; + let m1 = signed_event_at(&author, "m1", base); + let m2 = signed_event_at(&author, "m2", base + 10); + let m3 = signed_event_at(&author, "m3", base + 20); + for pool in [&writer, &replica] { + for ev in [&m1, &m2, &m3] { + insert_top_level(pool, community, channel, ev).await; + } + } + // Lag: the newest event exists only on the writer. + let fresh = signed_event_at(&author, "fresh-writer-only", base + 30); + insert_top_level(&writer, community, channel, &fresh).await; + // Marker: exists only on the "replica" (unphysical for a real replica, + // but it makes replica-served pages unambiguous). + let marker = signed_event_at(&author, "replica-only-marker", base + 5); + insert_top_level(&replica, community, channel, &marker).await; + + let db = Db::from_pools(writer.clone(), replica.clone()); + // Open the fence through "now": the fixture's history is far in the + // past, so every cursor falls below the fence and routing is + // eligible. Fence-gating itself is pinned by the fence tests below. + db.fence().force_open_for_tests(chrono::Utc::now()); + let cid = CommunityId::from_uuid(community); + + // Head fetch (cursor: None) → writer: sees `fresh`, never `marker`. + let head = db + .get_channel_window(cid, channel, 2, None, None) + .await + .expect("head window"); + let head_contents: Vec = head + .rows + .iter() + .map(|r| r.stored_event.event.content.clone()) + .collect(); + assert_eq!( + head_contents, + vec!["fresh-writer-only".to_string(), "m3".to_string()], + "head fetch must be served by the writer" + ); + + // Cursor page → replica: sees `marker`, never `fresh`. + let cursor = head.next_cursor.expect("has_more implies next_cursor"); + let page2 = db + .get_channel_window(cid, channel, 10, Some(cursor), None) + .await + .expect("cursor window"); + let page2_contents: Vec = page2 + .rows + .iter() + .map(|r| r.stored_event.event.content.clone()) + .collect(); + assert_eq!( + page2_contents, + vec![ + "m2".to_string(), + "replica-only-marker".to_string(), + "m1".to_string() + ], + "cursor page must be served by the replica" + ); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; +} + +/// Fail-closed on a mid-request replica failure (Dawn, review of +/// 1b0aa0dfa): a replica-routed page whose query errors *after* the +/// proof (the live shape is a hot-standby recovery conflict — 40001 / +/// 25P02 — cancelling the held snapshot under `max_standby_streaming_delay`) +/// must be re-run on the writer and served, never surfaced as an error +/// the writer could have answered. Degraded capacity, never holes. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn replica_window_failure_falls_back_to_writer() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "fb_w").await; + let (replica, rname) = create_scratch_db(&admin, "fb_r").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + let m1 = signed_event_at(&author, "m1", base); + let m2 = signed_event_at(&author, "m2", base + 10); + let m3 = signed_event_at(&author, "m3", base + 20); + for pool in [&writer, &replica] { + for ev in [&m1, &m2, &m3] { + insert_top_level(pool, community, channel, ev).await; + } + } + let marker = signed_event_at(&author, "replica-only-marker", base + 5); + insert_top_level(&replica, community, channel, &marker).await; + + let db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + let cid = CommunityId::from_uuid(community); + + let head = db + .get_channel_window(cid, channel, 1, None, None) + .await + .expect("head window"); + let cursor = head.next_cursor.expect("has_more implies next_cursor"); + + // Guard against a vacuous pass: the cursor page must actually be + // replica-eligible before we break the replica. + let healthy = db + .get_channel_window(cid, channel, 10, Some(cursor.clone()), None) + .await + .expect("healthy cursor window"); + assert!( + healthy + .rows + .iter() + .any(|r| r.stored_event.event.content == "replica-only-marker"), + "fixture must route the cursor page to the replica while healthy" + ); + + // Break the replica AFTER the proof point: the heartbeat table stays + // intact (the observation succeeds), the page query then fails. + sqlx::query("DROP TABLE events CASCADE") + .execute(&replica) + .await + .expect("drop replica events"); + + let page = db + .get_channel_window(cid, channel, 10, Some(cursor), None) + .await + .expect("replica failure must fall back to the writer, not error"); + let contents: Vec<&str> = page + .rows + .iter() + .map(|r| r.stored_event.event.content.as_str()) + .collect(); + assert_eq!( + contents, + vec!["m2", "m1"], + "fallback page must be the writer's answer (no replica marker)" + ); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; +} + +/// [`replica_window_failure_falls_back_to_writer`] for the thread-replies +/// path: a replica-routed thread page whose query errors after the proof +/// re-runs on the writer instead of surfacing an error. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn replica_thread_failure_falls_back_to_writer() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "fbt_w").await; + let (replica, rname) = create_scratch_db(&admin, "fbt_r").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + let root = signed_event_at(&author, "root", base); + for pool in [&writer, &replica] { + insert_top_level(pool, community, channel, &root).await; + } + let replies: Vec = (1..=3) + .map(|i| signed_event_at(&author, &format!("r{i}"), base + 10 * i as u64)) + .collect(); + for pool in [&writer, &replica] { + for reply in &replies { + insert_thread_reply(pool, community, channel, &root, reply).await; + } + } + // Replica-only divergent reply between r2 and r3 marks replica serves. + let ghost = signed_event_at(&author, "replica-only-ghost", base + 25); + insert_thread_reply(&replica, community, channel, &root, &ghost).await; + + let db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + let cid = CommunityId::from_uuid(community); + + let page1 = db + .get_thread_replies(cid, root.id.as_bytes(), Some(10), 2, None) + .await + .expect("head page"); + let cur = thread_cursor(page1.last().expect("page 1 non-empty")); + + // Healthy: the full page after r2 is the replica's [ghost]. + let healthy = db + .get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur)) + .await + .expect("healthy replica page"); + assert_eq!( + healthy[0].stored_event.event.content, "replica-only-ghost", + "fixture must route the cursor page to the replica while healthy" + ); + + sqlx::query("DROP TABLE events CASCADE") + .execute(&replica) + .await + .expect("drop replica events"); + + let page = db + .get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur)) + .await + .expect("replica failure must fall back to the writer, not error"); + assert_eq!( + page[0].stored_event.event.content, "r3", + "fallback page must be the writer's answer" + ); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; +} + +/// Mid-request degradation of the held session (Dawn, review of +/// 1b0aa0dfa): when the proved replica transaction dies between the page +/// and an aux follow-up (stand-in: `pg_terminate_backend` on the reader +/// connection, the same tx-fatal shape as a recovery-conflict cancel), +/// [`ReadSession::query_events`] must re-run the query on the writer and +/// permanently degrade the session instead of surfacing the error. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn read_session_degrades_to_writer_when_replica_connection_dies() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "deg_w").await; + let (replica, rname) = create_scratch_db(&admin, "deg_r").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + let m1 = signed_event_at(&author, "m1", base); + let m2 = signed_event_at(&author, "m2", base + 10); + for pool in [&writer, &replica] { + for ev in [&m1, &m2] { + insert_top_level(pool, community, channel, ev).await; + } + } + // Writer-only row proves the degraded aux ran on the writer. + let fresh = signed_event_at(&author, "fresh-writer-only", base + 20); + insert_top_level(&writer, community, channel, &fresh).await; + + let db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + let cid = CommunityId::from_uuid(community); + + let head = db + .get_channel_window(cid, channel, 1, None, None) + .await + .expect("head window"); + let cursor = head.next_cursor.expect("has_more implies next_cursor"); + let (_window, mut session) = db + .get_channel_window_with_session(cid, channel, 10, Some(cursor), None) + .await + .expect("routed cursor window"); + assert!( + session.is_replica(), + "fixture must route this page to the replica" + ); + + // Kill the reader's backend out from under the held transaction. + sqlx::query( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity \ + WHERE datname = $1 AND pid <> pg_backend_pid()", + ) + .bind(&rname) + .execute(&admin) + .await + .expect("terminate replica backends"); + + let mut aux = EventQuery::for_community(cid); + aux.channel_id = Some(channel); + let rows = session + .query_events(&aux) + .await + .expect("session must degrade to the writer, not error"); + assert!( + rows.iter() + .any(|se| se.event.content == "fresh-writer-only"), + "degraded aux must be served by the writer" + ); + assert!( + !session.is_replica(), + "the session must be permanently degraded to the writer" + ); + + drop(session); + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; +} + +/// Snapshot continuity (Wren, review of 17ea2ff6a): the routed request +/// runs inside ONE `REPEATABLE READ, READ ONLY` transaction whose first +/// statement was the heartbeat observation — so a row committed on the +/// replica *after* the proof must be invisible to every follow-up +/// statement in the same request (page, participants, aux). This +/// distinguishes the transaction contract from mere connection reuse: +/// autocommit statements on the same backend advance their snapshot +/// per statement and WOULD see the mid-request row. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn routed_request_holds_one_snapshot_across_page_and_aux() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "snap_w").await; + let (replica, rname) = create_scratch_db(&admin, "snap_r").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + let m1 = signed_event_at(&author, "m1", base); + let m2 = signed_event_at(&author, "m2", base + 10); + for pool in [&writer, &replica] { + for ev in [&m1, &m2] { + insert_top_level(pool, community, channel, ev).await; + } + } + + let db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + let cid = CommunityId::from_uuid(community); + + // Head page on the writer yields the cursor for a replica-routed page. + let head = db + .get_channel_window(cid, channel, 1, None, None) + .await + .expect("head window"); + let cursor = head.next_cursor.expect("has_more implies next_cursor"); + + // Route the cursor page to the replica and HOLD the session. + let (window, mut session) = db + .get_channel_window_with_session(cid, channel, 10, Some(cursor), None) + .await + .expect("routed cursor window"); + assert!( + session.is_replica(), + "fixture must route this page to the replica" + ); + assert_eq!(window.rows.len(), 1, "page after m2 is [m1]"); + + // Mid-request: a new event commits on the replica (stands in for + // replay advancing between the page and the aux closure). + let mid = signed_event_at(&author, "mid-request-commit", base + 5); + insert_top_level(&replica, community, channel, &mid).await; + + // A fresh autocommit statement on ANOTHER session sees it — the row + // is really there (control for the assertion below). + let mut control = EventQuery::for_community(cid); + control.channel_id = Some(channel); + let visible_elsewhere = event::query_events(&replica, &control) + .await + .expect("control query"); + assert!( + visible_elsewhere + .iter() + .any(|se| se.event.content == "mid-request-commit"), + "control: the mid-request row must be committed and visible to a new snapshot" + ); + + // The held request session must NOT see it: its snapshot was + // anchored by the heartbeat observation, before the commit. + let mut aux = EventQuery::for_community(cid); + aux.channel_id = Some(channel); + let in_request = session.query_events(&aux).await.expect("aux query"); + assert!( + !in_request + .iter() + .any(|se| se.event.content == "mid-request-commit"), + "request transaction must hold the proof-time snapshot; a \ + mid-request commit leaking in means the aux ran outside the \ + request transaction (autocommit connection reuse)" + ); + // Rows from the proof-time snapshot are still served. + assert!( + in_request.iter().any(|se| se.event.content == "m1"), + "proof-time rows must remain visible in the request snapshot" + ); + + drop(session); + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; +} + +/// Head gate (Predicate A): with the budget unset, a head fetch reads +/// the writer even over an open fence; with a budget set and a fresh +/// proved entry, the head page is served by the replica session +/// (bounded staleness accepted); with a budget the fence entry exceeds, +/// the head page falls back to the writer. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn head_fetch_routes_by_configured_budget() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "head_w").await; + let (replica, rname) = create_scratch_db(&admin, "head_r").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + let shared = signed_event_at(&author, "shared", base); + for pool in [&writer, &replica] { + insert_top_level(pool, community, channel, &shared).await; + } + // Divergent heads prove which pool served the fetch. + let fresh = signed_event_at(&author, "fresh-writer-only", base + 30); + insert_top_level(&writer, community, channel, &fresh).await; + let marker = signed_event_at(&author, "replica-only-marker", base + 20); + insert_top_level(&replica, community, channel, &marker).await; + + let mut db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + let cid = CommunityId::from_uuid(community); + let head_contents = |w: &thread::ChannelWindow| -> Vec { + w.rows + .iter() + .map(|r| r.stored_event.event.content.clone()) + .collect() + }; + + // Budget unset (rollout default): head → writer, fence open or not. + let head = db + .get_channel_window(cid, channel, 2, None, None) + .await + .expect("head, gate off"); + assert_eq!( + head_contents(&head), + vec!["fresh-writer-only".to_string(), "shared".to_string()], + "head routing must default off" + ); + + // Budget set, entry fresh (just recorded): head → replica. + db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); + let head = db + .get_channel_window(cid, channel, 2, None, None) + .await + .expect("head, gate on"); + assert_eq!( + head_contents(&head), + vec!["replica-only-marker".to_string(), "shared".to_string()], + "a fresh proved entry within budget must serve the head from the replica" + ); + + // Entry older than the budget: head falls back to the writer. + db.fence().close(); + db.fence().force_open_for_tests_at( + chrono::Utc::now(), + std::time::Instant::now() - std::time::Duration::from_secs(10), + ); + let head = db + .get_channel_window(cid, channel, 2, None, None) + .await + .expect("head, entry too old"); + assert_eq!( + head_contents(&head), + vec!["fresh-writer-only".to_string(), "shared".to_string()], + "an over-budget entry must fail the head gate closed" + ); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; +} + +/// End-to-end deploy-default proof for the NEW routed seams: with the +/// budget unset, a covered-eligible query (channel-pinned + `until`) +/// through [`Db::query_events_routed`] is served by the WRITER — the +/// `for_query` gate keeps the covered arm dark (rev 5). With the budget +/// set and a fresh proved entry, the same query routes to the replica. +/// Divergent fixtures prove which pool served each read. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn query_events_routed_defaults_dark_and_routes_covered_when_enabled() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "qer_w").await; + let (replica, rname) = create_scratch_db(&admin, "qer_r").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + let shared = signed_event_at(&author, "shared", base); + for pool in [&writer, &replica] { + insert_top_level(pool, community, channel, &shared).await; + } + let writer_only = signed_event_at(&author, "writer-only", base + 10); + insert_top_level(&writer, community, channel, &writer_only).await; + let replica_only = signed_event_at(&author, "replica-only", base + 20); + insert_top_level(&replica, community, channel, &replica_only).await; + + let mut db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + let cid = CommunityId::from_uuid(community); + + // Covered-eligible shape: channel-pinned with an `until` upper + // bound below the (now) fence wall. + let q = { + let mut q = EventQuery::for_community(cid); + q.channel_id = Some(channel); + q.until = chrono::DateTime::from_timestamp((base + 60) as i64, 0); + q + }; + let contents = |evs: &[StoredEvent]| -> std::collections::BTreeSet { + evs.iter().map(|e| e.event.content.clone()).collect() + }; + + // Deploy default: budget unset ⇒ writer, even though the shape is + // covered-eligible and the fence is open. + let rows = db + .query_events_routed("test_routed", &q) + .await + .expect("routed query, gate off"); + assert!( + contents(&rows).contains("writer-only"), + "budget unset must serve the writer" + ); + assert!( + !contents(&rows).contains("replica-only"), + "budget unset must not reach the replica via the covered arm" + ); + + // Budget set ⇒ the covered arm serves it from the replica. + db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); + let rows = db + .query_events_routed("test_routed", &q) + .await + .expect("routed query, gate on"); + assert!( + contents(&rows).contains("replica-only"), + "budget set + covered-eligible must route to the replica" + ); + assert!(!contents(&rows).contains("writer-only")); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; +} + +/// COUNT is bounded-only (rev 5 deletion-visibility rule): a +/// covered-eligible shape must NOT let a count take the covered arm. +/// With the budget unset the count reads the WRITER even with an open +/// fence; with the budget set and a fresh entry it reads the replica +/// under the bounded arm. Divergent row counts prove the serving pool. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn count_events_routed_is_bounded_only() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "cnt_w").await; + let (replica, rname) = create_scratch_db(&admin, "cnt_r").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + // Writer: 2 rows. Replica: 1 row. + for (i, content) in ["a", "b"].iter().enumerate() { + let ev = signed_event_at(&author, content, base + i as u64); + insert_top_level(&writer, community, channel, &ev).await; + } + let ev = signed_event_at(&author, "c", base); + insert_top_level(&replica, community, channel, &ev).await; + + let mut db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + let cid = CommunityId::from_uuid(community); + + // Covered-eligible shape on purpose: pinned + until. A count must + // ignore that eligibility. + let q = { + let mut q = EventQuery::for_community(cid); + q.channel_id = Some(channel); + q.until = chrono::DateTime::from_timestamp((base + 60) as i64, 0); + q + }; + + // Budget unset ⇒ bounded arm disabled ⇒ writer. + let n = db + .count_events_routed("test_count", &q) + .await + .expect("count, gate off"); + assert_eq!(n, 2, "budget unset must count on the writer"); + + // Budget set + fresh entry ⇒ bounded arm ⇒ replica. + db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); + let n = db + .count_events_routed("test_count", &q) + .await + .expect("count, gate on"); + assert_eq!(n, 1, "budget set must count on the replica (bounded)"); + + // Entry older than the budget ⇒ bounded fails ⇒ writer. Covered + // would still hold here (upper <= wall) — proving count never + // consults it. + db.fence().close(); + db.fence().force_open_for_tests_at( + chrono::Utc::now(), + std::time::Instant::now() - std::time::Duration::from_secs(10), + ); + let n = db + .count_events_routed("test_count", &q) + .await + .expect("count, entry too old"); + assert_eq!( + n, 2, + "an over-budget entry must fail the count closed to the writer, \ + even when the covered arm would admit the shape" + ); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; +} + +/// Routed relay-membership check: budget unset ⇒ writer; budget set + +/// fresh proved entry ⇒ replica (bounded arm); over-budget entry ⇒ +/// writer. Divergent membership rows prove which pool answered. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn is_relay_member_is_bounded_routed_and_fails_closed() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "mem_w").await; + let (replica, rname) = create_scratch_db(&admin, "mem_r").await; + + let community = Uuid::new_v4(); + for pool in [&writer, &replica] { + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community) + .bind(format!("member-routing-{}.example", community.simple())) + .execute(pool) + .await + .expect("insert community"); + } + let cid = CommunityId::from_uuid(community); + let writer_only = "aa".repeat(32); + let replica_only = "bb".repeat(32); + relay_members::add_relay_member(&writer, cid, &writer_only, "member", None) + .await + .expect("seed writer member"); + relay_members::add_relay_member(&replica, cid, &replica_only, "member", None) + .await + .expect("seed replica member"); + + let mut db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + + // Budget unset ⇒ bounded arm disabled ⇒ writer. + assert!( + db.is_relay_member(cid, &writer_only) + .await + .expect("gate off"), + "budget unset must answer from the writer" + ); + assert!(!db.is_relay_member(cid, &replica_only).await.unwrap()); + + // Budget set + fresh entry ⇒ replica. + db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); + assert!( + db.is_relay_member(cid, &replica_only) + .await + .expect("gate on"), + "budget set must answer from the replica" + ); + assert!(!db.is_relay_member(cid, &writer_only).await.unwrap()); + + // Entry older than the budget ⇒ fail closed to the writer. Close + // first so no prior fresh entry can be the one proved (matches the + // count test; today `force_open_for_tests_at` also clears the ring). + db.fence().close(); + db.fence().force_open_for_tests_at( + chrono::Utc::now(), + std::time::Instant::now() - std::time::Duration::from_secs(10), + ); + assert!( + db.is_relay_member(cid, &writer_only) + .await + .expect("entry too old"), + "an over-budget entry must fail closed to the writer" + ); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; +} + +/// Community separation across every routed seam, verified on +/// REPLICA-SERVED reads. +/// +/// The pre-existing feed/event scoping tests prove the shared SQL +/// builders confine rows to one community, but they exercise those +/// builders through the WRITER wrapper. `_on` variants are +/// executor-only refactors, so scoping *should* be identical — this +/// test refuses to take that on faith and re-proves it through the +/// routed executor, on a snapshot the replica actually served. +/// +/// Construction: two communities A and B exist in BOTH databases with +/// the same ids. The replica additionally holds a `replica-only` row in +/// each — divergent fixtures, so any row bearing that content proves +/// the replica (not the writer) served the read. Every assertion +/// requests A and demands B's rows never appear, including B's +/// `replica-only` row, which is the one a leaky predicate would surface. +/// The routed fallback must cost ONE reader acquire budget, even when the +/// Aurora capability cache is cold. +/// +/// Regression test for a stacked-budget bug found at `9fa3c9c0b`: the +/// capability probe used to `acquire()` from the pool itself and return +/// `false` *uncached* on `PoolTimedOut`, so the routed read then spent a +/// SECOND `READER_ACQUIRE_TIMEOUT` inside `begin`. Measured 302ms against +/// a ~150ms documented bound. Boot priming +/// ([`Db::spawn_read_pool_boot_ping`]) hid it only when the boot ping +/// SUCCEEDED — and a reader that is unavailable at boot is exactly the +/// case the bound is specified for, so the two failures are correlated. +/// +/// The fixture reproduces that state deliberately: a size-1 reader whose +/// sole connection is established and then HELD (so every further acquire +/// must time out), with `reader_aurora_identity` asserted cold. It routes +/// through `count_events_routed` rather than calling `proved_reader` +/// directly, because `buzz_db_route_decision` is emitted by `route_read` +/// — a direct call would prove the timing but never emit the label. +/// +/// Timing uses an upper bound of 2x the budget minus a margin: it must +/// fail for two stacked budgets (~300ms) while tolerating scheduler +/// jitter on one (~150ms). Asserting a lower bound too would pin the +/// budget's own value, which `reader_acquire_timeout_is_the_documented_budget` +/// already covers. +#[tokio::test(flavor = "current_thread")] +#[ignore = "requires Postgres"] +async fn routed_fallback_spends_one_acquire_budget_when_aurora_cache_is_cold() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (seed, wname) = create_scratch_db(&admin, "one_budget").await; + seed.close().await; + let base = admin_url().await; + let scratch_url = { + let idx = base.rfind('/').expect("db url has a path segment"); + format!("{}/{}", &base[..idx], wname) + }; + + // `Db::new` so the writer arms the floor guard and the reader is the + // real lazy `connect_read_pool` pool (min_connections=0, 150ms + // acquire timeout). Reader is sized 1 so holding one connection + // saturates it. + let mut db = Db::new(&DbConfig { + database_url: scratch_url.clone(), + read_database_url: Some(scratch_url), + max_connections: 4, + read_max_connections: Some(1), + ..DbConfig::default() + }) + .await + .expect("connect armed Db with size-1 lazy reader"); + db.fence().force_open_for_tests(chrono::Utc::now()); + db.set_replica_read_max_age_for_tests(Some(Duration::from_secs(5))); + + let read_pool = db.read_pool.clone().expect("reader pool configured"); + // Establish and hold the reader's only connection: saturated. + let held = read_pool + .acquire() + .await + .expect("establish the reader's sole connection"); + assert_eq!( + db.read_max_connections, 1, + "reader max must report 1 for this fixture to test saturation" + ); + assert_eq!( + read_pool.size(), + 1, + "the sole reader connection is established and held" + ); + // The bug is only observable with the capability cache cold; if a + // future change primes it here, this fixture would silently stop + // discriminating. + assert!( + db.reader_aurora_identity.get().is_none(), + "Aurora capability must be UNPRIMED (post-boot-ping-failure state)" + ); + + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let query = EventQuery::for_community(CommunityId::from_uuid(Uuid::new_v4())); + + // The recorder is installed thread-locally, so it must stay installed + // across the `.await` — hence the guard form rather than + // `with_local_recorder`, whose closure cannot host an await. The + // `current_thread` flavor keeps the route decision on this thread; on + // a multi-thread runtime the emit could land on a worker where no + // local recorder is installed and the label assertions would vacuously + // see an empty snapshot. + let start = std::time::Instant::now(); + let count = { + let _guard = metrics::set_default_local_recorder(&recorder); + db.count_events_routed("one_budget_probe", &query).await + } + .expect("writer fallback still answers the read"); + let elapsed = start.elapsed(); + + assert_eq!(count, 0, "writer answered on an empty scratch database"); + assert!( + elapsed < Duration::from_millis(250), + "routed fallback must spend ONE {}ms acquire budget, not two; took {}ms", + Db::READER_ACQUIRE_TIMEOUT.as_millis(), + elapsed.as_millis() + ); + + let reasons: std::collections::HashMap<(String, String), u64> = snapshotter + .snapshot() + .into_vec() + .into_iter() + .filter(|(key, ..)| key.key().name() == "buzz_db_route_decision") + .map(|(key, _, _, value)| { + let metrics_util::debugging::DebugValue::Counter(n) = value else { + panic!("buzz_db_route_decision must be a counter"); + }; + let labels: Vec<_> = key.key().labels().collect(); + let get = |name: &str| { + labels + .iter() + .find(|l| l.key() == name) + .map(|l| l.value().to_owned()) + .unwrap_or_default() + }; + ((get("decision"), get("reason")), n) + }) + .collect(); + + assert_eq!( + reasons.get(&("writer".to_owned(), "reader_acquire_timeout".to_owned())), + Some(&1), + "saturated reader must fall back as writer/reader_acquire_timeout; got {reasons:?}" + ); + // `reader_validation_error` would mean we misclassified a timeout as a + // broken reader, and `pool_busy` is the retired name — neither may + // appear in ANY emitted label. + assert!( + !reasons + .keys() + .any(|(_, reason)| reason == "reader_validation_error" || reason == "pool_busy"), + "no reader_validation_error or retired pool_busy label may be emitted; got {reasons:?}" + ); + + drop(held); + drop_scratch_db(&admin, db.pool.clone(), &wname).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn routed_reads_are_confined_to_the_requested_community() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "sep_w").await; + let (replica, rname) = create_scratch_db(&admin, "sep_r").await; + + let author = nostr::Keys::generate(); + let (comm_a, chan_a) = (Uuid::new_v4(), Uuid::new_v4()); + let (comm_b, chan_b) = (Uuid::new_v4(), Uuid::new_v4()); + for pool in [&writer, &replica] { + seed_community_channel(pool, comm_a, chan_a, &author).await; + seed_community_channel(pool, comm_b, chan_b, &author).await; + } + + // A p-tag mention is what makes a row eligible for the mentions and + // needs-action feeds. Kind 9 satisfies mentions + activity; + // needs-action admits only approval/reminder kinds, so each + // community also gets a kind-46010 row. + let mentioned = nostr::Keys::generate(); + let mentioned_hex = mentioned.public_key().to_hex(); + let mentioned_bytes = mentioned.public_key().to_bytes(); + let tagged_kind = |kind: u16, content: &str, secs: u64| { + nostr::EventBuilder::new(nostr::Kind::Custom(kind), content) + .tags([nostr::Tag::parse(["p", mentioned_hex.as_str()]).expect("p tag")]) + .custom_created_at(nostr::Timestamp::from(secs)) + .sign_with_keys(&author) + .expect("sign event") + }; + let tagged = |content: &str, secs: u64| tagged_kind(9, content, secs); + + let base = 1_700_000_000u64; + // Shared rows (both DBs) + replica-only rows (divergence) per community. + let a_shared = tagged("a-shared", base); + let b_shared = tagged("b-shared", base + 1); + for pool in [&writer, &replica] { + insert_top_level(pool, comm_a, chan_a, &a_shared).await; + insert_mentions( + pool, + CommunityId::from_uuid(comm_a), + &a_shared, + Some(chan_a), + ) + .await + .expect("mentions a-shared"); + insert_top_level(pool, comm_b, chan_b, &b_shared).await; + insert_mentions( + pool, + CommunityId::from_uuid(comm_b), + &b_shared, + Some(chan_b), + ) + .await + .expect("mentions b-shared"); + } + let a_replica_only = tagged("a-replica-only", base + 10); + let b_replica_only = tagged("b-replica-only", base + 11); + insert_top_level(&replica, comm_a, chan_a, &a_replica_only).await; + insert_mentions( + &replica, + CommunityId::from_uuid(comm_a), + &a_replica_only, + Some(chan_a), + ) + .await + .expect("mentions a-replica-only"); + insert_top_level(&replica, comm_b, chan_b, &b_replica_only).await; + insert_mentions( + &replica, + CommunityId::from_uuid(comm_b), + &b_replica_only, + Some(chan_b), + ) + .await + .expect("mentions b-replica-only"); + + // Needs-action fixtures: approval kind, replica-only in BOTH + // communities, so the assertion below is replica-served on A and + // must still not see B's. + let a_approval = tagged_kind(46010, "a-approval-replica-only", base + 20); + let b_approval = tagged_kind(46010, "b-approval-replica-only", base + 21); + insert_top_level(&replica, comm_a, chan_a, &a_approval).await; + insert_mentions( + &replica, + CommunityId::from_uuid(comm_a), + &a_approval, + Some(chan_a), + ) + .await + .expect("mentions a-approval"); + insert_top_level(&replica, comm_b, chan_b, &b_approval).await; + insert_mentions( + &replica, + CommunityId::from_uuid(comm_b), + &b_approval, + Some(chan_b), + ) + .await + .expect("mentions b-approval"); + + let mut db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); + let cid_a = CommunityId::from_uuid(comm_a); + + let contents = |evs: &[StoredEvent]| -> std::collections::BTreeSet { + evs.iter().map(|e| e.event.content.clone()).collect() + }; + // Every routed seam must (a) have been served by the replica — + // proven by a divergent row absent from the writer — and (b) contain + // no row belonging to community B. All B fixtures are named `b-*`, + // so the leak check is a single prefix scan. + let assert_a_only = |rows: &[StoredEvent], marker: &str, seam: &str| { + let got = contents(rows); + assert!( + got.contains(marker), + "{seam}: must be replica-served (divergent row `{marker}` absent from writer); got {got:?}" + ); + assert!( + !got.iter().any(|c| c.starts_with("b-")), + "{seam}: community B rows leaked into a community A read; got {got:?}" + ); + }; + + // 1. Generic query — covered arm (channel-pinned + `until`). + let mut q = EventQuery::for_community(cid_a); + q.channel_id = Some(chan_a); + q.until = chrono::DateTime::from_timestamp((base + 60) as i64, 0); + let rows = db + .query_events_routed("sep_query", &q) + .await + .expect("routed query"); + assert_a_only(&rows, "a-replica-only", "query_events_routed"); + + // 2. Generic query — bounded arm (no channel pin at all, so a + // missing community predicate could not be masked by the pin). + let unpinned = EventQuery::for_community(cid_a); + let rows = db + .query_events_routed_bounded("sep_query_bounded", &unpinned) + .await + .expect("routed bounded query"); + assert_a_only(&rows, "a-replica-only", "query_events_routed_bounded"); + + // 3. COUNT — bounded-only. Community A holds 3 rows on the replica + // (shared + replica-only + approval) but only 1 on the writer, + // and 3 more exist in community B. Exactly 3 proves the read was + // both replica-served and community-confined. + let count = db + .count_events_routed("sep_count", &unpinned) + .await + .expect("routed count"); + assert_eq!( + count, 3, + "count must see A's three replica rows only — not B's, not the writer's one" + ); + + // 4. By-ID hydration — ids carry no channel pin, and B's ids are + // requested alongside A's. Only A's may hydrate. + let ids: Vec<&[u8]> = vec![ + a_shared.id.as_bytes(), + a_replica_only.id.as_bytes(), + b_shared.id.as_bytes(), + b_replica_only.id.as_bytes(), + ]; + let rows = db + .get_events_by_ids_routed("sep_by_ids", cid_a, &ids) + .await + .expect("routed by-ids"); + assert_a_only(&rows, "a-replica-only", "get_events_by_ids_routed"); + + // 5-7. All three feed builders, each given BOTH channels as + // accessible — so only the community predicate can exclude B. + let both = [chan_a, chan_b]; + let rows = db + .query_feed_mentions_routed("sep_feed", cid_a, &mentioned_bytes, &both, None, 50) + .await + .expect("routed mentions"); + assert_a_only(&rows, "a-replica-only", "query_feed_mentions_routed"); + + let rows = db + .query_feed_needs_action_routed("sep_feed", cid_a, &mentioned_bytes, &both, None, 50) + .await + .expect("routed needs action"); + assert_a_only( + &rows, + "a-approval-replica-only", + "query_feed_needs_action_routed", + ); + + let rows = db + .query_feed_activity_routed("sep_feed", cid_a, &both, None, 50) + .await + .expect("routed activity"); + assert_a_only(&rows, "a-replica-only", "query_feed_activity_routed"); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; +} + +/// D4: a LAZY reader pool (connect_lazy, min_connections=0, never yet +/// used) must still let [`Db::spawn_fence_probe`] verify the writer's +/// floor guard and spawn — reader-down or reader-idle at boot must not +/// disable fence probing. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn lazy_reader_pool_still_spawns_fence_probe() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (seed, wname) = create_scratch_db(&admin, "lazy_w").await; + seed.close().await; + + let writer_url = { + let base = admin_url().await; + let idx = base.rfind('/').expect("db url has a path segment"); + format!("{}/{}", &base[..idx], wname) + }; + // `Db::new` (not `from_pools`) so the WRITER pool arms the + // `buzz.created_at_floor` GUC — `spawn_fence_probe` verifies the + // floor guard on a writer connection, and `create_scratch_db`'s + // plain `PgPool::connect` never arms it. The reader is still the + // lazy `connect_read_pool` pool this test is about. + let db = Db::new(&DbConfig { + database_url: writer_url.clone(), + read_database_url: Some(writer_url), + max_connections: 2, + ..DbConfig::default() + }) + .await + .expect("connect armed Db with lazy reader"); + + let spawned = db + .spawn_fence_probe() + .await + .expect("floor-guard verification must pass on the migrated writer"); + assert!(spawned, "a configured (lazy) reader must spawn the probe"); + + drop_scratch_db(&admin, db.pool.clone(), &wname).await; +} + +/// Thread replies: head fetch reads the writer; a FULL cursor page is +/// served by the replica; an UNDER-limit cursor page (candidate terminal +/// page) is re-run on the writer so a lagged replica can never truncate +/// the tail into a false EOF. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn thread_replies_cursor_pages_route_to_replica_with_writer_terminal_verification() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "routing_tw").await; + let (replica, rname) = create_scratch_db(&admin, "routing_tr").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + let root = signed_event_at(&author, "root", base); + for pool in [&writer, &replica] { + insert_top_level(pool, community, channel, &root).await; + } + + // Writer holds replies r1..r5; the lagged replica only has r1..r3. + let replies: Vec = (1..=5) + .map(|i| signed_event_at(&author, &format!("r{i}"), base + 10 * i as u64)) + .collect(); + for reply in &replies { + insert_thread_reply(&writer, community, channel, &root, reply).await; + } + for reply in &replies[..3] { + insert_thread_reply(&replica, community, channel, &root, reply).await; + } + + let db = Db::from_pools(writer.clone(), replica.clone()); + // Open the fence through "now" — fixture history is far in the past. + db.fence().force_open_for_tests(chrono::Utc::now()); + let cid = CommunityId::from_uuid(community); + + // Page 1 (no cursor) → writer. + let page1 = db + .get_thread_replies(cid, root.id.as_bytes(), Some(10), 2, None) + .await + .expect("page 1"); + let contents: Vec<&str> = page1 + .iter() + .map(|r| r.stored_event.event.content.as_str()) + .collect(); + assert_eq!(contents, vec!["r1", "r2"], "head page from writer"); + + // Page 2: replica serves a FULL page (r3 exists there) — but wait: + // replica has r1..r3, page after r2 with limit 2 returns only [r3] + // (under limit) → terminal-verification re-runs on the writer, which + // returns [r3, r4]. A lag-truncated EOF must never surface. + let cur2 = thread_cursor(page1.last().expect("page 1 non-empty")); + let page2 = db + .get_thread_replies(cid, root.id.as_bytes(), Some(10), 2, Some(&cur2)) + .await + .expect("page 2"); + let contents: Vec<&str> = page2 + .iter() + .map(|r| r.stored_event.event.content.as_str()) + .collect(); + assert_eq!( + contents, + vec!["r3", "r4"], + "under-limit replica page must be re-verified on the writer" + ); + + // Full-page replica serve: with limit 1, the page after r2 is [r3] — + // exactly `limit` rows, so the replica result stands. Prove it came + // from the replica with a replica-only divergent reply. + let ghost = signed_event_at(&author, "replica-only-ghost", base + 25); + insert_thread_reply(&replica, community, channel, &root, &ghost).await; + let page_replica = db + .get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur2)) + .await + .expect("full replica page"); + let contents: Vec<&str> = page_replica + .iter() + .map(|r| r.stored_event.event.content.as_str()) + .collect(); + assert_eq!( + contents, + vec!["replica-only-ghost"], + "a full cursor page must be served by the replica" + ); + + // Same query with no replica configured reads the writer and cannot + // see the ghost. + let db_writer_only = Db::from_pool(writer.clone()); + let page_writer = db_writer_only + .get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur2)) + .await + .expect("writer-only page"); + let contents: Vec<&str> = page_writer + .iter() + .map(|r| r.stored_event.event.content.as_str()) + .collect(); + assert_eq!(contents, vec!["r3"], "unset replica falls back to writer"); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; +} + +/// Channel DESC scrollback, out-of-order commit adversary: the replica is +/// missing a MIDDLE row (`m2`) because a transaction with an older +/// client-signed `created_at` committed late and has not replayed yet. +/// The replica's cursor page would be `[m1]` — silently skipping `m2` +/// forever, since the next cursor advances past it. The fence must route +/// any cursor above it to the writer. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn channel_cursor_above_fence_stays_on_writer_preventing_middle_hole() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "fence_cw").await; + let (replica, rname) = create_scratch_db(&admin, "fence_cr").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + let m1 = signed_event_at(&author, "m1", base); + let m2 = signed_event_at(&author, "m2-late-commit", base + 10); + let m3 = signed_event_at(&author, "m3", base + 20); + let m4 = signed_event_at(&author, "m4", base + 30); + for ev in [&m1, &m2, &m3, &m4] { + insert_top_level(&writer, community, channel, ev).await; + } + // Replica replayed everything EXCEPT the late-committed m2. + for ev in [&m1, &m3, &m4] { + insert_top_level(&replica, community, channel, ev).await; + } + + let db = Db::from_pools(writer.clone(), replica.clone()); + let cid = CommunityId::from_uuid(community); + + // Head page (writer): [m4, m3]; cursor lands on m3 (base+20). + let head = db + .get_channel_window(cid, channel, 2, None, None) + .await + .expect("head window"); + let cursor = head.next_cursor.expect("has_more implies next_cursor"); + + // Fence closed → cursor page must come from the writer: m2 present. + let contents = |w: &thread::ChannelWindow| -> Vec { + w.rows + .iter() + .map(|r| r.stored_event.event.content.clone()) + .collect() + }; + let page_closed = db + .get_channel_window(cid, channel, 10, Some(cursor.clone()), None) + .await + .expect("cursor page, fence closed"); + assert_eq!( + contents(&page_closed), + vec!["m2-late-commit".to_string(), "m1".to_string()], + "fence closed: cursor pages route to the writer" + ); + + // Fence open but BELOW the cursor timestamp (covers base+5 only): + // the cursor (base+20) is not covered → writer again. + db.fence() + .force_open_for_tests(chrono::DateTime::from_timestamp(base as i64 + 5, 0).expect("ts")); + let page_below = db + .get_channel_window(cid, channel, 10, Some(cursor.clone()), None) + .await + .expect("cursor page, fence below cursor"); + assert_eq!( + contents(&page_below), + vec!["m2-late-commit".to_string(), "m1".to_string()], + "cursor above the fence must stay on the writer" + ); + + // Counterfactual pinning the hazard: were the fence (wrongly) open + // through now, the replica would serve the page WITHOUT m2 — the + // permanent-skip hole this fence exists to prevent. + db.fence().force_open_for_tests(chrono::Utc::now()); + let page_hazard = db + .get_channel_window(cid, channel, 10, Some(cursor), None) + .await + .expect("cursor page, fence wrongly open"); + assert_eq!( + contents(&page_hazard), + vec!["m1".to_string()], + "fixture models the inversion: an over-open fence would skip m2" + ); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; +} + +/// Thread ASC pagination, out-of-order commit adversary: the replica +/// holds a FULL page whose newest row (`r4`) has a later key than a +/// not-yet-replayed row (`r3`). The old under-limit check alone would +/// serve `[r4]` and the client cursor would advance past `r3` forever. +/// The fence rule (full AND tail ≤ fence) must send that page to the +/// writer instead. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn thread_full_replica_page_above_fence_is_reverified_on_writer() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "fence_tw").await; + let (replica, rname) = create_scratch_db(&admin, "fence_tr").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + let root = signed_event_at(&author, "root", base); + for pool in [&writer, &replica] { + insert_top_level(pool, community, channel, &root).await; + } + let replies: Vec = (1..=4) + .map(|i| signed_event_at(&author, &format!("r{i}"), base + 10 * i as u64)) + .collect(); + for reply in &replies { + insert_thread_reply(&writer, community, channel, &root, reply).await; + } + // Replica replayed r1, r2, r4 — the late-committed r3 is missing. + for reply in [&replies[0], &replies[1], &replies[3]] { + insert_thread_reply(&replica, community, channel, &root, reply).await; + } + + let db = Db::from_pools(writer.clone(), replica.clone()); + let cid = CommunityId::from_uuid(community); + + // Fence covers r2 (base+20) but not r3/r4. + db.fence() + .force_open_for_tests(chrono::DateTime::from_timestamp(base as i64 + 20, 0).expect("ts")); + + // Page after r2 with limit 1: the replica would return the FULL page + // [r4] — but its tail is above the fence, so the writer re-runs it + // and returns [r3]. No skip. + let page1 = db + .get_thread_replies(cid, root.id.as_bytes(), Some(10), 2, None) + .await + .expect("head page"); + let cur = thread_cursor(page1.last().expect("head page non-empty")); + let page = db + .get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur)) + .await + .expect("cursor page"); + let contents: Vec<&str> = page + .iter() + .map(|r| r.stored_event.event.content.as_str()) + .collect(); + assert_eq!( + contents, + vec!["r3"], + "a full replica page above the fence must be re-run on the writer" + ); + + // Counterfactual: an over-open fence would serve the replica's [r4], + // skipping r3 permanently. + db.fence().force_open_for_tests(chrono::Utc::now()); + let hazard = db + .get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur)) + .await + .expect("hazard page"); + let contents: Vec<&str> = hazard + .iter() + .map(|r| r.stored_event.event.content.as_str()) + .collect(); + assert_eq!( + contents, + vec!["r4"], + "fixture models the inversion: an over-open fence would skip r3" + ); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; +} + +/// Commit-time floor guard (migration 0021), exact held-transaction +/// adversary: a channel-bearing row whose `created_at` is older than the +/// floor at COMMIT time must abort the transaction — the guard runs +/// inside commit processing with `clock_timestamp()`, so holding the +/// transaction open cannot outrun it. channel_id-NULL rows are +/// structurally exempt, and sessions without the GUC are unaffected. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn created_at_floor_guard_aborts_old_channel_rows_at_commit() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (pool, name) = create_scratch_db(&admin, "floor_guard").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&pool, community, channel, &author).await; + + let insert_raw = |ev: nostr::Event, channel_id: Option| { + let pool = pool.clone(); + async move { + let mut tx = pool.begin().await.expect("begin"); + // Arm the guard for this transaction only (the relay's + // writer pool arms it per connection; tests are explicit). + sqlx::query("SELECT set_config('buzz.created_at_floor', $1, true)") + .bind(crate::replica_fence::CREATED_AT_FLOOR_SECS.to_string()) + .execute(&mut *tx) + .await + .expect("arm guard"); + sqlx::query( + "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, \ + content, sig, received_at, channel_id) \ + VALUES ($1, $2, $3, to_timestamp($4), 9, '[]', $5, $6, NOW(), $7)", + ) + .bind(community) + .bind(ev.id.as_bytes().as_slice()) + .bind(ev.pubkey.to_bytes().as_slice()) + .bind(ev.created_at.as_secs() as f64) + .bind(&ev.content) + .bind(ev.sig.serialize().as_slice()) + .bind(channel_id) + .execute(&mut *tx) + .await + .expect("insert inside tx (guard is deferred to commit)"); + // Hold the transaction "open" past the insert, then commit — + // the deferred guard must still see the stale created_at. + sqlx::query("SELECT pg_sleep(0.05)") + .execute(&mut *tx) + .await + .expect("hold tx"); + tx.commit().await + } + }; + + let now_secs = chrono::Utc::now().timestamp() as u64; + let floor = crate::replica_fence::CREATED_AT_FLOOR_SECS as u64; + + // Old channel-bearing row → COMMIT aborts with check_violation. + let old = signed_event_at(&author, "old-held-tx", now_secs - floor - 60); + let err = insert_raw(old, Some(channel)) + .await + .expect_err("below-floor channel row must abort at COMMIT"); + let code = match &err { + sqlx::Error::Database(db_err) => db_err.code().map(|c| c.to_string()), + other => panic!("expected database error, got {other:?}"), + }; + assert_eq!( + code.as_deref(), + Some("23514"), + "guard raises check_violation" + ); + + // Fresh channel-bearing row → commits. + let fresh = signed_event_at(&author, "fresh", now_secs); + insert_raw(fresh, Some(channel)) + .await + .expect("fresh row commits under the armed guard"); + + // Old row WITHOUT a channel (push lease / profile shapes) → + // structurally exempt, commits. + let old_global = signed_event_at(&author, "old-global", now_secs - floor - 60); + insert_raw(old_global, None) + .await + .expect("channel_id-NULL rows are exempt from the floor"); + + // Unarmed session (no GUC) → guard inert; backfills stay possible + // (and must hold the fence closed, per the migration header). + let old_backfill = signed_event_at(&author, "old-backfill", now_secs - floor - 60); + insert_top_level(&pool, community, channel, &old_backfill).await; + + drop_scratch_db(&admin, pool, &name).await; +} + +#[test] +fn writer_pool_safety_hook_is_single_and_composed() { + let source = include_str!("mod.rs"); + let connect_pool = source + .split("async fn connect_pool") + .nth(1) + .and_then(|tail| tail.split("const READER_ACQUIRE_TIMEOUT").next()) + .expect("connect_pool source block"); + assert_eq!( + connect_pool.matches(".after_connect(").count(), + 1, + "SQLx replaces after_connect hooks; writer safety must use exactly one" + ); + assert!(connect_pool.contains("buzz.created_at_floor")); + assert!(connect_pool.contains("SHOW transaction_isolation")); + assert!(!connect_pool.contains("arm_floor_guard")); + assert!(!connect_pool.contains("_arm_floor_guard")); + assert!(!connect_pool.contains("allow(unused_variables)")); + + let reader_doc = source + .split("fn connect_read_pool") + .next() + .and_then(|prefix| prefix.rsplit("/// Connect the read-replica").next()) + .expect("reader pool documentation"); + assert!(reader_doc.contains("replica sessions are")); + assert!(reader_doc.contains("read-only")); + assert!(!reader_doc.contains("Db::connect_pool")); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn writer_pool_rejects_non_read_committed_database_default() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (seed_pool, name) = create_scratch_db(&admin, "writer_isolation").await; + sqlx::query(sqlx::AssertSqlSafe(format!( + "ALTER DATABASE {name} SET default_transaction_isolation = 'repeatable read'" + ))) + .execute(&admin) + .await + .expect("set unsafe database default"); + seed_pool.close().await; + + let base = admin_url().await; + let idx = base.rfind('/').expect("db url has a path segment"); + let scratch_url = format!("{}/{}", &base[..idx], name); + let error = Db::new(&DbConfig { + database_url: scratch_url, + max_connections: 1, + min_connections: 1, + acquire_timeout_secs: 1, + ..DbConfig::default() + }) + .await + .expect_err("writer pool must reject pinned-snapshot database defaults"); + assert!( + error.to_string().contains("requires READ COMMITTED") + || error.to_string().contains("pool timed out"), + "unexpected isolation rejection: {error}" + ); + + sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP DATABASE {name} WITH (FORCE)" + ))) + .execute(&admin) + .await + .expect("drop isolation test database"); +} + +/// The armed writer pool (`Db::new`) must enforce the floor end-to-end +/// through the public insert APIs, and the session GUC must be verifiably +/// set on pooled connections. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn armed_pool_rejects_old_channel_inserts_through_public_api() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (seed_pool, name) = create_scratch_db(&admin, "floor_pool").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&seed_pool, community, channel, &author).await; + + // Connect a Db the production way: after_connect arms the guard. + let base = admin_url().await; + let idx = base.rfind('/').expect("db url has a path segment"); + let scratch_url = format!("{}/{}", &base[..idx], name); + let db = Db::new(&DbConfig { + database_url: scratch_url, + max_connections: 2, + ..DbConfig::default() + }) + .await + .expect("connect armed Db"); + let cid = CommunityId::from_uuid(community); + + // Perci nit: assert the effective session value, not the intent. + let effective: String = sqlx::query_scalar("SHOW buzz.created_at_floor") + .fetch_one(&db.pool) + .await + .expect("SHOW guard GUC"); + assert_eq!( + effective, + crate::replica_fence::CREATED_AT_FLOOR_SECS.to_string(), + "writer pool must arm the floor guard on every connection" + ); + let isolation: String = sqlx::query_scalar("SHOW transaction_isolation") + .fetch_one(&db.pool) + .await + .expect("SHOW writer isolation"); + assert_eq!( + isolation, "read committed", + "the same writer after_connect hook must enforce the isolation premise" + ); + + let now_secs = chrono::Utc::now().timestamp() as u64; + let floor = crate::replica_fence::CREATED_AT_FLOOR_SECS as u64; + + // insert_event (single INSERT, autocommit): old channel row rejected. + let old = signed_event_at(&author, "old-direct", now_secs - floor - 60); + let err = event::insert_event(&db.pool, cid, &old, Some(channel)) + .await + .expect_err("armed pool must reject below-floor channel inserts"); + assert!( + err.to_string().contains("below the replica-fence floor"), + "unexpected error: {err}" + ); + + // insert_event_with_thread_metadata (multi-statement tx): same. + let old2 = signed_event_at(&author, "old-thread-meta", now_secs - floor - 90); + let ts = + chrono::DateTime::from_timestamp(old2.created_at.as_secs() as i64, 0).expect("valid ts"); + let err = event::insert_event_with_thread_metadata( + &db.pool, + cid, + &old2, + Some(channel), + Some(event::ThreadMetadataParams { + event_id: old2.id.as_bytes(), + event_created_at: ts, + channel_id: channel, + parent_event_id: None, + parent_event_created_at: None, + root_event_id: None, + root_event_created_at: None, + depth: 0, + broadcast: true, + }), + ) + .await + .expect_err("armed pool must reject below-floor thread-metadata inserts"); + assert!( + err.to_string().contains("below the replica-fence floor"), + "unexpected error: {err}" + ); + + // Fresh events pass through both APIs. + let fresh = signed_event_at(&author, "fresh-direct", now_secs); + event::insert_event(&db.pool, cid, &fresh, Some(channel)) + .await + .expect("fresh insert passes the armed guard"); + + drop_scratch_db(&admin, seed_pool, &name).await; + // db pool still holds connections to the dropped DB; close it. + db.pool.close().await; +} + +/// `spawn_fence_probe` must verify the floor guard before letting the +/// probe run — catalog shape AND observed behavior — and refuse on +/// sabotage. This is the production gate for a relay running with +/// `BUZZ_AUTO_MIGRATE` off: an armed GUC with no enforcing trigger must +/// never yield an open fence. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn fence_probe_refuses_to_start_without_verified_floor_guard() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (seed_pool, wname) = create_scratch_db(&admin, "fence_gate_w").await; + let (replica_pool, rname) = create_scratch_db(&admin, "fence_gate_r").await; + seed_pool.close().await; + replica_pool.close().await; + + let base = admin_url().await; + let idx = base.rfind('/').expect("db url has a path segment"); + let writer_url = format!("{}/{}", &base[..idx], wname); + let replica_url = format!("{}/{}", &base[..idx], rname); + + // Healthy schema: verification passes, probe starts. A SEPARATE Db + // instance, because its background probe legitimately opens its own + // fence (the heartbeat probe is writer-side only) — the refusal + // assertions below must run against a fence whose spawns were all + // refused. + let db_healthy = Db::new(&DbConfig { + database_url: writer_url.clone(), + read_database_url: Some(replica_url.clone()), + max_connections: 2, + ..DbConfig::default() + }) + .await + .expect("connect armed Db with replica"); + assert!( + db_healthy + .spawn_fence_probe() + .await + .expect("verification passes"), + "probe must start on a verified schema" + ); + + let db = Db::new(&DbConfig { + database_url: writer_url, + read_database_url: Some(replica_url), + max_connections: 2, + ..DbConfig::default() + }) + .await + .expect("connect armed Db with replica"); + + // Sabotage A: catalog-shaped no-op — same trigger, gutted function + // body. Catalog check alone would pass; behavior check must refuse. + sqlx::query( + "CREATE OR REPLACE FUNCTION events_created_at_floor_guard() RETURNS trigger \ + LANGUAGE plpgsql AS $$ BEGIN RETURN NULL; END $$", + ) + .execute(&db.pool) + .await + .expect("gut the guard function"); + let err = db + .spawn_fence_probe() + .await + .expect_err("inert guard body must refuse the probe"); + assert!( + err.to_string().contains("floor guard is inert"), + "unexpected error: {err}" + ); + + // Sabotage B: trigger dropped entirely (the BUZZ_AUTO_MIGRATE=off / + // 0021-unapplied shape). Catalog check must refuse. + sqlx::query("DROP TRIGGER events_created_at_floor ON events") + .execute(&db.pool) + .await + .expect("drop the guard trigger"); + let err = db + .spawn_fence_probe() + .await + .expect_err("missing trigger must refuse the probe"); + assert!( + err.to_string().contains("missing or mis-shaped"), + "unexpected error: {err}" + ); + + // In both refusal states the fence never opened. + assert!( + db.fence().verified_through().is_none(), + "fence must remain closed when verification refuses the probe" + ); + + db_healthy.pool.close().await; + if let Some(rp) = &db_healthy.read_pool { + rp.close().await; + } + db.pool.close().await; + if let Some(rp) = &db.read_pool { + rp.close().await; + } + let _ = sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP DATABASE IF EXISTS {wname} WITH (FORCE)" + ))) + .execute(&admin) + .await; + let _ = sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP DATABASE IF EXISTS {rname} WITH (FORCE)" + ))) + .execute(&admin) + .await; +} + +/// The `UPDATE OF` arm of the floor guard (Perci's second structural +/// hole): an old row legitimately admitted with `channel_id` NULL must +/// not be movable into keyset windows, and a channel row's `created_at` +/// must not be movable below the fence — through raw SQL, at COMMIT. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn floor_guard_blocks_updates_that_move_rows_below_the_fence() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (pool, name) = create_scratch_db(&admin, "floor_upd").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&pool, community, channel, &author).await; + + let now_secs = chrono::Utc::now().timestamp() as u64; + let floor = crate::replica_fence::CREATED_AT_FLOOR_SECS as u64; + + // Seed via unarmed session: one old channel-NULL row, one fresh + // channel row. + let old_null = signed_event_at(&author, "old-null", now_secs - floor - 120); + insert_top_level(&pool, community, channel, &old_null).await; + sqlx::query("UPDATE events SET channel_id = NULL WHERE community_id = $1 AND id = $2") + .bind(community) + .bind(old_null.id.as_bytes().as_slice()) + .execute(&pool) + .await + .expect("detach channel (unarmed seed)"); + let fresh = signed_event_at(&author, "fresh-row", now_secs); + insert_top_level(&pool, community, channel, &fresh).await; + + // Armed transaction, deferred to COMMIT (the production shape). + let run_armed_update = |sql: &'static str, id: Vec, age: Option| { + let pool = pool.clone(); + async move { + let mut tx = pool.begin().await.expect("begin"); + sqlx::query("SELECT set_config('buzz.created_at_floor', $1, true)") + .bind(crate::replica_fence::CREATED_AT_FLOOR_SECS.to_string()) + .execute(&mut *tx) + .await + .expect("arm guard"); + let q = sqlx::query(sql).bind(community).bind(id); + let q = match age { + Some(a) => q.bind(a as f64), + None => q, + }; + q.execute(&mut *tx) + .await + .expect("update inside tx (deferred)"); + tx.commit().await + } + }; + + // channel-NULL → channel-bearing on an old row: COMMIT must abort. + let err = run_armed_update( + "UPDATE events SET channel_id = community_id WHERE community_id = $1 AND id = $2", + old_null.id.as_bytes().to_vec(), + None, + ) + .await + .expect_err("moving an old channel-NULL row into a channel must abort at COMMIT"); + assert!( + matches!(&err, sqlx::Error::Database(e) if e.code().as_deref() == Some("23514")), + "unexpected error: {err}" + ); + + // created_at rewrite below the floor on a channel row: COMMIT must abort. + let err = run_armed_update( + "UPDATE events SET created_at = clock_timestamp() - make_interval(secs => $3::double precision) \ + WHERE community_id = $1 AND id = $2", + fresh.id.as_bytes().to_vec(), + Some(floor + 120), + ) + .await + .expect_err("rewriting created_at below the floor must abort at COMMIT"); + assert!( + matches!(&err, sqlx::Error::Database(e) if e.code().as_deref() == Some("23514")), + "unexpected error: {err}" + ); + + drop_scratch_db(&admin, pool, &name).await; +} diff --git a/crates/buzz-db/src/admin_moderation.rs b/crates/buzz-db/src/store/admin_moderation.rs similarity index 94% rename from crates/buzz-db/src/admin_moderation.rs rename to crates/buzz-db/src/store/admin_moderation.rs index 3dc8bd94c8..f38231787b 100644 --- a/crates/buzz-db/src/admin_moderation.rs +++ b/crates/buzz-db/src/store/admin_moderation.rs @@ -4,12 +4,14 @@ //! [`CommunityId`](buzz_core::CommunityId). Keep ordinary moderation reads in //! [`crate::moderation`] tenant-fenced. +use buzz_datastore_tracing::datastore_span; use chrono::{DateTime, Utc}; use serde::Serialize; use sqlx::{PgPool, Row as _}; use uuid::Uuid; use crate::error::Result; +use crate::Db; /// Maximum rows accepted by one admin query. pub const MAX_PAGE_SIZE: i64 = 200; @@ -404,11 +406,59 @@ fn row_to_feedback(row: sqlx::postgres::PgRow) -> Result { }) } +impl Db { + /// List reports for the deployment-global read-only admin plane. + #[allow(clippy::too_many_arguments)] + #[datastore_span(name = "admin_list_reports", system = "postgresql")] + pub async fn admin_list_reports( + &self, + community_id: Option, + status: Option<&str>, + report_type: Option<&str>, + target_kind: Option<&str>, + after: Option>, + before: Option>, + cursor: Option<(DateTime, Uuid)>, + limit: i64, + ) -> Result> { + list_reports( + &self.pool, + community_id, + status, + report_type, + target_kind, + after, + before, + cursor, + limit, + ) + .await + } + + /// Fetch one report for the deployment-global read-only admin plane. + #[datastore_span(name = "admin_get_report", system = "postgresql")] + pub async fn admin_get_report(&self, id: Uuid) -> Result> { + get_report(&self.pool, id).await + } + + /// List feedback for the deployment-global read-only admin plane. + #[datastore_span(name = "admin_list_feedback", system = "postgresql")] + pub async fn admin_list_feedback(&self, limit: i64) -> Result> { + list_feedback(&self.pool, limit).await + } + + /// Fetch one feedback submission for the deployment-global admin plane. + #[datastore_span(name = "admin_get_feedback", system = "postgresql")] + pub async fn admin_get_feedback(&self, id: Uuid) -> Result> { + get_feedback(&self.pool, id).await + } +} + #[cfg(test)] mod tests { use super::*; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 async fn setup_pool() -> PgPool { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") diff --git a/crates/buzz-db/src/store/allowlist.rs b/crates/buzz-db/src/store/allowlist.rs new file mode 100644 index 0000000000..6b213d5cce --- /dev/null +++ b/crates/buzz-db/src/store/allowlist.rs @@ -0,0 +1,209 @@ +//! Community-scoped authentication allowlist persistence. +//! +//! This store is distinct from NIP-43 relay membership. Membership backfill +//! orchestration remains with the relay-membership invariant owner. + +use buzz_core::CommunityId; +use buzz_datastore_tracing::datastore_span; +use chrono::{DateTime, Utc}; +use sqlx::Row; + +use crate::error::Result; +use crate::Db; + +/// An entry in the pubkey allowlist. +#[derive(Debug, Clone)] +pub struct AllowlistEntry { + /// The allowed pubkey. + pub pubkey: Vec, + /// Who added this entry. + pub added_by: Vec, + /// When the entry was added. + pub added_at: DateTime, + /// Optional note. + pub note: Option, +} + +impl Db { + /// Check if a pubkey is in the allowlist for `community`. + #[datastore_span(name = "is_pubkey_allowed", system = "postgresql")] + pub async fn is_pubkey_allowed(&self, community: CommunityId, pubkey: &[u8]) -> Result { + let row = sqlx::query( + "SELECT COUNT(*) as cnt FROM pubkey_allowlist WHERE community_id = $1 AND pubkey = $2", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .fetch_one(&self.pool) + .await?; + let cnt: i64 = row.try_get("cnt")?; + Ok(cnt > 0) + } + + /// Check if the community allowlist has any entries (i.e. is enforcement active). + #[datastore_span(name = "has_allowlist_entries", system = "postgresql")] + pub async fn has_allowlist_entries(&self, community: CommunityId) -> Result { + let row = + sqlx::query("SELECT COUNT(*) as cnt FROM pubkey_allowlist WHERE community_id = $1") + .bind(community.as_uuid()) + .fetch_one(&self.pool) + .await?; + let cnt: i64 = row.try_get("cnt")?; + Ok(cnt > 0) + } + + /// Add a pubkey to the community allowlist. + #[datastore_span(name = "add_to_allowlist", system = "postgresql")] + pub async fn add_to_allowlist( + &self, + community: CommunityId, + pubkey: &[u8], + added_by: &[u8], + note: Option<&str>, + ) -> Result { + let result = sqlx::query( + "INSERT INTO pubkey_allowlist (community_id, pubkey, added_by, note) VALUES ($1, $2, $3, $4) \ + ON CONFLICT DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(added_by) + .bind(note) + .execute(&self.pool) + .await?; + Ok(result.rows_affected() > 0) + } + + /// Remove a pubkey from the community allowlist. + #[datastore_span(name = "remove_from_allowlist", system = "postgresql")] + pub async fn remove_from_allowlist( + &self, + community: CommunityId, + pubkey: &[u8], + ) -> Result { + let result = + sqlx::query("DELETE FROM pubkey_allowlist WHERE community_id = $1 AND pubkey = $2") + .bind(community.as_uuid()) + .bind(pubkey) + .execute(&self.pool) + .await?; + Ok(result.rows_affected() > 0) + } + + /// List all pubkeys in the community allowlist. + #[datastore_span(name = "list_allowlist", system = "postgresql")] + pub async fn list_allowlist(&self, community: CommunityId) -> Result> { + let rows = sqlx::query( + "SELECT pubkey, added_by, added_at, note FROM pubkey_allowlist WHERE community_id = $1 ORDER BY added_at DESC", + ) + .bind(community.as_uuid()) + .fetch_all(&self.pool) + .await?; + + let mut out = Vec::with_capacity(rows.len()); + for row in rows { + out.push(AllowlistEntry { + pubkey: row.try_get("pubkey")?, + added_by: row.try_get("added_by")?, + added_at: row.try_get("added_at")?, + note: row.try_get("note")?, + }); + } + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::PgPool; + use uuid::Uuid; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials + + async fn setup_db() -> Db { + let database_url = + std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); + let pool = PgPool::connect(&database_url) + .await + .expect("connect to test DB"); + Db::from_pool(pool) + } + + async fn make_community(pool: &PgPool) -> Uuid { + let id = Uuid::new_v4(); + let host = format!("communities-of-channels-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert community"); + id + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn allowlist_is_scoped_to_community() { + let db = setup_db().await; + let community_a = CommunityId::from_uuid(make_community(&db.pool).await); + let community_b = CommunityId::from_uuid(make_community(&db.pool).await); + let pubkey = [7u8; 32]; + let added_by = [9u8; 32]; + + assert!(db + .add_to_allowlist(community_a, &pubkey, &added_by, Some("a-only")) + .await + .expect("add allowlist row")); + assert!(!db + .add_to_allowlist(community_a, &pubkey, &added_by, Some("duplicate")) + .await + .expect("duplicate allowlist row is idempotent")); + + assert!( + db.is_pubkey_allowed(community_a, &pubkey) + .await + .expect("allowlist check A"), + "pubkey added to A must be allowed in A" + ); + assert!( + !db.is_pubkey_allowed(community_b, &pubkey) + .await + .expect("allowlist check B"), + "pubkey added only to A must not be allowed in B" + ); + assert!(db + .has_allowlist_entries(community_a) + .await + .expect("A has entries")); + assert!(!db + .has_allowlist_entries(community_b) + .await + .expect("B has no entries")); + + let listed = db + .list_allowlist(community_a) + .await + .expect("list A allowlist"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].pubkey, pubkey); + + assert!( + !db.remove_from_allowlist(community_b, &pubkey) + .await + .expect("remove from B is no-op"), + "removing from B must not delete A's row" + ); + assert!(db + .is_pubkey_allowed(community_a, &pubkey) + .await + .expect("A still allowed after B remove")); + assert!(db + .remove_from_allowlist(community_a, &pubkey) + .await + .expect("remove from A")); + assert!(!db + .is_pubkey_allowed(community_a, &pubkey) + .await + .expect("A not allowed after remove")); + } +} diff --git a/crates/buzz-db/src/api_token.rs b/crates/buzz-db/src/store/api_token.rs similarity index 66% rename from crates/buzz-db/src/api_token.rs rename to crates/buzz-db/src/store/api_token.rs index 50821743d2..ec380d9e5e 100644 --- a/crates/buzz-db/src/api_token.rs +++ b/crates/buzz-db/src/store/api_token.rs @@ -5,6 +5,9 @@ use sqlx::{PgPool, Row}; use uuid::Uuid; use crate::error::{DbError, Result}; +use crate::Db; +use buzz_core::CommunityId; +use buzz_datastore_tracing::datastore_span; /// Create a new API token record. The caller is responsible for generating /// the raw token and computing its SHA-256 hash. @@ -324,6 +327,284 @@ pub async fn revoke_all_tokens( Ok(result.rows_affected()) } +/// Token summary returned by [`Db::list_active_tokens`]. +#[derive(Debug, Clone)] +pub struct TokenSummary { + /// Unique token identifier. + pub id: Uuid, + /// Human-readable token name. + pub name: String, + /// Compressed public key bytes of the token owner. + pub owner_pubkey: Vec, + /// Permission scopes granted to this token. + pub scopes: Vec, + /// When the token was created. + pub created_at: DateTime, + /// Optional expiry timestamp; `None` means no expiry. + pub expires_at: Option>, +} + +/// A full API token record. +#[derive(Debug, Clone)] +pub struct ApiTokenRecord { + /// Unique token identifier. + pub id: Uuid, + /// SHA-256 hash of the raw token value. + pub token_hash: Vec, + /// Compressed public key bytes of the token owner. + pub owner_pubkey: Vec, + /// Human-readable token name. + pub name: String, + /// Permission scopes granted to this token. + pub scopes: Vec, + /// Optional channel ID restrictions. + pub channel_ids: Option>, + /// When the token was created. + pub created_at: DateTime, + /// Optional expiry timestamp. + pub expires_at: Option>, + /// When the token was last used. + pub last_used_at: Option>, + /// When the token was revoked. + pub revoked_at: Option>, +} + +fn parse_api_token_row(row: sqlx::postgres::PgRow) -> Result { + let id: Uuid = row.try_get("id")?; + + let scopes_json: serde_json::Value = row.try_get("scopes")?; + let scopes: Vec = serde_json::from_value(scopes_json) + .map_err(|e| DbError::InvalidData(format!("scopes JSON: {e}")))?; + + let channel_ids: Option> = { + let raw: Option = row.try_get("channel_ids")?; + match raw { + None => None, + Some(v) => { + let strings: Vec = serde_json::from_value(v) + .map_err(|e| DbError::InvalidData(format!("channel_ids JSON: {e}")))?; + let uuids: std::result::Result, _> = + strings.iter().map(|s| s.parse::()).collect(); + Some(uuids.map_err(|e| DbError::InvalidData(format!("channel_ids UUID: {e}")))?) + } + } + }; + + Ok(ApiTokenRecord { + id, + token_hash: row.try_get("token_hash")?, + owner_pubkey: row.try_get("owner_pubkey")?, + name: row.try_get("name")?, + scopes, + channel_ids, + created_at: row.try_get("created_at")?, + expires_at: row.try_get("expires_at")?, + last_used_at: row.try_get("last_used_at")?, + revoked_at: row.try_get("revoked_at")?, + }) +} + +impl Db { + /// Create a new API token record. + #[allow(clippy::too_many_arguments)] + #[datastore_span(name = "create_api_token", system = "postgresql")] + pub async fn create_api_token( + &self, + community_id: CommunityId, + token_hash: &[u8], + owner_pubkey: &[u8], + name: &str, + scopes: &[String], + channel_ids: Option<&[Uuid]>, + expires_at: Option>, + ) -> Result { + create_api_token( + &self.pool, + *community_id.as_uuid(), + token_hash, + owner_pubkey, + name, + scopes, + channel_ids, + expires_at, + ) + .await + } + + /// Atomic conditional INSERT with 10-token limit (per (community, owner)). + #[allow(clippy::too_many_arguments)] + #[datastore_span(name = "create_api_token_if_under_limit", system = "postgresql")] + pub async fn create_api_token_if_under_limit( + &self, + community_id: CommunityId, + token_hash: &[u8], + owner_pubkey: &[u8], + name: &str, + scopes: &[String], + channel_ids: Option<&[Uuid]>, + expires_at: Option>, + ) -> Result> { + create_api_token_if_under_limit( + &self.pool, + *community_id.as_uuid(), + token_hash, + owner_pubkey, + name, + scopes, + channel_ids, + expires_at, + ) + .await + } + + /// Look up an active (non-revoked) API token by its SHA-256 hash, + /// scoped to the request's community. + /// + /// See [`get_api_token_by_hash_including_revoked`] for the + /// row-44 conformance rationale — the `(community_id, token_hash)` key + /// is enforced both by the storage UNIQUE index and by this WHERE clause. + #[datastore_span(name = "get_api_token_by_hash", system = "postgresql")] + pub async fn get_api_token_by_hash( + &self, + community_id: CommunityId, + hash: &[u8], + ) -> Result> { + let row = sqlx::query( + r#" + SELECT id, token_hash, owner_pubkey, name, scopes, channel_ids, + created_at, expires_at, last_used_at, revoked_at + FROM api_tokens + WHERE community_id = $1 AND token_hash = $2 AND revoked_at IS NULL + "#, + ) + .bind(community_id.as_uuid()) + .bind(hash) + .fetch_optional(&self.pool) + .await?; + + match row { + None => Ok(None), + Some(r) => parse_api_token_row(r).map(Some), + } + } + + /// Look up an API token by hash, including revoked, scoped to community. + #[datastore_span( + name = "get_api_token_by_hash_including_revoked", + system = "postgresql" + )] + pub async fn get_api_token_by_hash_including_revoked( + &self, + community_id: CommunityId, + hash: &[u8], + ) -> Result> { + get_api_token_by_hash_including_revoked(&self.pool, *community_id.as_uuid(), hash).await + } + + /// Record a token usage (update `last_used_at`), scoped to community. + #[datastore_span(name = "touch_api_token", system = "postgresql")] + pub async fn touch_api_token(&self, community_id: CommunityId, hash: &[u8]) -> Result<()> { + sqlx::query( + "UPDATE api_tokens SET last_used_at = NOW() WHERE community_id = $1 AND token_hash = $2", + ) + .bind(community_id.as_uuid()) + .bind(hash) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Alias for [`Self::touch_api_token`]. + pub async fn update_token_last_used( + &self, + community_id: CommunityId, + hash: &[u8], + ) -> Result<()> { + self.touch_api_token(community_id, hash).await + } + + /// List all active (non-revoked) tokens in a community, newest first. + #[datastore_span(name = "list_active_tokens", system = "postgresql")] + pub async fn list_active_tokens(&self, community_id: CommunityId) -> Result> { + let rows = sqlx::query( + r#" + SELECT id, name, owner_pubkey, scopes, created_at, expires_at + FROM api_tokens + WHERE community_id = $1 AND revoked_at IS NULL + ORDER BY created_at DESC + LIMIT 1000 + "#, + ) + .bind(community_id.as_uuid()) + .fetch_all(&self.pool) + .await?; + + let mut out = Vec::with_capacity(rows.len()); + for row in rows { + let id: Uuid = row.try_get("id")?; + let scopes_json: serde_json::Value = row.try_get("scopes")?; + let scopes: Vec = serde_json::from_value(scopes_json) + .map_err(|e| DbError::InvalidData(format!("scopes JSON: {e}")))?; + + out.push(TokenSummary { + id, + name: row.try_get("name")?, + owner_pubkey: row.try_get("owner_pubkey")?, + scopes, + created_at: row.try_get("created_at")?, + expires_at: row.try_get("expires_at")?, + }); + } + Ok(out) + } + + /// List all tokens for a (community, owner) pair (including revoked). + #[datastore_span(name = "list_tokens_by_owner", system = "postgresql")] + pub async fn list_tokens_by_owner( + &self, + community_id: CommunityId, + pubkey: &[u8], + ) -> Result> { + list_tokens_by_owner(&self.pool, *community_id.as_uuid(), pubkey).await + } + + /// Revoke a single token by ID, scoped to (community, owner). + #[datastore_span(name = "revoke_token", system = "postgresql")] + pub async fn revoke_token( + &self, + community_id: CommunityId, + id: Uuid, + owner_pubkey: &[u8], + revoked_by: &[u8], + ) -> Result { + revoke_token( + &self.pool, + *community_id.as_uuid(), + id, + owner_pubkey, + revoked_by, + ) + .await + } + + /// Revoke all active tokens for a (community, owner) pair. + #[datastore_span(name = "revoke_all_tokens", system = "postgresql")] + pub async fn revoke_all_tokens( + &self, + community_id: CommunityId, + owner_pubkey: &[u8], + revoked_by: &[u8], + ) -> Result { + revoke_all_tokens( + &self.pool, + *community_id.as_uuid(), + owner_pubkey, + revoked_by, + ) + .await + } +} + #[cfg(test)] mod tests { //! Row-44 conformance: API token lookups MUST be keyed on @@ -344,7 +625,7 @@ mod tests { use crate::{ApiTokenRecord, Db}; use sqlx::PgPool; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials async fn setup_db() -> Db { let pool = PgPool::connect(TEST_DB_URL) diff --git a/crates/buzz-db/src/archived_identities.rs b/crates/buzz-db/src/store/archived_identities.rs similarity index 80% rename from crates/buzz-db/src/archived_identities.rs rename to crates/buzz-db/src/store/archived_identities.rs index 941c0fc735..810c8c0aa1 100644 --- a/crates/buzz-db/src/archived_identities.rs +++ b/crates/buzz-db/src/store/archived_identities.rs @@ -6,10 +6,12 @@ //! All pubkey and event ID values are lowercase hex strings. use buzz_core::CommunityId; +use buzz_datastore_tracing::datastore_span; use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row as _}; use crate::error::Result; +use crate::Db; /// A single archived identity record. #[derive(Debug, Clone)] @@ -124,11 +126,60 @@ fn row_to_archived_identity( }) } +impl Db { + /// Returns `true` if `pubkey` (64-char hex) is archived in `community_id`. + #[datastore_span(name = "is_archived", system = "postgresql")] + pub async fn is_archived(&self, community_id: CommunityId, pubkey: &str) -> Result { + is_archived(&self.pool, community_id, pubkey).await + } + + /// Archives an identity in `community_id`. Returns `true` if inserted, + /// `false` if already archived. + #[allow(clippy::too_many_arguments)] + #[datastore_span(name = "archive", system = "postgresql")] + pub async fn archive( + &self, + community_id: CommunityId, + pubkey: &str, + consent_path: &str, + actor: &str, + reason: Option<&str>, + replaced_by: Option<&str>, + request_event_id: &str, + ) -> Result { + archive( + &self.pool, + community_id, + pubkey, + consent_path, + actor, + reason, + replaced_by, + request_event_id, + ) + .await + } + + /// Unarchives an identity from `community_id`. Returns `true` if deleted, + /// `false` if absent. + #[datastore_span(name = "unarchive", system = "postgresql")] + pub async fn unarchive(&self, community_id: CommunityId, pubkey: &str) -> Result { + unarchive(&self.pool, community_id, pubkey).await + } + + /// Returns all identities archived in `community_id`, ordered by archive + /// time ascending. + #[datastore_span(name = "list_archived", system = "postgresql")] + pub async fn list_archived(&self, community_id: CommunityId) -> Result> { + list_archived(&self.pool, community_id).await + } +} + #[cfg(test)] mod tests { use super::*; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 async fn setup_pool() -> PgPool { PgPool::connect(TEST_DB_URL) diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/store/channel.rs similarity index 100% rename from crates/buzz-db/src/channel.rs rename to crates/buzz-db/src/store/channel.rs diff --git a/crates/buzz-db/src/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs similarity index 99% rename from crates/buzz-db/src/channel_members.rs rename to crates/buzz-db/src/store/channel_members.rs index 912f7dcf68..f0fd3332ac 100644 --- a/crates/buzz-db/src/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -2944,7 +2944,7 @@ mod tests { .connect(&scratch_url) .await .expect("connect desired-schema scratch db"); - sqlx::raw_sql(include_str!("../../../schema/schema.sql")) + sqlx::raw_sql(include_str!("../../../../schema/schema.sql")) .execute(&pool) .await .expect("apply desired-state schema"); diff --git a/crates/buzz-db/src/community.rs b/crates/buzz-db/src/store/community.rs similarity index 99% rename from crates/buzz-db/src/community.rs rename to crates/buzz-db/src/store/community.rs index 5df896a350..5e8462345b 100644 --- a/crates/buzz-db/src/community.rs +++ b/crates/buzz-db/src/store/community.rs @@ -602,7 +602,7 @@ mod tests { #[test] fn community_implementation_tests_and_spans_have_single_owners() { let community_source = include_str!("community.rs"); - let lib_source = include_str!("lib.rs"); + let lib_source = include_str!("../lib.rs"); let operations = [ "lookup_community_by_host", "is_community_active", diff --git a/crates/buzz-db/src/deletion.rs b/crates/buzz-db/src/store/deletion.rs similarity index 99% rename from crates/buzz-db/src/deletion.rs rename to crates/buzz-db/src/store/deletion.rs index 98a039d62f..c7fcdc09f6 100644 --- a/crates/buzz-db/src/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -17,6 +17,7 @@ use sqlx::{AssertSqlSafe, PgConnection, PgPool, Postgres, Row, Transaction}; use uuid::Uuid; use crate::error::{DbError, Result}; +use crate::Db; /// Default PostgreSQL lease duration for one claimed deletion request. pub const DEFAULT_LEASE_DURATION: Duration = Duration::from_secs(60); @@ -627,6 +628,23 @@ pub struct DeletionStore { pool: PgPool, } +impl Db { + /// Validate the minimum deletion fence catalog required by serving paths. + pub async fn validate_deletion_serving_catalog(&self) -> Result<()> { + self.deletion_store().validate_serving_catalog().await + } + + /// Validate the exact live community-deletion tenant catalog for destruction. + pub async fn validate_deletion_catalog(&self) -> Result<()> { + self.deletion_store().validate_catalog().await + } + + /// Return the shared durable whole-community deletion adapter. + pub fn deletion_store(&self) -> DeletionStore { + DeletionStore::new(self.pool.clone()) + } +} + impl DeletionStore { /// Construct from the writer pool used by [`crate::Db`]. pub(crate) fn new(pool: PgPool) -> Self { diff --git a/crates/buzz-db/src/dm.rs b/crates/buzz-db/src/store/dm.rs similarity index 85% rename from crates/buzz-db/src/dm.rs rename to crates/buzz-db/src/store/dm.rs index 89e15c7026..89e4a0e522 100644 --- a/crates/buzz-db/src/dm.rs +++ b/crates/buzz-db/src/store/dm.rs @@ -10,7 +10,9 @@ use uuid::Uuid; use crate::channel::ChannelRecord; use crate::error::{DbError, Result}; +use crate::Db; use buzz_core::CommunityId; +use buzz_datastore_tracing::datastore_span; // -- Public structs ----------------------------------------------------------- @@ -514,6 +516,89 @@ fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result { }) } +// -- Db API ------------------------------------------------------------------- + +impl Db { + /// Find an existing DM by its participant hash. + #[datastore_span(name = "find_dm_by_participants", system = "postgresql")] + pub async fn find_dm_by_participants( + &self, + community_id: CommunityId, + participant_hash: &[u8], + ) -> Result> { + crate::dm::find_dm_by_participants(&self.pool, community_id, participant_hash).await + } + + /// Create or return an existing DM channel. + #[datastore_span(name = "create_dm", system = "postgresql")] + pub async fn create_dm( + &self, + community_id: CommunityId, + participants: &[&[u8]], + created_by: &[u8], + ) -> Result { + crate::dm::create_dm(&self.pool, community_id, participants, created_by).await + } + + /// List all DMs for a user. + #[datastore_span(name = "list_dms_for_user", system = "postgresql")] + pub async fn list_dms_for_user( + &self, + community_id: CommunityId, + pubkey: &[u8], + limit: u32, + cursor: Option, + ) -> Result> { + crate::dm::list_dms_for_user(&self.pool, community_id, pubkey, limit, cursor).await + } + + /// Open or retrieve a DM for the given participants. + #[datastore_span(name = "open_dm", system = "postgresql")] + pub async fn open_dm( + &self, + community_id: CommunityId, + pubkeys: &[&[u8]], + created_by: &[u8], + ) -> Result<(ChannelRecord, bool)> { + crate::dm::open_dm(&self.pool, community_id, pubkeys, created_by).await + } + + /// Hide a DM channel for a specific user. + /// + /// The DM is not deleted — it can be restored by opening a new DM with + /// the same participants. + #[datastore_span(name = "hide_dm", system = "postgresql")] + pub async fn hide_dm( + &self, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + ) -> Result<()> { + crate::dm::hide_dm(&self.pool, community_id, channel_id, pubkey).await + } + + /// Unhide a DM channel for a specific user. + #[datastore_span(name = "unhide_dm", system = "postgresql")] + pub async fn unhide_dm( + &self, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + ) -> Result<()> { + crate::dm::unhide_dm(&self.pool, community_id, channel_id, pubkey).await + } + + /// List the channel IDs of all DMs the given user currently has hidden. + #[datastore_span(name = "list_hidden_dms", system = "postgresql")] + pub async fn list_hidden_dms( + &self, + community_id: CommunityId, + pubkey: &[u8], + ) -> Result> { + crate::dm::list_hidden_dms(&self.pool, community_id, pubkey).await + } +} + // -- Tests -------------------------------------------------------------------- #[cfg(test)] diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/store/event.rs similarity index 73% rename from crates/buzz-db/src/event.rs rename to crates/buzz-db/src/store/event.rs index 136bcce26b..60e6b05ef9 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -14,8 +14,16 @@ use buzz_core::kind::{ KIND_HUDDLE_STARTED, SHARED_GATED_KINDS, }; use buzz_core::{CommunityId, StoredEvent}; +use buzz_datastore_tracing::datastore_span; use crate::error::{DbError, Result}; +use crate::Db; + +// Compatibility exports preserve the pre-extraction public event-store paths. +pub use crate::reminder::{ + claim_due_reminder, claim_due_reminder_with_stamp, query_due_reminders, release_due_reminder, + DueReminder, +}; /// Largest page [`query_events`] will return when [`EventQuery::max_limit`] is /// unset — the effective ceiling on any client-requested `limit`. @@ -140,21 +148,7 @@ impl EventQuery { } } -/// Result of atomically inserting a kind:7 reaction event and its reaction row. -#[derive(Debug)] -pub enum ReactionEventInsertOutcome { - /// Target event was absent in this community, or was soft-deleted. No writes committed. - TargetMissing, - /// The active `(target, actor, emoji)` reaction already exists. No event was stored. - Duplicate, - /// Reaction row and event transaction committed. - Inserted { - /// Stored reaction event. - stored_event: Box, - /// Whether the event row itself was newly inserted. - was_inserted: bool, - }, -} +pub use crate::reaction::{insert_reaction_event_with_thread_metadata, ReactionEventInsertOutcome}; /// Maximum length for a `d_tag` value (bytes). NIP-33 d-tags are short identifiers; /// anything beyond this is either a bug or abuse. @@ -1354,238 +1348,395 @@ pub async fn insert_event_with_thread_metadata( Ok(result) } -/// Atomically insert a kind:7 reaction event and its reaction row. -/// -/// Ordering is load-bearing: resolve target, upsert/reactivate the reaction row, -/// check `rows_affected`, then insert the kind:7 event. Active duplicates return -/// before event insertion so duplicate reactions never store a duplicate kind:7. -#[allow(clippy::too_many_arguments)] -pub async fn insert_reaction_event_with_thread_metadata( - pool: &PgPool, - community_id: CommunityId, - reaction_event: &Event, - channel_id: Option, - thread_meta: Option>, - target_event_id: &[u8], - actor_pubkey: &[u8], - emoji: &str, -) -> Result { - let mut tx = pool.begin().await?; - - let target_row = sqlx::query( - "SELECT created_at FROM events \ - WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL \ - ORDER BY created_at DESC LIMIT 1", - ) - .bind(community_id.as_uuid()) - .bind(target_event_id) - .fetch_optional(&mut *tx) - .await?; +impl Db { + /// Inserts an event. Returns `(StoredEvent, was_inserted)` — `false` on duplicate. + #[datastore_span(name = "insert_event", system = "postgresql")] + pub async fn insert_event( + &self, + community_id: CommunityId, + event: &nostr::Event, + channel_id: Option, + ) -> Result<(StoredEvent, bool)> { + let result = + crate::event::insert_event(&self.pool, community_id, event, channel_id).await?; + if result.1 { + if let Err(e) = + crate::insert_mentions(&self.pool, community_id, event, channel_id).await + { + tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); + } + } + Ok(result) + } - let Some(target_row) = target_row else { - tx.rollback().await?; - return Ok(ReactionEventInsertOutcome::TargetMissing); - }; - let target_created_at: DateTime = target_row.get("created_at"); - - // Preserve add_reaction's exact new / re-activate / active-duplicate semantics. - let reaction_inserted = crate::reaction::add_reaction_tx( - &mut tx, - community_id, - target_event_id, - target_created_at, - actor_pubkey, - emoji, - Some(reaction_event.id.as_bytes()), - ) - .await?; + /// Queries events matching the given filter parameters. + /// + /// Always reads from the WRITER pool. If the result influences a write + /// or a permission decision, this is the method to call. Display-path + /// callers that tolerate bounded staleness should use + /// [`Db::query_events_routed`] instead — converting a caller is an + /// explicit, per-callsite decision, never a change to this method. + #[datastore_span(name = "query_events", system = "postgresql")] + pub async fn query_events(&self, q: &EventQuery) -> Result> { + crate::event::query_events(&self.pool, q).await + } + + /// [`Db::query_events`] with replica routing — the opt-in fast path for + /// display reads. + /// + /// Rule of thumb: **if the result influences a write or a permission, + /// it reads from the writer** — do not convert such a caller to this + /// method. Every new caller must be added to the caller-classification + /// table in `PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md`. + /// + /// Routing derives the strongest sound predicate from the query shape + /// ([`crate::RoutePredicate::for_query`]): a channel-pinned query with an + /// `until` upper bound may be served covered (provably complete below + /// the fence wall); anything else is bounded-staleness only. The whole + /// seam is gated on `BUZZ_REPLICA_READ_MAX_AGE_MS` (default off): when + /// unset, even covered-eligible queries stay on the writer, so merging + /// this seam is a true no-op until the budget is configured. Every + /// failure fails closed to the writer. + #[datastore_span(name = "query_events_routed", system = "postgresql")] + pub async fn query_events_routed( + &self, + path: &'static str, + q: &EventQuery, + ) -> Result> { + let predicate = crate::RoutePredicate::for_query(q, self.replica_read_max_age.is_some()); + match self.route_read(path, predicate).await { + crate::RouteDecision::Replica(mut tx, _entry, reason) => { + match crate::event::query_events_on(&mut tx, q).await { + Ok(events) => { + Self::record_route(path, "replica", reason); + Ok(events) + } + Err(e) => { + // Mid-query replica failure: fail closed to the + // writer rather than surfacing a routed error. + tracing::warn!(path, "replica read failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + crate::event::query_events(&self.pool, q).await + } + } + } + crate::RouteDecision::Writer => crate::event::query_events(&self.pool, q).await, + } + } - if !reaction_inserted { - tx.rollback().await?; - return Ok(ReactionEventInsertOutcome::Duplicate); + /// [`Db::query_events_routed`] restricted to the BOUNDED arm — for + /// reads whose result feeds a COUNT rather than a displayed page. + /// + /// The covered arm bounds insert-completeness only; stale deletions can + /// briefly inflate the result set (see [`crate::RoutePredicate::Covered`]). A + /// display page absorbs that per-row; a number derived from the rows + /// does not. Same classification-table requirement as + /// [`Db::query_events_routed`]. + #[datastore_span(name = "query_events_routed_bounded", system = "postgresql")] + pub async fn query_events_routed_bounded( + &self, + path: &'static str, + q: &EventQuery, + ) -> Result> { + match self.route_read(path, crate::RoutePredicate::Bounded).await { + crate::RouteDecision::Replica(mut tx, _entry, reason) => { + match crate::event::query_events_on(&mut tx, q).await { + Ok(events) => { + Self::record_route(path, "replica", reason); + Ok(events) + } + Err(e) => { + tracing::warn!(path, "replica read failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + crate::event::query_events(&self.pool, q).await + } + } + } + crate::RouteDecision::Writer => crate::event::query_events(&self.pool, q).await, + } } - let (stored_event, was_inserted) = insert_event_with_thread_metadata_tx( - &mut tx, - community_id, - reaction_event, - channel_id, - thread_meta, - ) - .await?; + /// Count events matching the given query (NIP-45 COUNT support). + /// + /// Always reads from the WRITER pool — see [`Db::query_events`] for the + /// writer-vs-routed rule. + #[datastore_span(name = "count_events", system = "postgresql")] + pub async fn count_events(&self, q: &EventQuery) -> Result { + crate::event::count_events(&self.pool, q).await + } - tx.commit().await?; + /// [`Db::count_events`] with replica routing — same contract, rules, + /// and classification-table requirement as [`Db::query_events_routed`]. + /// + /// Counts route on the BOUNDED arm only, never covered: the covered + /// arm bounds insert-completeness but not deletion visibility (soft + /// deletes are UPDATEs outside the floor guard), and a count has no + /// downstream per-row re-filter to absorb extra rows — a silently + /// inflated number for up to `FENCE_STALENESS` is a different product + /// statement than a page briefly showing a deleted row. `Bounded` ties + /// the error to the accepted budget `B`. + #[datastore_span(name = "count_events_routed", system = "postgresql")] + pub async fn count_events_routed(&self, path: &'static str, q: &EventQuery) -> Result { + match self.route_read(path, crate::RoutePredicate::Bounded).await { + crate::RouteDecision::Replica(mut tx, _entry, reason) => { + match crate::event::count_events_on(&mut tx, q).await { + Ok(count) => { + Self::record_route(path, "replica", reason); + Ok(count) + } + Err(e) => { + tracing::warn!(path, "replica count failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + crate::event::count_events(&self.pool, q).await + } + } + } + crate::RouteDecision::Writer => crate::event::count_events(&self.pool, q).await, + } + } - Ok(ReactionEventInsertOutcome::Inserted { - stored_event: Box::new(stored_event), - was_inserted, - }) -} + /// Return whether a creator-signed huddle-start event links a parent + /// channel to an ephemeral huddle channel. + #[datastore_span(name = "huddle_started_link_exists", system = "postgresql")] + pub async fn huddle_started_link_exists( + &self, + community_id: CommunityId, + parent_channel_id: Uuid, + ephemeral_channel_id: Uuid, + creator_pubkey: &[u8], + ) -> Result { + crate::event::huddle_started_link_exists( + &self.pool, + community_id, + parent_channel_id, + ephemeral_channel_id, + creator_pubkey, + ) + .await + } -/// A due reminder row returned by [`query_due_reminders`]. -#[derive(Debug)] -pub struct DueReminder { - /// Server-resolved community this reminder row belongs to. - pub community_id: CommunityId, - /// Normalized host mapped to that community. - pub host: String, - /// The event's raw ID bytes. - pub id: Vec, - /// The event's pubkey bytes. - pub pubkey: Vec, - /// The event's `created_at` timestamp. - pub created_at: DateTime, - /// The event's kind (always 30300). - pub kind: i32, - /// The event's JSONB tags. - pub tags: serde_json::Value, - /// The event's encrypted content. - pub content: String, - /// The event's signature bytes. - pub sig: Vec, - /// The channel ID (always None for reminders — global events). - pub channel_id: Option, -} + /// Fetch the latest replaceable event for a (kind, pubkey) pair. + /// + /// Uses canonical NIP-16 ordering: `created_at DESC, id ASC`. + /// This matches the write path in [`replace_addressable_event`] and handles + /// historical duplicate survivors correctly. + #[datastore_span(name = "get_latest_global_replaceable", system = "postgresql")] + pub async fn get_latest_global_replaceable( + &self, + community_id: CommunityId, + kind: i32, + pubkey_bytes: &[u8], + ) -> Result> { + crate::event::get_latest_global_replaceable(&self.pool, community_id, kind, pubkey_bytes) + .await + } -/// Query due reminders: latest-per-address `kind:30300` rows where -/// `not_before <= now`, `deleted_at IS NULL`, `delivered_at IS NULL`. -/// -/// Returns the latest head per `(pubkey, d_tag)` using canonical NIP-16 -/// ordering (`created_at DESC, id ASC`). -pub async fn query_due_reminders( - pool: &PgPool, - now_secs: i64, - batch_limit: i64, -) -> Result> { - let kind_i32 = KIND_EVENT_REMINDER as i32; - let rows = sqlx::query( - r#" - SELECT DISTINCT ON (e.community_id, e.pubkey, e.d_tag) - e.community_id, c.host, e.id, e.pubkey, e.created_at, e.kind, e.tags, e.content, e.sig, e.channel_id - FROM events AS e - JOIN communities AS c ON c.id = e.community_id - WHERE e.kind = $1 - AND e.not_before IS NOT NULL - AND e.not_before <= $2 - AND e.deleted_at IS NULL - AND e.delivered_at IS NULL - AND c.archived_at IS NULL - ORDER BY e.community_id, e.pubkey, e.d_tag, e.created_at DESC, e.id ASC - LIMIT $3 - "#, - ) - .bind(kind_i32) - .bind(now_secs) - .bind(batch_limit) - .fetch_all(pool) - .await?; + /// Fetches a single non-deleted event by its raw ID bytes. + /// + /// Returns `None` if the event does not exist or has been soft-deleted. + #[datastore_span(name = "get_event_by_id", system = "postgresql")] + pub async fn get_event_by_id( + &self, + community_id: CommunityId, + id_bytes: &[u8], + ) -> Result> { + crate::event::get_event_by_id(&self.pool, community_id, id_bytes).await + } + + /// Fetches a single event by its raw ID bytes, **including soft-deleted rows**. + #[datastore_span(name = "get_event_by_id_including_deleted", system = "postgresql")] + pub async fn get_event_by_id_including_deleted( + &self, + community_id: CommunityId, + id_bytes: &[u8], + ) -> Result> { + crate::event::get_event_by_id_including_deleted(&self.pool, community_id, id_bytes).await + } + + /// Soft-deletes an event. Returns `Ok(true)` if deleted, `Ok(false)` if already deleted. + #[datastore_span(name = "soft_delete_event", system = "postgresql")] + pub async fn soft_delete_event( + &self, + community_id: CommunityId, + event_id: &[u8], + ) -> Result { + crate::event::soft_delete_event(&self.pool, community_id, event_id).await + } + + /// Soft-delete the live row for an addressable coordinate `(kind, pubkey, d_tag)` + /// when it is not newer than the deletion request. + /// Used by NIP-09 a-tag deletion for parameterized-replaceable kinds; + /// `deletion_created_at_secs` is the deletion event's `created_at`. + #[datastore_span(name = "soft_delete_by_coordinate", system = "postgresql")] + pub async fn soft_delete_by_coordinate( + &self, + community_id: CommunityId, + kind: i32, + pubkey: &[u8], + d_tag: &str, + deletion_created_at_secs: i64, + ) -> Result { + crate::event::soft_delete_by_coordinate( + &self.pool, + community_id, + kind, + pubkey, + d_tag, + deletion_created_at_secs, + ) + .await + } - let results = rows - .into_iter() - .map(|row| DueReminder { - community_id: CommunityId::from_uuid(row.get("community_id")), - host: row.get("host"), - id: row.get("id"), - pubkey: row.get("pubkey"), - created_at: row.get("created_at"), - kind: row.get("kind"), - tags: row.get("tags"), - content: row.get("content"), - sig: row.get("sig"), - channel_id: row.get("channel_id"), - }) - .collect(); + /// Atomically soft-delete an event and decrement thread reply counters. + #[datastore_span(name = "soft_delete_event_and_update_thread", system = "postgresql")] + pub async fn soft_delete_event_and_update_thread( + &self, + community_id: CommunityId, + event_id: &[u8], + parent_event_id: Option<&[u8]>, + root_event_id: Option<&[u8]>, + ) -> Result { + crate::event::soft_delete_event_and_update_thread( + &self.pool, + community_id, + event_id, + parent_event_id, + root_event_id, + ) + .await + } - Ok(results) -} + /// Returns the most recent `created_at` for a channel. + #[datastore_span(name = "get_last_message_at", system = "postgresql")] + pub async fn get_last_message_at( + &self, + community_id: CommunityId, + channel_id: Uuid, + ) -> Result>> { + crate::event::get_last_message_at(&self.pool, community_id, channel_id).await + } -/// Atomically claim a due reminder for delivery. Returns `Some(id)` if this -/// caller won the claim (set `delivered_at`), or `None` if another pod already -/// claimed it. Mirrors the reaper's `archived_at IS NULL` guard for cross-pod -/// idempotency. -pub async fn claim_due_reminder( - pool: &PgPool, - community_id: CommunityId, - event_id: &[u8], - event_created_at: DateTime, -) -> Result { - claim_due_reminder_with_stamp( - pool, - community_id, - event_id, - event_created_at, - Utc::now().timestamp(), - ) - .await -} + /// Bulk-fetch the most recent `created_at` for a set of channel IDs. + #[datastore_span(name = "get_last_message_at_bulk", system = "postgresql")] + pub async fn get_last_message_at_bulk( + &self, + community_id: CommunityId, + channel_ids: &[Uuid], + ) -> Result>> { + crate::event::get_last_message_at_bulk(&self.pool, community_id, channel_ids).await + } -/// Atomically claim a due reminder using a caller-supplied delivery stamp. -/// -/// The same stamp should be passed to [`release_due_reminder`] if the publish -/// side effect fails, so rollback can compare-and-clear only this pod's claim. -/// -/// Scoped by `community_id`: `events` is keyed `(community_id, created_at, id)`, -/// and the same Nostr event id (hence the same `id`/`created_at` pair) is -/// allowed across communities. Without the community predicate a claim for -/// `A/X` would also mark `B/X` delivered. The caller already holds the owning -/// community on the `DueReminder` row. -pub async fn claim_due_reminder_with_stamp( - pool: &PgPool, - community_id: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - delivery_stamp: i64, -) -> Result { - let result = sqlx::query( - r#" - UPDATE events - SET delivered_at = $1 - WHERE community_id = $2 AND created_at = $3 AND id = $4 AND delivered_at IS NULL - "#, - ) - .bind(delivery_stamp) - .bind(community_id.as_uuid()) - .bind(event_created_at) - .bind(event_id) - .execute(pool) - .await?; + /// Batch-fetch non-deleted events by their raw IDs. + #[datastore_span(name = "get_events_by_ids", system = "postgresql")] + pub async fn get_events_by_ids( + &self, + community_id: CommunityId, + ids: &[&[u8]], + ) -> Result> { + crate::event::get_events_by_ids(&self.pool, community_id, ids).await + } - Ok(result.rows_affected() > 0) -} + /// [`Db::get_events_by_ids`] with replica routing — same contract and + /// classification-table requirement as [`Db::query_events_routed`]. + /// + /// By-id fetches route on the BOUNDED arm only: an id list carries no + /// channel pin, so no fence floor can prove insert-completeness — the + /// covered arm is structurally unavailable. Used for FTS hit hydration, + /// where a missing row degrades to a skipped search hit downstream. + #[datastore_span(name = "get_events_by_ids_routed", system = "postgresql")] + pub async fn get_events_by_ids_routed( + &self, + path: &'static str, + community_id: CommunityId, + ids: &[&[u8]], + ) -> Result> { + match self.route_read(path, crate::RoutePredicate::Bounded).await { + crate::RouteDecision::Replica(mut tx, _entry, reason) => { + match crate::event::get_events_by_ids_on(&mut tx, community_id, ids).await { + Ok(events) => { + Self::record_route(path, "replica", reason); + Ok(events) + } + Err(e) => { + tracing::warn!(path, "replica read failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + crate::event::get_events_by_ids(&self.pool, community_id, ids).await + } + } + } + crate::RouteDecision::Writer => { + crate::event::get_events_by_ids(&self.pool, community_id, ids).await + } + } + } -/// Release a previously claimed reminder when publish fails. -/// -/// The `delivery_stamp` must be the exact value written by the claiming pod; -/// that compare-and-clear prevents one pod from rolling back another pod's -/// later claim after a retry/race. -/// -/// Scoped by `community_id` for the same reason as the claim: a release for -/// `A/X` must not clear `B/X` even when their `id`/`created_at`/stamp coincide. -pub async fn release_due_reminder( - pool: &PgPool, - community_id: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - delivery_stamp: i64, -) -> Result { - let result = sqlx::query( - r#" - UPDATE events - SET delivered_at = NULL - WHERE community_id = $1 - AND created_at = $2 - AND id = $3 - AND delivered_at = $4 - "#, - ) - .bind(community_id.as_uuid()) - .bind(event_created_at) - .bind(event_id) - .bind(delivery_stamp) - .execute(pool) - .await?; + /// Atomically insert an event AND its thread metadata in a single transaction. + #[datastore_span(name = "insert_event_with_thread_metadata", system = "postgresql")] + pub async fn insert_event_with_thread_metadata( + &self, + community_id: CommunityId, + event: &nostr::Event, + channel_id: Option, + thread_meta: Option>, + ) -> Result<(StoredEvent, bool)> { + let result = crate::event::insert_event_with_thread_metadata( + &self.pool, + community_id, + event, + channel_id, + thread_meta, + ) + .await?; + if result.1 { + if let Err(e) = + crate::insert_mentions(&self.pool, community_id, event, channel_id).await + { + tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); + } + } + Ok(result) + } - Ok(result.rows_affected() == 1) + /// Backfill `d_tag` for existing NIP-33 events (kind 30000–39999) that have `d_tag IS NULL`. + /// + /// Idempotent — safe to call on every startup. No-ops when all rows are already populated. + /// Runs a single UPDATE touching only NIP-33 rows with NULL d_tag. + #[datastore_span(name = "backfill_d_tags", system = "postgresql")] + pub async fn backfill_d_tags(&self) -> Result { + let result = sqlx::query( + "UPDATE events \ + SET d_tag = COALESCE( \ + (SELECT elem->>1 FROM jsonb_array_elements(tags) AS elem \ + WHERE elem->>0 = 'd' LIMIT 1), \ + '' \ + ) \ + WHERE kind BETWEEN 30000 AND 39999 AND d_tag IS NULL \ + AND community_write_allowed(community_id)", + ) + .execute(&self.pool) + .await?; + Ok(result.rows_affected()) + } + + /// Soft-delete NIP-29 discovery events for a channel created by a specific relay pubkey. + #[datastore_span(name = "soft_delete_discovery_events", system = "postgresql")] + pub async fn soft_delete_discovery_events( + &self, + community_id: CommunityId, + channel_id: Uuid, + relay_pubkey: &[u8], + ) -> Result { + let result = sqlx::query( + "UPDATE events SET deleted_at = NOW() \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3 AND deleted_at IS NULL AND kind IN (39000, 39001, 39002)", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(relay_pubkey) + .execute(&self.pool) + .await?; + Ok(result.rows_affected()) + } } #[cfg(test)] @@ -2073,298 +2224,6 @@ mod tests { .expect("sign text event") } - fn make_reaction_event(keys: &Keys, target_id_hex: &str, emoji: &str) -> nostr::Event { - let nonce = Uuid::new_v4().to_string(); - EventBuilder::new(Kind::Custom(7), emoji) - .tags(vec![ - Tag::parse(["e", target_id_hex]).expect("reaction e tag"), - Tag::parse(["nonce", nonce.as_str()]).expect("nonce tag"), - ]) - .sign_with_keys(keys) - .expect("sign reaction event") - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn reaction_single_tx_stores_wrapped_max_shortcode() { - let pool = setup_pool().await; - let community = CommunityId::from_uuid(make_test_community(&pool).await); - let target = make_text_event("long custom emoji target"); - insert_event(&pool, community, &target, None) - .await - .expect("insert target"); - - let actor = Keys::generate(); - let emoji = format!(":{}:", "a".repeat(64)); - let reaction = make_reaction_event(&actor, &target.id.to_hex(), &emoji); - let outcome = insert_reaction_event_with_thread_metadata( - &pool, - community, - &reaction, - None, - None, - target.id.as_bytes(), - &actor.public_key().to_bytes(), - &emoji, - ) - .await - .expect("store wrapped 64-character shortcode"); - - assert!(matches!( - outcome, - ReactionEventInsertOutcome::Inserted { - was_inserted: true, - .. - } - )); - assert_eq!(emoji.chars().count(), 66); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn reaction_single_tx_duplicate_short_circuit_stores_no_event() { - let pool = setup_pool().await; - let community = CommunityId::from_uuid(make_test_community(&pool).await); - let target = make_text_event("reaction target"); - insert_event(&pool, community, &target, None) - .await - .expect("insert target"); - - let actor = Keys::generate(); - let actor_pubkey = actor.public_key().to_bytes(); - let target_hex = target.id.to_hex(); - let first = make_reaction_event(&actor, &target_hex, "👍"); - let second = make_reaction_event(&actor, &target_hex, "👍"); - - let first_outcome = insert_reaction_event_with_thread_metadata( - &pool, - community, - &first, - None, - None, - target.id.as_bytes(), - &actor_pubkey, - "👍", - ) - .await - .expect("first reaction insert"); - assert!(matches!( - first_outcome, - ReactionEventInsertOutcome::Inserted { - was_inserted: true, - .. - } - )); - - let duplicate = insert_reaction_event_with_thread_metadata( - &pool, - community, - &second, - None, - None, - target.id.as_bytes(), - &actor_pubkey, - "👍", - ) - .await - .expect("duplicate reaction insert"); - assert!(matches!(duplicate, ReactionEventInsertOutcome::Duplicate)); - - let duplicate_event = get_event_by_id(&pool, community, second.id.as_bytes()) - .await - .expect("lookup duplicate reaction event"); - assert!( - duplicate_event.is_none(), - "active duplicate reaction must short-circuit before storing kind:7 event" - ); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn reaction_single_tx_cross_community_target_rejected() { - let pool = setup_pool().await; - let community_a = CommunityId::from_uuid(make_test_community(&pool).await); - let community_b = CommunityId::from_uuid(make_test_community(&pool).await); - let target = make_text_event("community A target only"); - insert_event(&pool, community_a, &target, None) - .await - .expect("insert target in A"); - - let actor = Keys::generate(); - let actor_pubkey = actor.public_key().to_bytes(); - let reaction = make_reaction_event(&actor, &target.id.to_hex(), "👍"); - - let outcome = insert_reaction_event_with_thread_metadata( - &pool, - community_b, - &reaction, - None, - None, - target.id.as_bytes(), - &actor_pubkey, - "👍", - ) - .await - .expect("cross-community reaction attempt"); - assert!(matches!(outcome, ReactionEventInsertOutcome::TargetMissing)); - - assert!( - get_event_by_id(&pool, community_b, reaction.id.as_bytes()) - .await - .expect("lookup B reaction event") - .is_none(), - "reaction event must not store when target exists only in another community" - ); - assert!( - crate::reaction::get_active_reaction_record( - &pool, - community_b, - target.id.as_bytes(), - DateTime::from_timestamp(target.created_at.as_secs() as i64, 0).unwrap(), - &actor_pubkey, - "👍", - ) - .await - .expect("lookup B reaction row") - .is_none(), - "reaction row must not be inserted for cross-community target miss" - ); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn reaction_single_tx_event_insert_failure_rolls_back_reaction() { - let pool = setup_pool().await; - let community = CommunityId::from_uuid(make_test_community(&pool).await); - let target = make_text_event("rollback target"); - insert_event(&pool, community, &target, None) - .await - .expect("insert target"); - - let actor = Keys::generate(); - let actor_pubkey = actor.public_key().to_bytes(); - let target_hex = target.id.to_hex(); - let bad_reaction = EventBuilder::new(Kind::Custom(20000), "👍") - .tags(vec![ - Tag::parse(["e", target_hex.as_str()]).expect("reaction e tag") - ]) - .sign_with_keys(&actor) - .expect("sign ephemeral reaction-shaped event"); - let target_created_at = DateTime::from_timestamp(target.created_at.as_secs() as i64, 0) - .expect("target timestamp"); - - let err = insert_reaction_event_with_thread_metadata( - &pool, - community, - &bad_reaction, - None, - None, - target.id.as_bytes(), - &actor_pubkey, - "👍", - ) - .await - .expect_err("ephemeral event insert must fail after reaction upsert attempt"); - assert!(matches!(err, DbError::EphemeralEventRejected(20000))); - - assert!( - crate::reaction::get_active_reaction_record( - &pool, - community, - target.id.as_bytes(), - target_created_at, - &actor_pubkey, - "👍", - ) - .await - .expect("lookup reaction row after rollback") - .is_none(), - "transaction rollback must remove the reaction row when event insert fails" - ); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn reaction_single_tx_reactivates_soft_deleted_reaction() { - let pool = setup_pool().await; - let community = CommunityId::from_uuid(make_test_community(&pool).await); - let target = make_text_event("reactivation target"); - insert_event(&pool, community, &target, None) - .await - .expect("insert target"); - - let actor = Keys::generate(); - let actor_pubkey = actor.public_key().to_bytes(); - let target_hex = target.id.to_hex(); - let target_created_at = DateTime::from_timestamp(target.created_at.as_secs() as i64, 0) - .expect("target timestamp"); - let first = make_reaction_event(&actor, &target_hex, "👍"); - let second = make_reaction_event(&actor, &target_hex, "👍"); - - assert!(matches!( - insert_reaction_event_with_thread_metadata( - &pool, - community, - &first, - None, - None, - target.id.as_bytes(), - &actor_pubkey, - "👍", - ) - .await - .expect("first reaction insert"), - ReactionEventInsertOutcome::Inserted { .. } - )); - assert!(crate::reaction::remove_reaction( - &pool, - community, - target.id.as_bytes(), - target_created_at, - &actor_pubkey, - "👍", - ) - .await - .expect("soft delete reaction")); - - let outcome = insert_reaction_event_with_thread_metadata( - &pool, - community, - &second, - None, - None, - target.id.as_bytes(), - &actor_pubkey, - "👍", - ) - .await - .expect("reactivate reaction"); - assert!(matches!( - outcome, - ReactionEventInsertOutcome::Inserted { - was_inserted: true, - .. - } - )); - - let active = crate::reaction::get_active_reaction_record( - &pool, - community, - target.id.as_bytes(), - target_created_at, - &actor_pubkey, - "👍", - ) - .await - .expect("active record after reactivation") - .expect("reaction active after reactivation"); - assert_eq!( - active.reaction_event_id.as_deref(), - Some(second.id.as_bytes().as_slice()), - "reactivation through the tx path must preserve add_reaction's source-id update semantics" - ); - } - #[test] fn extract_d_tag_from_nip33_event() { let event = make_event_with_kind_and_tags( @@ -2495,240 +2354,70 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] - async fn query_due_reminders_returns_row_community_and_host_per_tenant() { - let pool = setup_pool().await; - let community_a_uuid = make_test_community(&pool).await; - let community_b_uuid = make_test_community(&pool).await; - let community_a = CommunityId::from_uuid(community_a_uuid); - let community_b = CommunityId::from_uuid(community_b_uuid); - let host_a: String = sqlx::query_scalar("SELECT host FROM communities WHERE id = $1") - .bind(community_a_uuid) - .fetch_one(&pool) - .await - .expect("load host A"); - let host_b: String = sqlx::query_scalar("SELECT host FROM communities WHERE id = $1") - .bind(community_b_uuid) - .fetch_one(&pool) - .await - .expect("load host B"); - - let not_before = Utc::now().timestamp() - 1; - let keys_a = Keys::generate(); - let keys_b = Keys::generate(); - let event_a = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "a") - .tags([ - Tag::parse(["d", "due-reminder-scope-a"]).unwrap(), - Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), - ]) - .sign_with_keys(&keys_a) - .expect("sign A"); - let event_b = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "b") - .tags([ - Tag::parse(["d", "due-reminder-scope-b"]).unwrap(), - Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), - ]) - .sign_with_keys(&keys_b) - .expect("sign B"); - - insert_event(&pool, community_a, &event_a, None) - .await - .expect("insert A"); - insert_event(&pool, community_b, &event_b, None) - .await - .expect("insert B"); - - let due = query_due_reminders(&pool, Utc::now().timestamp(), 100) - .await - .expect("query due reminders"); - - assert!(due.iter().any(|row| { - row.id == event_a.id.as_bytes() && row.community_id == community_a && row.host == host_a - })); - assert!(due.iter().any(|row| { - row.id == event_b.id.as_bytes() && row.community_id == community_b && row.host == host_b - })); - } + async fn coordinate_delete_spares_head_newer_than_the_deletion() { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; - /// Two pods race to claim the same due reminder: exactly one wins. The - /// scheduler publishes only on a winning claim (`Ok(true)`) and `continue`s - /// on the loser (`Ok(false)`), so a single winning claim *is* the proof of - /// exactly one publish side effect across N pods. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn claim_due_reminder_is_won_by_exactly_one_of_two_racing_pods() { - let pool = setup_pool().await; - let community = CommunityId::from_uuid(make_test_community(&pool).await); - let not_before = Utc::now().timestamp() - 1; + let db = Db::from_pool(setup_pool().await); + let community = CommunityId::from_uuid(make_test_community(&db.pool).await); let keys = Keys::generate(); - let event = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "due") - .tags([ - Tag::parse(["d", "due-reminder-claim-race"]).unwrap(), - Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), - ]) - .sign_with_keys(&keys) - .expect("sign reminder"); - insert_event(&pool, community, &event, None) - .await - .expect("insert reminder"); - - let id = event.id.as_bytes().to_vec(); - let created_at = event.created_at.as_secs() as i64; - let created_at = chrono::DateTime::from_timestamp(created_at, 0).expect("created_at"); - - // Two pods, two distinct per-attempt stamps, same reminder. - let stamp_p1: i64 = 0x1111_1111_1111_1111; - let stamp_p2: i64 = 0x2222_2222_2222_2222; - let won_p1 = claim_due_reminder_with_stamp(&pool, community, &id, created_at, stamp_p1) - .await - .expect("p1 claim"); - let won_p2 = claim_due_reminder_with_stamp(&pool, community, &id, created_at, stamp_p2) - .await - .expect("p2 claim"); - - assert!( - won_p1 ^ won_p2, - "exactly one pod must win the claim (p1={won_p1}, p2={won_p2}) — \ - the loser never reaches the publish side effect" - ); - } + let kind = buzz_core::kind::KIND_PROJECT as i32; + let d_tag = "stale-tombstone-project"; + let pubkey = keys.public_key().to_bytes().to_vec(); + let base = Timestamp::now().as_secs(); + + let version = |content: &str, offset: u64| { + EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), content) + .tags(vec![Tag::parse(["d", d_tag]).expect("d tag")]) + .custom_created_at(Timestamp::from(base + offset)) + .sign_with_keys(&keys) + .expect("sign project version") + }; + + for (content, offset) in [("v1", 0), ("v2", 100)] { + assert!( + db.replace_parameterized_event(community, &version(content, offset), d_tag, None) + .await + .expect("store project version") + .1 + ); + } - /// A failed publish releases the claim so the reminder is redeliverable, - /// and the compare-and-clear stamp guard prevents one pod from rolling back - /// another pod's claim. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn release_due_reminder_rolls_back_only_the_matching_stamp() { - let pool = setup_pool().await; - let community = CommunityId::from_uuid(make_test_community(&pool).await); - let not_before = Utc::now().timestamp() - 1; - let keys = Keys::generate(); - let event = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "due") - .tags([ - Tag::parse(["d", "due-reminder-release"]).unwrap(), - Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), - ]) - .sign_with_keys(&keys) - .expect("sign reminder"); - insert_event(&pool, community, &event, None) + // Tombstone timestamped between V1 and V2: it authorizes deleting V1, + // never the newer head that replaced it. + let stale_deleted = db + .soft_delete_by_coordinate(community, kind, &pubkey, d_tag, (base + 50) as i64) .await - .expect("insert reminder"); - - let id = event.id.as_bytes().to_vec(); - let created_at = event.created_at.as_secs() as i64; - let created_at = chrono::DateTime::from_timestamp(created_at, 0).expect("created_at"); - let stamp: i64 = 0x3333_3333_3333_3333; - + .expect("stale coordinate delete"); assert!( - claim_due_reminder_with_stamp(&pool, community, &id, created_at, stamp) - .await - .expect("claim"), - "first claim wins" + !stale_deleted, + "a tombstone older than the live head must delete nothing" ); - // A release with the *wrong* stamp must be a no-op (does not clear - // another pod's claim). - assert!( - !release_due_reminder(&pool, community, &id, created_at, stamp ^ 0xFFFF) - .await - .expect("wrong-stamp release"), - "release with a non-matching stamp must not clear the claim" - ); - assert!( - !claim_due_reminder_with_stamp(&pool, community, &id, created_at, stamp) - .await - .expect("re-claim after no-op release"), - "reminder must still be claimed after a no-op release" - ); - - // The matching-stamp release rolls the claim back; the reminder is - // redeliverable and a subsequent claim wins again. - assert!( - release_due_reminder(&pool, community, &id, created_at, stamp) - .await - .expect("matching-stamp release"), - "release with the claiming stamp must clear the claim" - ); - assert!( - claim_due_reminder_with_stamp(&pool, community, &id, created_at, stamp) - .await - .expect("re-claim after release"), - "released reminder must be reclaimable for retry" + let live_content: Option = sqlx::query_scalar( + "SELECT content FROM events \ + WHERE community_id=$1 AND kind=$2 AND pubkey=$3 AND d_tag=$4 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(kind) + .bind(&pubkey) + .bind(d_tag) + .fetch_optional(&db.pool) + .await + .expect("read live head"); + assert_eq!( + live_content.as_deref(), + Some("v2"), + "the newer head must survive a stale tombstone" ); - } - /// Cross-community confinement: the same Nostr reminder event (identical - /// `id` and `created_at`) inserted into communities A and B must claim and - /// release independently. A claim/release for `A/X` must never touch `B/X`. - /// - /// This is the primitive the scheduler's exactly-once-publish proof rests - /// on: `events` is keyed `(community_id, created_at, id)`, so without the - /// community predicate a claim for A would mark B delivered (suppressing - /// B's reminder) and a matching-stamp release for A would clear B. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn reminder_claim_and_release_are_confined_to_their_community() { - let pool = setup_pool().await; - let community_a = CommunityId::from_uuid(make_test_community(&pool).await); - let community_b = CommunityId::from_uuid(make_test_community(&pool).await); - - // One signed event, inserted into both communities — same id/created_at. - let not_before = Utc::now().timestamp() - 1; - let keys = Keys::generate(); - let event = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "due") - .tags([ - Tag::parse(["d", "due-reminder-cross-community"]).unwrap(), - Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), - ]) - .sign_with_keys(&keys) - .expect("sign reminder"); - insert_event(&pool, community_a, &event, None) - .await - .expect("insert A/X"); - insert_event(&pool, community_b, &event, None) + // A tombstone at or after the head's own timestamp still deletes it. + let current_deleted = db + .soft_delete_by_coordinate(community, kind, &pubkey, d_tag, (base + 100) as i64) .await - .expect("insert B/X"); - - let id = event.id.as_bytes().to_vec(); - let created_at = event.created_at.as_secs() as i64; - let created_at = chrono::DateTime::from_timestamp(created_at, 0).expect("created_at"); - let stamp: i64 = 0x4444_4444_4444_4444; - - // Claim A/X. B/X must remain claimable — A's claim did not mark B. + .expect("current coordinate delete"); assert!( - claim_due_reminder_with_stamp(&pool, community_a, &id, created_at, stamp) - .await - .expect("claim A"), - "A/X claim wins" - ); - assert!( - claim_due_reminder_with_stamp(&pool, community_b, &id, created_at, stamp) - .await - .expect("claim B"), - "B/X must still be claimable after A/X is claimed — \ - a claim for A must not mark B delivered" - ); - - // Both are now claimed under the same stamp. A matching-stamp release - // for A/X must clear only A/X; B/X must stay claimed. - assert!( - release_due_reminder(&pool, community_a, &id, created_at, stamp) - .await - .expect("release A"), - "A/X release with the claiming stamp clears A/X" - ); - assert!( - !claim_due_reminder_with_stamp(&pool, community_b, &id, created_at, stamp) - .await - .expect("re-claim B after A release"), - "B/X must remain claimed after A/X is released — \ - a release for A must not clear B" - ); - // And A/X is genuinely redeliverable (the release was real, not a no-op). - assert!( - claim_due_reminder_with_stamp(&pool, community_a, &id, created_at, stamp) - .await - .expect("re-claim A after release"), - "A/X must be reclaimable after its own release" + current_deleted, + "a tombstone at the head's timestamp must delete it (NIP-09 is at-or-before)" ); } diff --git a/crates/buzz-db/src/feed.rs b/crates/buzz-db/src/store/feed.rs similarity index 79% rename from crates/buzz-db/src/feed.rs rename to crates/buzz-db/src/store/feed.rs index 6900e2061c..01e4fef32b 100644 --- a/crates/buzz-db/src/feed.rs +++ b/crates/buzz-db/src/store/feed.rs @@ -28,6 +28,7 @@ /// before the query is issued so the SQL `LIMIT` clause always reflects this cap. pub const FEED_MAX_LIMIT: i64 = 100; +use buzz_datastore_tracing::datastore_span; use chrono::{DateTime, Utc}; use sqlx::postgres::PgRow; use sqlx::{PgPool, QueryBuilder}; @@ -41,8 +42,8 @@ use buzz_core::kind::{ }; use buzz_core::{CommunityId, StoredEvent}; -use crate::error::Result; use crate::event::row_to_stored_event; +use crate::{error::Result, Db, RouteDecision, RoutePredicate}; /// Column list shared by every feed subquery that aliases the `events` table as `e`. const EVENT_COLS: &str = @@ -303,6 +304,235 @@ pub(crate) async fn query_activity_on( collect_stored_events(rows) } +// -- Db API ------------------------------------------------------------------- + +impl Db { + /// Find events that @mention the given pubkey. + #[datastore_span(name = "query_feed_mentions", system = "postgresql")] + pub async fn query_feed_mentions( + &self, + community: CommunityId, + pubkey_bytes: &[u8], + accessible_channel_ids: &[Uuid], + since: Option>, + limit: i64, + ) -> Result> { + crate::feed::query_mentions( + &self.pool, + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ) + .await + } + + /// [`Db::query_feed_mentions`] with replica routing — same contract and + /// classification-table requirement as [`Db::query_events_routed`]. + /// + /// Feed queries route on the BOUNDED arm only: the `accessible_channel_ids` + /// parameter admits community-global rows alongside channel rows, so no + /// single channel's fence floor can prove completeness — the covered arm + /// is structurally unavailable, not merely unchosen. + #[datastore_span(name = "query_feed_mentions_routed", system = "postgresql")] + pub async fn query_feed_mentions_routed( + &self, + path: &'static str, + community: CommunityId, + pubkey_bytes: &[u8], + accessible_channel_ids: &[Uuid], + since: Option>, + limit: i64, + ) -> Result> { + match self.route_read(path, RoutePredicate::Bounded).await { + RouteDecision::Replica(mut tx, _entry, reason) => match crate::feed::query_mentions_on( + &mut tx, + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ) + .await + { + Ok(events) => { + Self::record_route(path, "replica", reason); + Ok(events) + } + Err(e) => { + tracing::warn!(path, "replica read failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + crate::feed::query_mentions( + &self.pool, + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ) + .await + } + }, + RouteDecision::Writer => { + crate::feed::query_mentions( + &self.pool, + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ) + .await + } + } + } + + /// Find events that require action from the given pubkey. + #[datastore_span(name = "query_feed_needs_action", system = "postgresql")] + pub async fn query_feed_needs_action( + &self, + community: CommunityId, + pubkey_bytes: &[u8], + accessible_channel_ids: &[Uuid], + since: Option>, + limit: i64, + ) -> Result> { + crate::feed::query_needs_action( + &self.pool, + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ) + .await + } + + /// [`Db::query_feed_needs_action`] with replica routing — BOUNDED arm + /// only; see [`Db::query_feed_mentions_routed`] for why the covered arm + /// is structurally unavailable to feed queries. + #[datastore_span(name = "query_feed_needs_action_routed", system = "postgresql")] + pub async fn query_feed_needs_action_routed( + &self, + path: &'static str, + community: CommunityId, + pubkey_bytes: &[u8], + accessible_channel_ids: &[Uuid], + since: Option>, + limit: i64, + ) -> Result> { + match self.route_read(path, RoutePredicate::Bounded).await { + RouteDecision::Replica(mut tx, _entry, reason) => { + match crate::feed::query_needs_action_on( + &mut tx, + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ) + .await + { + Ok(events) => { + Self::record_route(path, "replica", reason); + Ok(events) + } + Err(e) => { + tracing::warn!(path, "replica read failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + crate::feed::query_needs_action( + &self.pool, + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ) + .await + } + } + } + RouteDecision::Writer => { + crate::feed::query_needs_action( + &self.pool, + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ) + .await + } + } + } + + /// Find recent activity across accessible channels. + #[datastore_span(name = "query_feed_activity", system = "postgresql")] + pub async fn query_feed_activity( + &self, + community: CommunityId, + accessible_channel_ids: &[Uuid], + since: Option>, + limit: i64, + ) -> Result> { + crate::feed::query_activity(&self.pool, community, accessible_channel_ids, since, limit) + .await + } + + /// [`Db::query_feed_activity`] with replica routing — BOUNDED arm only; + /// see [`Db::query_feed_mentions_routed`] for why the covered arm is + /// structurally unavailable to feed queries. + #[datastore_span(name = "query_feed_activity_routed", system = "postgresql")] + pub async fn query_feed_activity_routed( + &self, + path: &'static str, + community: CommunityId, + accessible_channel_ids: &[Uuid], + since: Option>, + limit: i64, + ) -> Result> { + match self.route_read(path, RoutePredicate::Bounded).await { + RouteDecision::Replica(mut tx, _entry, reason) => match crate::feed::query_activity_on( + &mut tx, + community, + accessible_channel_ids, + since, + limit, + ) + .await + { + Ok(events) => { + Self::record_route(path, "replica", reason); + Ok(events) + } + Err(e) => { + tracing::warn!(path, "replica read failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + crate::feed::query_activity( + &self.pool, + community, + accessible_channel_ids, + since, + limit, + ) + .await + } + }, + RouteDecision::Writer => { + crate::feed::query_activity( + &self.pool, + community, + accessible_channel_ids, + since, + limit, + ) + .await + } + } + } +} + // -- Tests -------------------------------------------------------------------- #[cfg(test)] @@ -904,8 +1134,19 @@ mod tests { // 11,000 rows x 6 binds = 66,000 > 65,535: overflows a single statement. let mention_count = 11_000usize; + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey, role) \ + SELECT $1, $2, decode(lpad(to_hex(n), 64, '0'), 'hex'), 'member' \ + FROM generate_series(1, $3) n", + ) + .bind(community.as_uuid()) + .bind(channel) + .bind(mention_count as i64) + .execute(&pool) + .await + .expect("insert canonical roster members"); let tags: Vec = (1..=mention_count) - .map(|n| Tag::parse(["p", &format!("{n:064x}")]).expect("p tag")) + .map(|n| Tag::parse(["p", &format!("{n:064x}"), "", "member"]).expect("p tag")) .collect(); let event = store_feed_event(&pool, community, 39002, "", Some(channel), tags).await; diff --git a/crates/buzz-db/src/git_repo.rs b/crates/buzz-db/src/store/git_repo.rs similarity index 87% rename from crates/buzz-db/src/git_repo.rs rename to crates/buzz-db/src/store/git_repo.rs index c1e47c0f8c..5afea1e4fd 100644 --- a/crates/buzz-db/src/git_repo.rs +++ b/crates/buzz-db/src/store/git_repo.rs @@ -16,10 +16,11 @@ //! idempotent re-announce (same owner) from a collision (different owner), and //! backs the per-pubkey quota via `COUNT`. +use buzz_datastore_tracing::datastore_span; use sqlx::{PgPool, Row as _}; use crate::error::Result; -use crate::CommunityId; +use crate::{CommunityId, Db}; /// Outcome of a name-reservation attempt. /// @@ -179,12 +180,62 @@ pub async fn release_repo_name( Ok(result.rows_affected()) } +impl Db { + /// Return the current owner of git repo name `repo_id` in `community`, or + /// `None` if unreserved. See [`repo_name_owner`]. + #[datastore_span(name = "repo_name_owner", system = "postgresql")] + pub async fn repo_name_owner( + &self, + community: CommunityId, + repo_id: &str, + ) -> Result> { + repo_name_owner(&self.pool, community, repo_id).await + } + + /// Reserve a git repo name for `owner_pubkey` in `community` (NIP-34). + /// + /// See [`reserve_repo_name`] for the outcome semantics. The per-pubkey + /// quota is enforced by the caller against `count_repos_for_owner`. + #[datastore_span(name = "reserve_repo_name", system = "postgresql")] + pub async fn reserve_repo_name( + &self, + community: CommunityId, + repo_id: &str, + owner_pubkey: &str, + ) -> Result { + reserve_repo_name(&self.pool, community, repo_id, owner_pubkey).await + } + + /// Count git repos reserved by `owner_pubkey` in `community` (quota check). + #[datastore_span(name = "count_repos_for_owner", system = "postgresql")] + pub async fn count_repos_for_owner( + &self, + community: CommunityId, + owner_pubkey: &str, + ) -> Result { + count_repos_for_owner(&self.pool, community, owner_pubkey).await + } + + /// Release a git repo name reservation held by `owner_pubkey` (rollback). + /// + /// Returns the number of rows removed (0 or 1). See [`release_repo_name`]. + #[datastore_span(name = "release_repo_name", system = "postgresql")] + pub async fn release_repo_name( + &self, + community: CommunityId, + repo_id: &str, + owner_pubkey: &str, + ) -> Result { + release_repo_name(&self.pool, community, repo_id, owner_pubkey).await + } +} + #[cfg(test)] mod tests { use super::*; use uuid::Uuid; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 async fn setup_pool() -> PgPool { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs new file mode 100644 index 0000000000..1fa1273eb0 --- /dev/null +++ b/crates/buzz-db/src/store/mod.rs @@ -0,0 +1,56 @@ +//! Domain-owned persistence implementations. + +/// Explicit deployment-global admin report reads. +pub mod admin_moderation; +/// Community-scoped authentication allowlist persistence. +pub mod allowlist; +/// API token storage and lookup. +pub mod api_token; +/// Relay-scoped archived identity persistence (NIP-IA). +pub mod archived_identities; +/// Channel lifecycle and metadata persistence. +pub mod channel; +/// Channel membership and roster persistence. +pub mod channel_members; +/// Community lifecycle and host-map persistence. +pub mod community; +/// Durable whole-community deletion lifecycle and PostgreSQL adapter. +pub mod deletion; +/// Direct message channel persistence. +pub mod dm; +/// Event storage and retrieval. +pub mod event; +/// Home feed queries. +pub mod feed; +/// Git repository name registry (NIP-34 kind:30617). +pub mod git_repo; +/// Community moderation: reports, bans/timeouts, audit actions. +pub mod moderation; +/// Monthly table partition management. +pub mod partition; +/// Buzz product-feedback sidecar persistence. +pub mod product_feedback; +/// Community-scoped push lease and durable wake-outbox persistence. +pub mod push; +/// Reaction persistence. +pub mod reaction; +/// HTTP report-resolution enforcement state machine persistence. +pub mod relay_admin_actions; +/// Use-limited relay invite persistence (v2 opaque tokens). +pub mod relay_invite; +/// Relay-level membership persistence (NIP-43). +pub mod relay_members; +/// Deployment-global relay operator/moderator roster persistence. +pub mod relay_operators; +/// Event-reminder delivery query, claim, and release persistence. +pub mod reminder; +/// Replaceable-event persistence and coordinate locking. +pub mod replaceable; +/// Thread metadata persistence. +pub mod thread; +/// Per-community usage rollup queries for Prometheus gauges. +pub mod usage; +/// User profile persistence. +pub mod user; +/// Workflow, run, and approval persistence. +pub mod workflow; diff --git a/crates/buzz-db/src/moderation.rs b/crates/buzz-db/src/store/moderation.rs similarity index 85% rename from crates/buzz-db/src/moderation.rs rename to crates/buzz-db/src/store/moderation.rs index 7146886e3e..5ac7c93af9 100644 --- a/crates/buzz-db/src/moderation.rs +++ b/crates/buzz-db/src/store/moderation.rs @@ -14,12 +14,13 @@ //! Lane ownership: L1 (Max). Signatures below are the contract; changes go //! through the integration thread. +use buzz_datastore_tracing::datastore_span; use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row as _}; use uuid::Uuid; use crate::error::Result; -use crate::CommunityId; +use crate::{CommunityId, Db}; /// What a report points at. Exactly one target class per report row. #[derive(Debug, Clone, PartialEq, Eq)] @@ -651,13 +652,174 @@ fn row_to_action(row: sqlx::postgres::PgRow) -> Result { }) } +impl Db { + /// Insert a tenant-scoped NIP-56 report row, idempotent by report event id. + #[datastore_span(name = "insert_moderation_report", system = "postgresql")] + pub async fn insert_moderation_report( + &self, + community: CommunityId, + report: NewReport<'_>, + ) -> Result { + insert_report(&self.pool, community, report).await + } + + /// List moderation reports for a community, newest first. + #[datastore_span(name = "list_moderation_reports", system = "postgresql")] + pub async fn list_moderation_reports( + &self, + community: CommunityId, + status: Option<&str>, + limit: i64, + ) -> Result> { + list_reports(&self.pool, community, status, limit).await + } + + /// Fetch one moderation report by row id. + #[datastore_span(name = "get_moderation_report", system = "postgresql")] + pub async fn get_moderation_report( + &self, + community: CommunityId, + report_id: Uuid, + ) -> Result> { + get_report(&self.pool, community, report_id).await + } + + /// Fetch one moderation report by signed NIP-56 report event id. + #[datastore_span(name = "get_moderation_report_by_event", system = "postgresql")] + pub async fn get_moderation_report_by_event( + &self, + community: CommunityId, + report_event_id: &[u8], + ) -> Result> { + get_report_by_event(&self.pool, community, report_event_id).await + } + + /// Resolve, dismiss, or escalate an open moderation report. + #[datastore_span(name = "resolve_moderation_report", system = "postgresql")] + pub async fn resolve_moderation_report( + &self, + community: CommunityId, + report_id: Uuid, + status: &str, + resolved_by: &[u8], + action_id: Option, + ) -> Result { + resolve_report( + &self.pool, + community, + report_id, + status, + resolved_by, + action_id, + ) + .await + } + + /// Upsert a community ban for a member pubkey. + #[datastore_span(name = "ban_community_member", system = "postgresql")] + pub async fn ban_community_member( + &self, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + reason: Option<&str>, + expires_at: Option>, + ) -> Result<()> { + ban_member(&self.pool, community, pubkey, actor, reason, expires_at).await + } + + /// Lift a community ban for a member pubkey. + #[datastore_span(name = "unban_community_member", system = "postgresql")] + pub async fn unban_community_member( + &self, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + ) -> Result { + unban_member(&self.pool, community, pubkey, actor).await + } + + /// Upsert a community timeout/write-block for a member pubkey. + #[datastore_span(name = "timeout_community_member", system = "postgresql")] + pub async fn timeout_community_member( + &self, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + muted_until: DateTime, + reason: Option<&str>, + ) -> Result<()> { + timeout_member(&self.pool, community, pubkey, actor, muted_until, reason).await + } + + /// Clear a community timeout/write-block for a member pubkey. + #[datastore_span(name = "untimeout_community_member", system = "postgresql")] + pub async fn untimeout_community_member( + &self, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + ) -> Result { + untimeout_member(&self.pool, community, pubkey, actor).await + } + + /// Fetch the active ban/timeout restriction state for enforcement hot paths. + #[datastore_span(name = "moderation_restriction_state", system = "postgresql")] + pub async fn moderation_restriction_state( + &self, + community: CommunityId, + pubkey: &[u8], + ) -> Result { + restriction_state(&self.pool, community, pubkey).await + } + + /// Fetch the full ban/timeout row for a member pubkey. + #[datastore_span(name = "get_community_ban", system = "postgresql")] + pub async fn get_community_ban( + &self, + community: CommunityId, + pubkey: &[u8], + ) -> Result> { + get_ban(&self.pool, community, pubkey).await + } + + /// List currently restricted members in a community. + #[datastore_span(name = "list_community_restrictions", system = "postgresql")] + pub async fn list_community_restrictions( + &self, + community: CommunityId, + ) -> Result> { + list_restricted(&self.pool, community).await + } + + /// Insert a moderation audit action row. + #[datastore_span(name = "insert_moderation_action", system = "postgresql")] + pub async fn insert_moderation_action( + &self, + community: CommunityId, + action: NewAction<'_>, + ) -> Result { + insert_action(&self.pool, community, action).await + } + + /// List moderation audit action rows, newest first. + #[datastore_span(name = "list_moderation_actions", system = "postgresql")] + pub async fn list_moderation_actions( + &self, + community: CommunityId, + limit: i64, + ) -> Result> { + list_actions(&self.pool, community, limit).await + } +} + #[cfg(test)] mod tests { use super::*; use chrono::Duration; use uuid::Uuid; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 async fn setup_pool() -> PgPool { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") diff --git a/crates/buzz-db/src/partition.rs b/crates/buzz-db/src/store/partition.rs similarity index 94% rename from crates/buzz-db/src/partition.rs rename to crates/buzz-db/src/store/partition.rs index b3803f1b34..ba252f71f4 100644 --- a/crates/buzz-db/src/partition.rs +++ b/crates/buzz-db/src/store/partition.rs @@ -2,11 +2,13 @@ //! //! Call `ensure_future_partitions` on startup and monthly via cron. +use buzz_datastore_tracing::datastore_span; use chrono::{Datelike, TimeZone, Utc}; use sqlx::{PgPool, Row}; use tracing::info; use crate::error::{DbError, Result}; +use crate::Db; /// Tables that may be partition-managed. Allowlist prevents DDL injection. const PARTITIONED_TABLES: &[&str] = &["events", "delivery_log"]; @@ -55,6 +57,14 @@ pub async fn ensure_future_partitions(pool: &PgPool, months_ahead: u32) -> Resul Ok(()) } +impl Db { + /// Ensures monthly partitions exist for the next N months. + #[datastore_span(name = "ensure_future_partitions", system = "postgresql")] + pub async fn ensure_future_partitions(&self, months_ahead: u32) -> Result<()> { + ensure_future_partitions(&self.pool, months_ahead).await + } +} + /// Validate that a partition suffix is digits and underscores only. fn validate_partition_suffix(suffix: &str) -> bool { !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit() || c == '_') diff --git a/crates/buzz-db/src/product_feedback.rs b/crates/buzz-db/src/store/product_feedback.rs similarity index 89% rename from crates/buzz-db/src/product_feedback.rs rename to crates/buzz-db/src/store/product_feedback.rs index 1a9f45e62b..8a0ef36bea 100644 --- a/crates/buzz-db/src/product_feedback.rs +++ b/crates/buzz-db/src/store/product_feedback.rs @@ -3,12 +3,13 @@ //! Feedback retains its source [`CommunityId`] as provenance, but is not a //! community moderation concern and is never inserted into the events table. +use buzz_datastore_tracing::datastore_span; use chrono::{DateTime, Utc}; use serde::Serialize; use sqlx::{PgPool, Row as _}; use uuid::Uuid; -use crate::{error::Result, CommunityId}; +use crate::{error::Result, CommunityId, Db}; /// Validated fields from an accepted product-feedback event. #[derive(Debug, Clone)] @@ -117,6 +118,24 @@ pub async fn list(pool: &PgPool, limit: i64) -> Result, + ) -> Result { + insert(&self.pool, community, feedback).await + } + + /// List product feedback across the deployment, newest first. + #[datastore_span(name = "list_product_feedback", system = "postgresql")] + pub async fn list_product_feedback(&self, limit: i64) -> Result> { + list(&self.pool, limit).await + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/buzz-db/src/push.rs b/crates/buzz-db/src/store/push.rs similarity index 93% rename from crates/buzz-db/src/push.rs rename to crates/buzz-db/src/store/push.rs index 3aa6cd9b3f..fc94843a9c 100644 --- a/crates/buzz-db/src/push.rs +++ b/crates/buzz-db/src/store/push.rs @@ -11,6 +11,8 @@ use sqlx::{PgPool, Row as _}; use uuid::Uuid; use crate::error::Result; +use crate::Db; +use buzz_datastore_tracing::datastore_span; /// Namespace for the per-community push-gate advisory lock. Must match the /// key built inside the `enqueue_push_match_job` trigger (migration 0023): @@ -1278,6 +1280,177 @@ fn row_to_claimed_wake(row: sqlx::postgres::PgRow) -> Result { }) } +impl Db { + /// Exclusively claim a batch of due matcher jobs from one community. + #[datastore_span(name = "claim_due_push_match_batch", system = "postgresql")] + pub async fn claim_due_push_match_batch( + &self, + limit: i64, + lease_until: DateTime, + ) -> Result> { + crate::push::claim_due_match_batch(&self.pool, limit, lease_until).await + } + + /// Load active endpoint-enabled leases eligible for push matching. + #[datastore_span(name = "active_push_match_leases", system = "postgresql")] + pub async fn active_push_match_leases( + &self, + community: CommunityId, + ) -> Result> { + crate::push::active_match_leases(&self.pool, community).await + } + + /// Complete matcher jobs from one claimed batch while the fence holds. + #[datastore_span(name = "complete_push_match_batch", system = "postgresql")] + pub async fn complete_push_match_batch( + &self, + community: CommunityId, + claim_id: uuid::Uuid, + event_ids: &[Vec], + ) -> Result { + crate::push::complete_match_batch(&self.pool, community, claim_id, event_ids).await + } + + /// Release fenced matcher claims from one batch for retry. + #[datastore_span(name = "retry_push_match_batch", system = "postgresql")] + pub async fn retry_push_match_batch( + &self, + community: CommunityId, + claim_id: uuid::Uuid, + event_ids: &[Vec], + next: DateTime, + ) -> Result { + crate::push::retry_match_batch(&self.pool, community, claim_id, event_ids, next).await + } + + /// Delete exhausted matcher jobs (periodic sweep, off the claim path). + #[datastore_span(name = "reap_exhausted_push_matches", system = "postgresql")] + pub async fn reap_exhausted_push_matches(&self) -> Result { + crate::push::reap_exhausted_matches(&self.pool).await + } + + /// Idempotently enqueue a wake for a matched lease and event. + #[datastore_span(name = "enqueue_push_wake", system = "postgresql")] + pub async fn enqueue_push_wake( + &self, + community: CommunityId, + author: &[u8], + installation_id: &str, + wake: crate::push::NewWake<'_>, + ) -> Result { + crate::push::enqueue_wake(&self.pool, community, author, installation_id, wake).await + } + + /// Set-wise [`Self::enqueue_push_wake`]: one transaction per batch. + #[datastore_span(name = "enqueue_push_wakes", system = "postgresql")] + pub async fn enqueue_push_wakes( + &self, + community: CommunityId, + requests: &[crate::push::WakeRequest], + ) -> Result> { + crate::push::enqueue_wakes(&self.pool, community, requests).await + } + + /// Exclusively claim due wake jobs for one community. + #[datastore_span(name = "claim_due_push_wakes", system = "postgresql")] + pub async fn claim_due_push_wakes( + &self, + community: CommunityId, + limit: i64, + lease_until: DateTime, + ) -> Result> { + crate::push::claim_due_wakes(&self.pool, community, limit, lease_until).await + } + + /// Revalidate a wake's claim, source event, and current lease before send. + #[datastore_span(name = "revalidate_push_wake", system = "postgresql")] + pub async fn revalidate_push_wake( + &self, + community: CommunityId, + id: Uuid, + claim_id: Uuid, + ) -> Result { + crate::push::revalidate_wake_for_send(&self.pool, community, id, claim_id).await + } + + /// Mark a fenced wake claim delivered. + #[datastore_span(name = "complete_push_wake", system = "postgresql")] + pub async fn complete_push_wake( + &self, + community: CommunityId, + id: Uuid, + claim_id: Uuid, + ) -> Result { + crate::push::complete_wake(&self.pool, community, id, claim_id).await + } + + /// Release a fenced wake claim for retry at the supplied time. + #[datastore_span(name = "retry_push_wake", system = "postgresql")] + pub async fn retry_push_wake( + &self, + community: CommunityId, + id: Uuid, + claim_id: Uuid, + next: DateTime, + ) -> Result { + crate::push::retry_wake(&self.pool, community, id, claim_id, next).await + } + + /// Mark a fenced wake claim terminally failed. + #[datastore_span(name = "fail_push_wake", system = "postgresql")] + pub async fn fail_push_wake( + &self, + community: CommunityId, + id: Uuid, + claim_id: Uuid, + ) -> Result { + crate::push::fail_wake(&self.pool, community, id, claim_id).await + } + + /// Disable an endpoint only if the specified lease generation is current. + #[datastore_span(name = "disable_push_endpoint", system = "postgresql")] + pub async fn disable_push_endpoint( + &self, + community: CommunityId, + author: &[u8], + installation_id: &str, + generation: i64, + ) -> Result { + crate::push::disable_endpoint_generation( + &self.pool, + community, + author, + installation_id, + generation, + ) + .await + } + + /// Atomically persist a validated kind:30350 event and its effective lease. + #[allow(clippy::too_many_arguments)] + #[datastore_span(name = "accept_push_lease_event", system = "postgresql")] + pub async fn accept_push_lease_event( + &self, + community: CommunityId, + event: &nostr::Event, + installation_id: &str, + version: crate::push::LeaseVersion<'_>, + active: Option>, + max_active_leases: i64, + ) -> Result { + crate::push::accept_lease_event( + &self.pool, + community, + event, + installation_id, + version, + active, + max_active_leases, + ) + .await + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/buzz-db/src/store/reaction.rs b/crates/buzz-db/src/store/reaction.rs new file mode 100644 index 0000000000..1f14adf176 --- /dev/null +++ b/crates/buzz-db/src/store/reaction.rs @@ -0,0 +1,1149 @@ +//! Reaction persistence. +//! +//! One reaction per user per emoji per event. Soft-delete via removed_at. + +use buzz_datastore_tracing::datastore_span; +use chrono::{DateTime, Utc}; +use nostr::Event; +use sqlx::{PgPool, Postgres, Row, Transaction}; +use uuid::Uuid; + +use crate::{ + error::Result, + event::{insert_event_with_thread_metadata_tx, ThreadMetadataParams}, + Db, +}; +use buzz_core::{CommunityId, StoredEvent}; + +// -- Public structs ----------------------------------------------------------- + +/// Result of atomically inserting a kind:7 reaction event and its reaction row. +#[derive(Debug)] +pub enum ReactionEventInsertOutcome { + /// Target event was absent in this community, or was soft-deleted. No writes committed. + TargetMissing, + /// The active `(target, actor, emoji)` reaction already exists. No event was stored. + Duplicate, + /// Reaction row and event transaction committed. + Inserted { + /// Stored reaction event. + stored_event: Box, + /// Whether the event row itself was newly inserted. + was_inserted: bool, + }, +} + +/// A grouped set of reactions for a single emoji on an event. +#[derive(Debug, Clone)] +pub struct ReactionGroup { + /// The emoji character or shortcode used in this reaction group. + pub emoji: String, + /// Total number of active reactions with this emoji. + pub count: i64, + /// Individual users who reacted with this emoji. + pub users: Vec, +} + +/// A single user who reacted with a given emoji. +#[derive(Debug, Clone)] +pub struct ReactionUser { + /// Compressed 33-byte public key of the reacting user. + pub pubkey: Vec, + /// Optional display name resolved from the users table. + pub display_name: Option, + /// Nostr event ID of the kind:7 reaction event (raw bytes), if present. + /// Clients use this to build signed kind:5 deletion events for reaction removal. + pub reaction_event_id: Option>, +} + +/// Bulk reaction entry for embedding in message lists. +#[derive(Debug, Clone)] +pub struct BulkReactionEntry { + /// The event this reaction entry belongs to. + pub event_id: Vec, + /// Partition key timestamp for the event. + pub event_created_at: DateTime, + /// Emoji + count summaries for this event. + pub reactions: Vec, +} + +/// Emoji + count summary (no user list) for bulk fetches. +#[derive(Debug, Clone)] +pub struct ReactionSummary { + /// The emoji character or shortcode. + pub emoji: String, + /// Number of active reactions with this emoji. + pub count: i64, +} + +/// Active reaction row metadata for a specific actor + emoji + target tuple. +#[derive(Debug, Clone)] +pub struct ActiveReactionRecord { + /// Nostr event ID of the reaction event, if this row came from a real kind:7 event. + pub reaction_event_id: Option>, +} + +// -- Write operations --------------------------------------------------------- + +const ADD_REACTION_SQL: &str = r#" + INSERT INTO reactions (community_id, event_created_at, event_id, pubkey, emoji, reaction_event_id) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (community_id, event_created_at, event_id, pubkey, emoji) DO UPDATE SET + created_at = NOW(), + removed_at = NULL, + reaction_event_id = COALESCE(EXCLUDED.reaction_event_id, reactions.reaction_event_id) + WHERE reactions.removed_at IS NOT NULL + "#; + +/// Add (or re-activate) a reaction. +/// +/// Returns `Ok(true)` if the reaction was added or re-activated, `Ok(false)` if +/// the reaction is already active (duplicate, no change made). +/// +/// Uses `INSERT ... ON CONFLICT DO UPDATE` to eliminate the TOCTOU race where +/// two concurrent adds both see no existing row and then race to INSERT. +pub async fn add_reaction( + pool: &PgPool, + community: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + pubkey: &[u8], + emoji: &str, + reaction_event_id: Option<&[u8]>, +) -> Result { + let result = sqlx::query(ADD_REACTION_SQL) + .bind(community.as_uuid()) + .bind(event_created_at) + .bind(event_id) + .bind(pubkey) + .bind(emoji) + .bind(reaction_event_id) + .execute(pool) + .await?; + + // Three cases: + // (a) New reaction (no existing row): INSERT succeeds → rows_affected = 1 → true. + // (b) Reactivating (row exists, removed_at IS NOT NULL): WHERE matches → UPDATE fires + // → rows_affected = 1 → true. + // (c) Active duplicate (row exists, removed_at IS NULL): WHERE fails → no UPDATE + // → rows_affected = 0 → false. Caller should short-circuit and not store the event. + Ok(result.rows_affected() != 0) +} + +/// Add (or re-activate) a reaction inside an existing transaction. +/// +/// Uses the same `INSERT ... ON CONFLICT DO UPDATE ... WHERE removed_at IS NOT NULL` +/// statement as [`add_reaction`], preserving the new / re-activate / active-duplicate +/// semantics while letting callers atomically couple the reaction row to other writes. +pub(crate) async fn add_reaction_tx( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + pubkey: &[u8], + emoji: &str, + reaction_event_id: Option<&[u8]>, +) -> Result { + let result = sqlx::query(ADD_REACTION_SQL) + .bind(community.as_uuid()) + .bind(event_created_at) + .bind(event_id) + .bind(pubkey) + .bind(emoji) + .bind(reaction_event_id) + .execute(&mut **tx) + .await?; + + Ok(result.rows_affected() != 0) +} + +/// Atomically insert a kind:7 reaction event and its reaction row. +/// +/// Ordering is load-bearing: resolve target, upsert/reactivate the reaction row, +/// check `rows_affected`, then insert the kind:7 event. Active duplicates return +/// before event insertion so duplicate reactions never store a duplicate kind:7. +#[allow(clippy::too_many_arguments)] +pub async fn insert_reaction_event_with_thread_metadata( + pool: &PgPool, + community_id: CommunityId, + reaction_event: &Event, + channel_id: Option, + thread_meta: Option>, + target_event_id: &[u8], + actor_pubkey: &[u8], + emoji: &str, +) -> Result { + let mut tx = pool.begin().await?; + + let target_row = sqlx::query( + "SELECT created_at FROM events \ + WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL \ + ORDER BY created_at DESC LIMIT 1", + ) + .bind(community_id.as_uuid()) + .bind(target_event_id) + .fetch_optional(&mut *tx) + .await?; + + let Some(target_row) = target_row else { + tx.rollback().await?; + return Ok(ReactionEventInsertOutcome::TargetMissing); + }; + let target_created_at: DateTime = target_row.get("created_at"); + + // Preserve add_reaction's exact new / re-activate / active-duplicate semantics. + let reaction_inserted = add_reaction_tx( + &mut tx, + community_id, + target_event_id, + target_created_at, + actor_pubkey, + emoji, + Some(reaction_event.id.as_bytes()), + ) + .await?; + + if !reaction_inserted { + tx.rollback().await?; + return Ok(ReactionEventInsertOutcome::Duplicate); + } + + let (stored_event, was_inserted) = insert_event_with_thread_metadata_tx( + &mut tx, + community_id, + reaction_event, + channel_id, + thread_meta, + ) + .await?; + + tx.commit().await?; + + Ok(ReactionEventInsertOutcome::Inserted { + stored_event: Box::new(stored_event), + was_inserted, + }) +} + +/// Soft-delete a reaction by setting `removed_at`. +/// +/// Returns `true` if a row was updated, `false` if not found or already removed. +pub async fn remove_reaction( + pool: &PgPool, + community: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + pubkey: &[u8], + emoji: &str, +) -> Result { + let result = sqlx::query( + r#" + UPDATE reactions + SET removed_at = NOW() + WHERE community_id = $1 + AND event_created_at = $2 + AND event_id = $3 + AND pubkey = $4 + AND emoji = $5 + AND removed_at IS NULL + "#, + ) + .bind(community.as_uuid()) + .bind(event_created_at) + .bind(event_id) + .bind(pubkey) + .bind(emoji) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Soft-delete a reaction by the reaction event's own ID. +/// +/// Returns `true` if a row was updated, `false` if not found or already removed. +pub async fn remove_reaction_by_source_event_id( + pool: &PgPool, + community: CommunityId, + reaction_event_id: &[u8], +) -> Result { + let result = sqlx::query( + r#" + UPDATE reactions + SET removed_at = NOW() + WHERE community_id = $1 + AND reaction_event_id = $2 + AND removed_at IS NULL + "#, + ) + .bind(community.as_uuid()) + .bind(reaction_event_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Look up the active reaction row for one actor + emoji + target tuple. +pub async fn get_active_reaction_record( + pool: &PgPool, + community: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + pubkey: &[u8], + emoji: &str, +) -> Result> { + let row = sqlx::query( + r#" + SELECT reaction_event_id + FROM reactions + WHERE community_id = $1 + AND event_id = $2 + AND event_created_at = $3 + AND pubkey = $4 + AND emoji = $5 + AND removed_at IS NULL + LIMIT 1 + "#, + ) + .bind(community.as_uuid()) + .bind(event_id) + .bind(event_created_at) + .bind(pubkey) + .bind(emoji) + .fetch_optional(pool) + .await?; + + row.map(|row| -> Result { + Ok(ActiveReactionRecord { + reaction_event_id: row.try_get("reaction_event_id")?, + }) + }) + .transpose() +} + +/// Backfill the source event ID on an active reaction row. +/// +/// Called after the kind:7 event is created and stored, to link the +/// reaction row to its source event. Returns `true` if the row was updated. +pub async fn set_reaction_event_id( + pool: &PgPool, + community: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + pubkey: &[u8], + emoji: &str, + reaction_event_id: &[u8], +) -> Result { + let result = sqlx::query( + r#" + UPDATE reactions + SET reaction_event_id = $1 + WHERE community_id = $2 + AND event_created_at = $3 + AND event_id = $4 + AND pubkey = $5 + AND emoji = $6 + AND removed_at IS NULL + "#, + ) + .bind(reaction_event_id) + .bind(community.as_uuid()) + .bind(event_created_at) + .bind(event_id) + .bind(pubkey) + .bind(emoji) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +// -- Read operations ---------------------------------------------------------- + +/// Get all active reactions for an event, grouped by emoji. +/// +/// Returns one [`ReactionGroup`] per emoji, each containing the list of reacting +/// user pubkeys. Display names are NOT resolved here -- callers should enrich via +/// scoped user lookups if needed. +/// +/// `cursor` is reserved for future keyset pagination (currently unused). +pub async fn get_reactions( + pool: &PgPool, + community: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + limit: u32, + _cursor: Option<&str>, +) -> Result> { + // Two-step query: first get the limited set of distinct emoji groups, + // then fetch all rows for those groups. This ensures `limit` applies to + // emoji groups (the API contract), not raw rows — so one busy emoji + // cannot consume the entire page and hide other groups. + let rows = sqlx::query( + r#" + SELECT r.emoji, r.pubkey, r.reaction_event_id + FROM reactions r + INNER JOIN ( + SELECT DISTINCT emoji + FROM reactions + WHERE community_id = $1 + AND event_id = $2 + AND event_created_at = $3 + AND removed_at IS NULL + ORDER BY emoji + LIMIT $4 + ) g ON g.emoji = r.emoji + WHERE r.community_id = $1 + AND r.event_id = $2 + AND r.event_created_at = $3 + AND r.removed_at IS NULL + ORDER BY r.emoji, r.created_at + "#, + ) + .bind(community.as_uuid()) + .bind(event_id) + .bind(event_created_at) + .bind(limit as i64) + .fetch_all(pool) + .await?; + + // Group individual rows by emoji in Rust. + let mut groups: Vec = Vec::new(); + let mut current_emoji: Option = None; + let mut current_users: Vec = Vec::new(); + + for row in &rows { + let emoji: String = row.try_get("emoji")?; + let pubkey: Vec = row.try_get("pubkey")?; + let reaction_event_id: Option> = row.try_get("reaction_event_id")?; + + if current_emoji.as_ref() != Some(&emoji) { + if let Some(prev_emoji) = current_emoji.take() { + let count = current_users.len() as i64; + groups.push(ReactionGroup { + emoji: prev_emoji, + count, + users: std::mem::take(&mut current_users), + }); + } + current_emoji = Some(emoji); + } + + current_users.push(ReactionUser { + pubkey, + display_name: None, + reaction_event_id, + }); + } + + // Flush the final group. + if let Some(emoji) = current_emoji { + let count = current_users.len() as i64; + groups.push(ReactionGroup { + emoji, + count, + users: current_users, + }); + } + + Ok(groups) +} + +/// Batch-fetch emoji counts for a set of (event_id, event_created_at) pairs. +/// +/// Returns one [`BulkReactionEntry`] per input pair that has at least one +/// active reaction. Pairs with no reactions are omitted. +pub async fn get_reactions_bulk( + pool: &PgPool, + community: CommunityId, + event_ids: &[(&[u8], DateTime)], +) -> Result> { + if event_ids.is_empty() { + return Ok(Vec::new()); + } + + // Run one query per event. For typical message-list sizes (<=100 events) + // this is acceptable; a single-query approach with dynamic IN clauses over + // composite keys can be added later if needed. + let mut entries = Vec::new(); + + for (event_id, event_created_at) in event_ids { + let rows = sqlx::query( + r#" + SELECT emoji, COUNT(*) AS count + FROM reactions + WHERE community_id = $1 + AND event_id = $2 + AND event_created_at = $3 + AND removed_at IS NULL + GROUP BY emoji + ORDER BY emoji + "#, + ) + .bind(community.as_uuid()) + .bind(*event_id) + .bind(event_created_at) + .fetch_all(pool) + .await?; + + if rows.is_empty() { + continue; + } + + let mut reactions = Vec::with_capacity(rows.len()); + for row in rows { + let emoji: String = row.try_get("emoji")?; + let count: i64 = row.try_get("count")?; + reactions.push(ReactionSummary { emoji, count }); + } + + entries.push(BulkReactionEntry { + event_id: event_id.to_vec(), + event_created_at: *event_created_at, + reactions, + }); + } + + Ok(entries) +} + +// -- Db API ------------------------------------------------------------------- + +impl Db { + /// Atomically insert a kind:7 reaction event and its reaction row. + #[allow(clippy::too_many_arguments)] + #[datastore_span( + name = "insert_reaction_event_with_thread_metadata", + system = "postgresql" + )] + pub async fn insert_reaction_event_with_thread_metadata( + &self, + community_id: CommunityId, + event: &nostr::Event, + channel_id: Option, + thread_meta: Option>, + target_event_id: &[u8], + actor_pubkey: &[u8], + emoji: &str, + ) -> Result { + let outcome = crate::reaction::insert_reaction_event_with_thread_metadata( + &self.pool, + community_id, + event, + channel_id, + thread_meta, + target_event_id, + actor_pubkey, + emoji, + ) + .await?; + if let ReactionEventInsertOutcome::Inserted { + was_inserted: true, .. + } = &outcome + { + if let Err(e) = + crate::insert_mentions(&self.pool, community_id, event, channel_id).await + { + tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); + } + } + Ok(outcome) + } + + /// Add (or re-activate) a reaction. + #[datastore_span(name = "add_reaction", system = "postgresql")] + pub async fn add_reaction( + &self, + community: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + pubkey: &[u8], + emoji: &str, + reaction_event_id: Option<&[u8]>, + ) -> Result { + crate::reaction::add_reaction( + &self.pool, + community, + event_id, + event_created_at, + pubkey, + emoji, + reaction_event_id, + ) + .await + } + + /// Soft-delete a reaction. + #[datastore_span(name = "remove_reaction", system = "postgresql")] + pub async fn remove_reaction( + &self, + community: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + pubkey: &[u8], + emoji: &str, + ) -> Result { + crate::reaction::remove_reaction( + &self.pool, + community, + event_id, + event_created_at, + pubkey, + emoji, + ) + .await + } + + /// Soft-delete a reaction by its source event ID. + #[datastore_span(name = "remove_reaction_by_source_event_id", system = "postgresql")] + pub async fn remove_reaction_by_source_event_id( + &self, + community: CommunityId, + reaction_event_id: &[u8], + ) -> Result { + crate::reaction::remove_reaction_by_source_event_id( + &self.pool, + community, + reaction_event_id, + ) + .await + } + + /// Look up the active reaction row for one actor + emoji + target tuple. + #[datastore_span(name = "get_active_reaction_record", system = "postgresql")] + pub async fn get_active_reaction_record( + &self, + community: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + pubkey: &[u8], + emoji: &str, + ) -> Result> { + crate::reaction::get_active_reaction_record( + &self.pool, + community, + event_id, + event_created_at, + pubkey, + emoji, + ) + .await + } + + /// Backfill the source event ID on an active reaction row. + #[datastore_span(name = "set_reaction_event_id", system = "postgresql")] + pub async fn set_reaction_event_id( + &self, + community: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + pubkey: &[u8], + emoji: &str, + reaction_event_id: &[u8], + ) -> Result { + crate::reaction::set_reaction_event_id( + &self.pool, + community, + event_id, + event_created_at, + pubkey, + emoji, + reaction_event_id, + ) + .await + } + + /// Get all active reactions for an event, grouped by emoji. + #[datastore_span(name = "get_reactions", system = "postgresql")] + pub async fn get_reactions( + &self, + community: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + limit: u32, + cursor: Option<&str>, + ) -> Result> { + crate::reaction::get_reactions( + &self.pool, + community, + event_id, + event_created_at, + limit, + cursor, + ) + .await + } + + /// Batch-fetch emoji counts for a set of (event_id, event_created_at) pairs. + #[datastore_span(name = "get_reactions_bulk", system = "postgresql")] + pub async fn get_reactions_bulk( + &self, + community: CommunityId, + event_ids: &[(&[u8], DateTime)], + ) -> Result> { + crate::reaction::get_reactions_bulk(&self.pool, community, event_ids).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + error::DbError, + event::{get_event_by_id, insert_event}, + }; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + + async fn setup_pool() -> PgPool { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + + PgPool::connect(&database_url) + .await + .expect("connect to test DB") + } + + async fn make_test_community(pool: &PgPool) -> Uuid { + let id = Uuid::new_v4(); + let host = format!("reaction-test-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert test community"); + id + } + + fn make_text_event(content: &str) -> nostr::Event { + EventBuilder::new(Kind::Custom(9), content) + .sign_with_keys(&Keys::generate()) + .expect("sign text event") + } + + fn make_reaction_event(keys: &Keys, target_id_hex: &str, emoji: &str) -> nostr::Event { + let nonce = Uuid::new_v4().to_string(); + EventBuilder::new(Kind::Custom(7), emoji) + .tags(vec![ + Tag::parse(["e", target_id_hex]).expect("reaction e tag"), + Tag::parse(["nonce", nonce.as_str()]).expect("nonce tag"), + ]) + .sign_with_keys(keys) + .expect("sign reaction event") + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn reaction_single_tx_stores_wrapped_max_shortcode() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let target = make_text_event("long custom emoji target"); + insert_event(&pool, community, &target, None) + .await + .expect("insert target"); + + let actor = Keys::generate(); + let emoji = format!(":{}:", "a".repeat(64)); + let reaction = make_reaction_event(&actor, &target.id.to_hex(), &emoji); + let outcome = insert_reaction_event_with_thread_metadata( + &pool, + community, + &reaction, + None, + None, + target.id.as_bytes(), + &actor.public_key().to_bytes(), + &emoji, + ) + .await + .expect("store wrapped 64-character shortcode"); + + assert!(matches!( + outcome, + ReactionEventInsertOutcome::Inserted { + was_inserted: true, + .. + } + )); + assert_eq!(emoji.chars().count(), 66); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn reaction_single_tx_duplicate_short_circuit_stores_no_event() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let target = make_text_event("reaction target"); + insert_event(&pool, community, &target, None) + .await + .expect("insert target"); + + let actor = Keys::generate(); + let actor_pubkey = actor.public_key().to_bytes(); + let target_hex = target.id.to_hex(); + let first = make_reaction_event(&actor, &target_hex, "👍"); + let second = make_reaction_event(&actor, &target_hex, "👍"); + + let first_outcome = insert_reaction_event_with_thread_metadata( + &pool, + community, + &first, + None, + None, + target.id.as_bytes(), + &actor_pubkey, + "👍", + ) + .await + .expect("first reaction insert"); + assert!(matches!( + first_outcome, + ReactionEventInsertOutcome::Inserted { + was_inserted: true, + .. + } + )); + + let duplicate = insert_reaction_event_with_thread_metadata( + &pool, + community, + &second, + None, + None, + target.id.as_bytes(), + &actor_pubkey, + "👍", + ) + .await + .expect("duplicate reaction insert"); + assert!(matches!(duplicate, ReactionEventInsertOutcome::Duplicate)); + + let duplicate_event = get_event_by_id(&pool, community, second.id.as_bytes()) + .await + .expect("lookup duplicate reaction event"); + assert!( + duplicate_event.is_none(), + "active duplicate reaction must short-circuit before storing kind:7 event" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn reaction_single_tx_cross_community_target_rejected() { + let pool = setup_pool().await; + let community_a = CommunityId::from_uuid(make_test_community(&pool).await); + let community_b = CommunityId::from_uuid(make_test_community(&pool).await); + let target = make_text_event("community A target only"); + insert_event(&pool, community_a, &target, None) + .await + .expect("insert target in A"); + + let actor = Keys::generate(); + let actor_pubkey = actor.public_key().to_bytes(); + let reaction = make_reaction_event(&actor, &target.id.to_hex(), "👍"); + + let outcome = insert_reaction_event_with_thread_metadata( + &pool, + community_b, + &reaction, + None, + None, + target.id.as_bytes(), + &actor_pubkey, + "👍", + ) + .await + .expect("cross-community reaction attempt"); + assert!(matches!(outcome, ReactionEventInsertOutcome::TargetMissing)); + + assert!( + get_event_by_id(&pool, community_b, reaction.id.as_bytes()) + .await + .expect("lookup B reaction event") + .is_none(), + "reaction event must not store when target exists only in another community" + ); + assert!( + crate::reaction::get_active_reaction_record( + &pool, + community_b, + target.id.as_bytes(), + DateTime::from_timestamp(target.created_at.as_secs() as i64, 0).unwrap(), + &actor_pubkey, + "👍", + ) + .await + .expect("lookup B reaction row") + .is_none(), + "reaction row must not be inserted for cross-community target miss" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn reaction_single_tx_event_insert_failure_rolls_back_reaction() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let target = make_text_event("rollback target"); + insert_event(&pool, community, &target, None) + .await + .expect("insert target"); + + let actor = Keys::generate(); + let actor_pubkey = actor.public_key().to_bytes(); + let target_hex = target.id.to_hex(); + let bad_reaction = EventBuilder::new(Kind::Custom(20000), "👍") + .tags(vec![ + Tag::parse(["e", target_hex.as_str()]).expect("reaction e tag") + ]) + .sign_with_keys(&actor) + .expect("sign ephemeral reaction-shaped event"); + let target_created_at = DateTime::from_timestamp(target.created_at.as_secs() as i64, 0) + .expect("target timestamp"); + + let err = insert_reaction_event_with_thread_metadata( + &pool, + community, + &bad_reaction, + None, + None, + target.id.as_bytes(), + &actor_pubkey, + "👍", + ) + .await + .expect_err("ephemeral event insert must fail after reaction upsert attempt"); + assert!(matches!(err, DbError::EphemeralEventRejected(20000))); + + assert!( + crate::reaction::get_active_reaction_record( + &pool, + community, + target.id.as_bytes(), + target_created_at, + &actor_pubkey, + "👍", + ) + .await + .expect("lookup reaction row after rollback") + .is_none(), + "transaction rollback must remove the reaction row when event insert fails" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn reaction_single_tx_reactivates_soft_deleted_reaction() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let target = make_text_event("reactivation target"); + insert_event(&pool, community, &target, None) + .await + .expect("insert target"); + + let actor = Keys::generate(); + let actor_pubkey = actor.public_key().to_bytes(); + let target_hex = target.id.to_hex(); + let target_created_at = DateTime::from_timestamp(target.created_at.as_secs() as i64, 0) + .expect("target timestamp"); + let first = make_reaction_event(&actor, &target_hex, "👍"); + let second = make_reaction_event(&actor, &target_hex, "👍"); + + assert!(matches!( + insert_reaction_event_with_thread_metadata( + &pool, + community, + &first, + None, + None, + target.id.as_bytes(), + &actor_pubkey, + "👍", + ) + .await + .expect("first reaction insert"), + ReactionEventInsertOutcome::Inserted { .. } + )); + assert!(crate::reaction::remove_reaction( + &pool, + community, + target.id.as_bytes(), + target_created_at, + &actor_pubkey, + "👍", + ) + .await + .expect("soft delete reaction")); + + let outcome = insert_reaction_event_with_thread_metadata( + &pool, + community, + &second, + None, + None, + target.id.as_bytes(), + &actor_pubkey, + "👍", + ) + .await + .expect("reactivate reaction"); + assert!(matches!( + outcome, + ReactionEventInsertOutcome::Inserted { + was_inserted: true, + .. + } + )); + + let active = crate::reaction::get_active_reaction_record( + &pool, + community, + target.id.as_bytes(), + target_created_at, + &actor_pubkey, + "👍", + ) + .await + .expect("active record after reactivation") + .expect("reaction active after reactivation"); + assert_eq!( + active.reaction_event_id.as_deref(), + Some(second.id.as_bytes().as_slice()), + "reactivation through the tx path must preserve add_reaction's source-id update semantics" + ); + } + + /// BUG-5 regression: the `reactions` table is community-scoped + /// (`PK (community_id, event_created_at, event_id, pubkey, emoji)`), so a + /// reaction added under community A must be invisible and unremovable from + /// community B — even for the *identical* `(event_id, pubkey, emoji)` shape. + /// Before the fix, `add_reaction` omitted `community_id` (NOT NULL → 500) and + /// every read/remove filtered `event_id` only (latent cross-tenant bleed). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn reactions_are_scoped_to_community() { + let pool = setup_pool().await; + let db = Db::from_pool(pool.clone()); + let community_a = CommunityId::from_uuid(make_test_community(&pool).await); + let community_b = CommunityId::from_uuid(make_test_community(&pool).await); + + // Identical referenced-event shape across both tenants. + let event_id = [0xABu8; 32]; + let event_created_at = Utc::now(); + let pubkey = [7u8; 32]; + let emoji = "👍"; + + // (1) Add succeeds under A (this INSERT 500'd before the fix). + assert!( + db.add_reaction( + community_a, + &event_id, + event_created_at, + &pubkey, + emoji, + None + ) + .await + .expect("add reaction under A"), + "first reaction under A must be inserted" + ); + // Idempotent: re-adding the same active reaction is a no-op. + assert!( + !db.add_reaction( + community_a, + &event_id, + event_created_at, + &pubkey, + emoji, + None + ) + .await + .expect("duplicate reaction under A"), + "active duplicate under A must not re-insert" + ); + + // (2) Visible on A, invisible on B (grouped read path). + let groups_a = db + .get_reactions(community_a, &event_id, event_created_at, 100, None) + .await + .expect("get reactions A"); + assert_eq!(groups_a.len(), 1, "A must see its own reaction group"); + assert_eq!(groups_a[0].emoji, emoji); + assert_eq!(groups_a[0].count, 1); + + let groups_b = db + .get_reactions(community_b, &event_id, event_created_at, 100, None) + .await + .expect("get reactions B"); + assert!( + groups_b.is_empty(), + "B must NOT see A's reaction for the same event shape, got {groups_b:?}" + ); + + // (3) Active-record lookup is scoped: present on A, absent on B. + assert!( + db.get_active_reaction_record(community_a, &event_id, event_created_at, &pubkey, emoji) + .await + .expect("active record A") + .is_some(), + "A's active reaction record must be present" + ); + assert!( + db.get_active_reaction_record(community_b, &event_id, event_created_at, &pubkey, emoji) + .await + .expect("active record B") + .is_none(), + "B must not find A's active reaction record" + ); + + // (4) B can add the identical shape independently (no PK collision). + assert!( + db.add_reaction( + community_b, + &event_id, + event_created_at, + &pubkey, + emoji, + None + ) + .await + .expect("add reaction under B"), + "B must be able to add the same shape as its own scoped row" + ); + + // (5) Removing from B does not touch A's row. + assert!( + db.remove_reaction(community_b, &event_id, event_created_at, &pubkey, emoji) + .await + .expect("remove under B"), + "B remove must affect B's own row" + ); + assert!( + db.get_active_reaction_record(community_a, &event_id, event_created_at, &pubkey, emoji) + .await + .expect("active record A after B remove") + .is_some(), + "A's reaction must survive a B-side removal" + ); + + // (6) A remove affects only A; A's read now empty. + assert!( + db.remove_reaction(community_a, &event_id, event_created_at, &pubkey, emoji) + .await + .expect("remove under A"), + "A remove must affect A's row" + ); + let groups_a_after = db + .get_reactions(community_a, &event_id, event_created_at, 100, None) + .await + .expect("get reactions A after remove"); + assert!( + groups_a_after.is_empty(), + "A's reaction must be gone after A removes it" + ); + } +} diff --git a/crates/buzz-db/src/relay_admin_actions.rs b/crates/buzz-db/src/store/relay_admin_actions.rs similarity index 89% rename from crates/buzz-db/src/relay_admin_actions.rs rename to crates/buzz-db/src/store/relay_admin_actions.rs index 9835c52624..438543da58 100644 --- a/crates/buzz-db/src/relay_admin_actions.rs +++ b/crates/buzz-db/src/store/relay_admin_actions.rs @@ -10,6 +10,7 @@ //! //! Lane ownership: relay admin API (Duncan). +use buzz_datastore_tracing::datastore_span; use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row as _}; use uuid::Uuid; @@ -1654,6 +1655,393 @@ fn row_to_outbox_claimed(row: sqlx::postgres::PgRow) -> Result { }) } +impl crate::Db { + /// Atomic decision-only report closure: CAS open→terminal + audit row in one transaction. + #[allow(clippy::too_many_arguments)] + #[datastore_span(name = "resolve_report_decision_atomic", system = "postgresql")] + pub async fn resolve_report_decision_atomic( + &self, + community_id: CommunityId, + report_id: uuid::Uuid, + terminal_status: &str, + audit_action: &str, + actor_pubkey: &[u8], + actor_authority: &str, + target_pubkey: Option<&[u8]>, + target_event_id: Option<&[u8]>, + channel_id: Option, + reason: Option<&str>, + ) -> Result { + resolve_report_decision_atomic( + &self.pool, + community_id, + report_id, + terminal_status, + audit_action, + actor_pubkey, + actor_authority, + target_pubkey, + target_event_id, + channel_id, + reason, + ) + .await + } + + /// Attempt to claim a report for HTTP enforcement (CAS open → processing). + #[allow(clippy::too_many_arguments)] + #[datastore_span(name = "claim_report_for_enforcement", system = "postgresql")] + pub async fn claim_report_for_enforcement( + &self, + community_id: CommunityId, + report_id: uuid::Uuid, + request_id: uuid::Uuid, + actor_pubkey: &[u8], + actor_role: &str, + action: &str, + reason: Option<&str>, + timeout_until: Option>, + audit_action: &str, + actor_authority: &str, + target_pubkey: Option<&[u8]>, + target_event_id: Option<&[u8]>, + channel_id: Option, + ) -> Result { + claim_report( + &self.pool, + community_id, + report_id, + request_id, + actor_pubkey, + actor_role, + action, + reason, + timeout_until, + audit_action, + actor_authority, + target_pubkey, + target_event_id, + channel_id, + ) + .await + } + + /// Advance an action from 'pending' to 'enforcing'. + #[datastore_span(name = "begin_enforcing_action", system = "postgresql")] + pub async fn begin_enforcing_action(&self, action_id: uuid::Uuid) -> Result { + begin_enforcing(&self.pool, action_id).await + } + + /// Commit the core mutation step (advance step_marker to 'mutation_committed'). + #[datastore_span(name = "commit_action_mutation_step", system = "postgresql")] + pub async fn commit_action_mutation_step(&self, action_id: uuid::Uuid) -> Result { + commit_mutation_step(&self.pool, action_id).await + } + + /// Finalize enforcement: action → succeeded, report → terminal status, + /// and enqueue outbox delivery rows atomically. + #[allow(clippy::too_many_arguments)] + #[datastore_span(name = "finalize_action_success", system = "postgresql")] + pub async fn finalize_action_success( + &self, + action_id: uuid::Uuid, + community_id: CommunityId, + report_id: uuid::Uuid, + terminal_status: &str, + actor_pubkey: &[u8], + action_name: &str, + target_pubkey: Option<&[u8]>, + target_event_id: Option<&[u8]>, + channel_id: Option, + reason: Option<&str>, + timeout_until: Option>, + ) -> Result { + finalize_success( + &self.pool, + action_id, + community_id, + report_id, + terminal_status, + actor_pubkey, + action_name, + target_pubkey, + target_event_id, + channel_id, + reason, + timeout_until, + ) + .await + } + + /// Atomically execute a ban mutation and commit the step marker. + /// Returns `true` if this driver committed the marker, `false` if already marked or lease lost. + #[datastore_span(name = "execute_ban_with_marker", system = "postgresql")] + pub async fn execute_ban_with_marker( + &self, + action_id: uuid::Uuid, + lease_token: uuid::Uuid, + community_id: CommunityId, + target_pubkey: &[u8], + actor_pubkey: &[u8], + reason: Option<&str>, + ) -> Result { + execute_ban_with_marker( + &self.pool, + action_id, + lease_token, + community_id, + target_pubkey, + actor_pubkey, + reason, + ) + .await + } + + /// Atomically execute a timeout mutation and commit the step marker. + /// Returns `true` if this driver committed the marker, `false` if already marked or lease lost. + #[allow(clippy::too_many_arguments)] + #[datastore_span(name = "execute_timeout_with_marker", system = "postgresql")] + pub async fn execute_timeout_with_marker( + &self, + action_id: uuid::Uuid, + lease_token: uuid::Uuid, + community_id: CommunityId, + target_pubkey: &[u8], + actor_pubkey: &[u8], + until: chrono::DateTime, + reason: Option<&str>, + ) -> Result { + execute_timeout_with_marker( + &self.pool, + action_id, + lease_token, + community_id, + target_pubkey, + actor_pubkey, + until, + reason, + ) + .await + } + + /// Atomically execute a kick mutation and commit the step marker. + /// Returns `Removed` (member was present), `AlreadyGone` (absent before this action), + /// or `AlreadyMarked` (marker already committed by another driver or lease lost). + #[datastore_span(name = "execute_kick_with_marker", system = "postgresql")] + pub async fn execute_kick_with_marker( + &self, + action_id: uuid::Uuid, + lease_token: uuid::Uuid, + community_id: CommunityId, + channel_id: uuid::Uuid, + target_pubkey: &[u8], + actor_pubkey: &[u8], + ) -> Result { + execute_kick_with_marker( + &self.pool, + action_id, + lease_token, + community_id, + channel_id, + target_pubkey, + actor_pubkey, + ) + .await + } + + /// Atomically execute a soft-delete mutation and commit the step marker. + /// Returns `true` if this driver committed the marker, `false` if already marked or lease lost. + #[datastore_span(name = "execute_delete_with_marker", system = "postgresql")] + pub async fn execute_delete_with_marker( + &self, + action_id: uuid::Uuid, + lease_token: uuid::Uuid, + community_id: CommunityId, + target_event_id: &[u8], + parent_event_id: Option<&[u8]>, + root_event_id: Option<&[u8]>, + ) -> Result { + execute_delete_with_marker( + &self.pool, + action_id, + lease_token, + community_id, + target_event_id, + parent_event_id, + root_event_id, + ) + .await + } + + /// Acquire the action mutation lease (prevents concurrent double-mutation). + #[datastore_span(name = "acquire_admin_action_lease", system = "postgresql")] + pub async fn acquire_admin_action_lease( + &self, + action_id: uuid::Uuid, + lease_until: chrono::DateTime, + ) -> Result { + acquire_action_lease(&self.pool, action_id, lease_until).await + } + + /// Release the action mutation lease. No-op if caller no longer holds the token. + #[datastore_span(name = "release_admin_action_lease", system = "postgresql")] + pub async fn release_admin_action_lease( + &self, + action_id: uuid::Uuid, + lease_token: uuid::Uuid, + ) -> Result<()> { + release_action_lease(&self.pool, action_id, lease_token).await + } + + /// Claim a batch of stranded `relay_admin_actions` for the action recovery worker. + #[datastore_span(name = "claim_stranded_admin_action_batch", system = "postgresql")] + pub async fn claim_stranded_admin_action_batch( + &self, + worker_id: &str, + lease_until: chrono::DateTime, + batch_size: i64, + ) -> Result> { + claim_stranded_action_batch(&self.pool, worker_id, lease_until, batch_size).await + } + + /// Record a pre-mutation enforcement failure (keeps report in 'processing'). + #[datastore_span(name = "record_action_failure", system = "postgresql")] + pub async fn record_action_failure( + &self, + action_id: uuid::Uuid, + lease_token: uuid::Uuid, + error: &str, + ) -> Result { + record_failure(&self.pool, action_id, lease_token, error).await + } + + /// Cancel a pre-mutation failed action (returns report to 'open'), + /// attributing the cancel to `cancelled_by`. + #[datastore_span(name = "cancel_admin_action", system = "postgresql")] + pub async fn cancel_admin_action( + &self, + action_id: uuid::Uuid, + community_id: CommunityId, + report_id: uuid::Uuid, + cancelled_by: &[u8], + ) -> Result { + cancel_action(&self.pool, action_id, community_id, report_id, cancelled_by).await + } + + /// Reopen a terminal report (resolved|dismissed|escalated → open) with a + /// durable `reopen` audit row, keyed idempotent on `request_id`. + #[datastore_span(name = "reopen_report", system = "postgresql")] + pub async fn reopen_report( + &self, + community_id: CommunityId, + report_id: uuid::Uuid, + request_id: uuid::Uuid, + actor_pubkey: &[u8], + actor_role: &str, + reason: Option<&str>, + ) -> Result { + reopen_report( + &self.pool, + community_id, + report_id, + request_id, + actor_pubkey, + actor_role, + reason, + ) + .await + } + + /// Fetch an action record by ID. + #[datastore_span(name = "get_admin_action", system = "postgresql")] + pub async fn get_admin_action( + &self, + action_id: uuid::Uuid, + ) -> Result> { + get_action(&self.pool, action_id).await + } + + /// Enqueue an outbox artifact/notice delivery command. + #[datastore_span(name = "enqueue_admin_outbox", system = "postgresql")] + pub async fn enqueue_admin_outbox( + &self, + action_id: uuid::Uuid, + task_type: &str, + payload: serde_json::Value, + dedup_key: &str, + ) -> Result<()> { + enqueue_outbox(&self.pool, action_id, task_type, payload, dedup_key).await + } + + /// Mark an outbox record as delivered, fenced by the claim token. + /// Returns `true` if updated, `false` if ownership was already lost. + #[datastore_span(name = "mark_admin_outbox_delivered", system = "postgresql")] + pub async fn mark_admin_outbox_delivered( + &self, + outbox_id: uuid::Uuid, + claim_token: uuid::Uuid, + ) -> Result { + mark_outbox_delivered(&self.pool, outbox_id, claim_token).await + } + + /// Mark an outbox record as failed, fenced by the claim token. + /// Returns `true` if updated, `false` if ownership was already lost. + #[datastore_span(name = "fail_admin_outbox_row", system = "postgresql")] + pub async fn fail_admin_outbox_row( + &self, + outbox_id: uuid::Uuid, + claim_token: uuid::Uuid, + error: &str, + ) -> Result { + fail_outbox_row(&self.pool, outbox_id, claim_token, error).await + } + + /// Claim a batch of pending outbox rows for the given worker pod. + #[datastore_span(name = "claim_pending_admin_outbox_batch", system = "postgresql")] + pub async fn claim_pending_admin_outbox_batch( + &self, + worker_id: &str, + lease_until: chrono::DateTime, + batch_size: i64, + ) -> Result> { + claim_pending_outbox_batch(&self.pool, worker_id, lease_until, batch_size).await + } + + /// List pending outbox records for an action. + #[datastore_span(name = "list_pending_admin_outbox", system = "postgresql")] + pub async fn list_pending_admin_outbox( + &self, + action_id: uuid::Uuid, + ) -> Result> { + list_pending_outbox(&self.pool, action_id).await + } + + /// Deployment-authority kick: remove a member without requiring tenant owner/admin actor. + #[datastore_span(name = "deploy_kick_member", system = "postgresql")] + pub async fn deploy_kick_member( + &self, + community_id: CommunityId, + channel_id: uuid::Uuid, + target_pubkey: &[u8], + actor_pubkey: &[u8], + ) -> Result { + deploy_kick_member( + &self.pool, + community_id, + channel_id, + target_pubkey, + actor_pubkey, + ) + .await + } + + /// Update product_feedback status (operator-managed lifecycle). + #[datastore_span(name = "update_feedback_status", system = "postgresql")] + pub async fn update_feedback_status(&self, id: uuid::Uuid, status: &str) -> Result { + update_feedback_status(&self.pool, id, status).await + } +} + #[cfg(test)] mod tests { use super::*; @@ -1661,7 +2049,7 @@ mod tests { use sqlx::PgPool; use uuid::Uuid; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 async fn setup_pool() -> PgPool { let url = diff --git a/crates/buzz-db/src/relay_invite.rs b/crates/buzz-db/src/store/relay_invite.rs similarity index 94% rename from crates/buzz-db/src/relay_invite.rs rename to crates/buzz-db/src/store/relay_invite.rs index 14331b022f..1424829933 100644 --- a/crates/buzz-db/src/relay_invite.rs +++ b/crates/buzz-db/src/store/relay_invite.rs @@ -21,11 +21,12 @@ use buzz_core::invite::{ encode_v2_code, hash_v2_code, MAX_INVITE_TTL_SECS, MAX_INVITE_USES, MIN_INVITE_TTL_SECS, V2_SECRET_LEN, }; +use buzz_datastore_tracing::datastore_span; use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row as _}; use crate::error::Result; -use crate::CommunityId; +use crate::{CommunityId, Db}; /// Outcome of a v2 invite claim. Expected invalid/expired/exhausted states are /// typed variants so the relay layer can map them to distinct HTTP responses @@ -380,6 +381,53 @@ pub async fn claim_relay_invite( }) } +impl Db { + /// Mints a v2 use-limited relay invite. The plaintext code is returned + /// exactly once; only its SHA-256 hash is persisted. + /// + /// `max_uses` is `None` for unlimited or `Some(1..=10000)`. + /// `ttl_secs` must be in the shared invite lifetime range. + #[datastore_span(name = "mint_relay_invite", system = "postgresql")] + pub async fn mint_relay_invite( + &self, + community: CommunityId, + created_by: &str, + ttl_secs: u64, + max_uses: Option, + ) -> Result { + mint_relay_invite(&self.pool, community, created_by, ttl_secs, max_uses).await + } + + /// Delete one bounded batch of invites expired before `cutoff`. + #[datastore_span(name = "reap_expired_relay_invites", system = "postgresql")] + pub async fn reap_expired_relay_invites(&self, cutoff: DateTime) -> Result { + reap_expired_relay_invites(&self.pool, cutoff).await + } + + /// Atomically claims a v2 relay invite. The full redemption (membership + /// insert, policy evidence, use_count increment) runs in one PostgreSQL + /// transaction with `FOR UPDATE` on the invite row. + /// + /// `token_hash` is the SHA-256 of the presented v2 code (32 bytes). + #[datastore_span(name = "claim_relay_invite", system = "postgresql")] + pub async fn claim_relay_invite( + &self, + community: CommunityId, + token_hash: &[u8; 32], + claimer_pubkey: &str, + policy_version: Option<&str>, + ) -> Result { + claim_relay_invite( + &self.pool, + community, + token_hash, + claimer_pubkey, + policy_version, + ) + .await + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/store/relay_members.rs similarity index 71% rename from crates/buzz-db/src/relay_members.rs rename to crates/buzz-db/src/store/relay_members.rs index 3cb86e8a43..0a20b011eb 100644 --- a/crates/buzz-db/src/relay_members.rs +++ b/crates/buzz-db/src/store/relay_members.rs @@ -6,11 +6,14 @@ //! community B (NIP-43 admission confinement). `pubkey` values are 64-char //! lowercase hex strings. +use buzz_core::StoredEvent; +use buzz_datastore_tracing::datastore_span; use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row as _}; +use uuid::Uuid; -use crate::error::Result; -use crate::CommunityId; +use crate::error::{DbError, Result}; +use crate::{observability, replaceable, CommunityId, Db, RouteDecision, RoutePredicate}; /// A single relay member record. #[derive(Debug, Clone)] @@ -609,6 +612,361 @@ pub async fn backfill_from_allowlist(pool: &PgPool, community: CommunityId) -> R Ok(result.rows_affected()) } +impl Db { + /// Returns `true` if `pubkey` (64-char hex) is a member of `community`. + /// + /// Replica-routed on the bounded arm — the one PERMISSION read routed by + /// explicit product decision (bounded-stale membership beats the 10s + /// cache it replaced). Admits and revokes may lag by at most the budget + /// `B`; everything else fails closed to the writer, exactly like + /// [`Db::query_events_routed_bounded`]. Not precedent for routing other + /// permission reads. + #[datastore_span(name = "is_relay_member", system = "postgresql")] + pub async fn is_relay_member(&self, community: CommunityId, pubkey: &str) -> Result { + let path = "relay_membership"; + match self.route_read(path, RoutePredicate::Bounded).await { + RouteDecision::Replica(mut tx, _entry, reason) => { + match is_relay_member_on(&mut tx, community, pubkey).await { + Ok(is_member) => { + Self::record_route(path, "replica", reason); + Ok(is_member) + } + Err(e) => { + tracing::warn!(path, "replica read failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + is_relay_member(&self.pool, community, pubkey).await + } + } + } + RouteDecision::Writer => is_relay_member(&self.pool, community, pubkey).await, + } + } + + /// Returns the relay member record for `pubkey` in `community`, or `None` if not found. + #[datastore_span(name = "get_relay_member", system = "postgresql")] + pub async fn get_relay_member( + &self, + community: CommunityId, + pubkey: &str, + ) -> Result> { + get_relay_member(&self.pool, community, pubkey).await + } + + /// Returns all relay members of `community` ordered by `created_at` ascending. + #[datastore_span(name = "list_relay_members", system = "postgresql")] + pub async fn list_relay_members(&self, community: CommunityId) -> Result> { + list_relay_members(&self.pool, community).await + } + + /// Adds a new relay member to `community`. + /// + /// Returns `true` if the row was actually inserted, `false` if the pubkey + /// already existed in `community` (idempotent — `ON CONFLICT DO NOTHING`). + #[datastore_span(name = "add_relay_member", system = "postgresql")] + pub async fn add_relay_member( + &self, + community: CommunityId, + pubkey: &str, + role: &str, + added_by: Option<&str>, + ) -> Result { + add_relay_member(&self.pool, community, pubkey, role, added_by).await + } + + /// Claims relay membership via an invite and atomically persists the + /// accepted policy version when a policy is configured. + #[datastore_span(name = "claim_relay_membership", system = "postgresql")] + pub async fn claim_relay_membership( + &self, + community: CommunityId, + pubkey: &str, + role: &str, + policy_version: Option<&str>, + ) -> Result { + claim_relay_membership(&self.pool, community, pubkey, role, policy_version).await + } + + /// Returns whether a member has persisted acceptance evidence for a policy version. + #[datastore_span(name = "has_join_policy_acceptance", system = "postgresql")] + pub async fn has_join_policy_acceptance( + &self, + community: CommunityId, + pubkey: &str, + policy_version: &str, + ) -> Result { + has_join_policy_acceptance(&self.pool, community, pubkey, policy_version).await + } + + /// Removes a relay member from `community` atomically, refusing to delete the owner. + #[datastore_span(name = "remove_relay_member", system = "postgresql")] + pub async fn remove_relay_member( + &self, + community: CommunityId, + pubkey: &str, + ) -> Result { + remove_relay_member(&self.pool, community, pubkey).await + } + + /// Removes a relay member from `community` only if their current role matches `expected_role`. + /// + /// Atomic conditional delete — eliminates the TOCTOU race between a + /// prior role read and the delete. See [`remove_relay_member_if_role`]. + #[datastore_span(name = "remove_relay_member_if_role", system = "postgresql")] + pub async fn remove_relay_member_if_role( + &self, + community: CommunityId, + pubkey: &str, + expected_role: &str, + ) -> Result { + remove_relay_member_if_role(&self.pool, community, pubkey, expected_role).await + } + + /// Updates the role of an existing relay member in `community`. Returns `true` if updated. + #[datastore_span(name = "update_relay_member_role", system = "postgresql")] + pub async fn update_relay_member_role( + &self, + community: CommunityId, + pubkey: &str, + new_role: &str, + ) -> Result { + update_relay_member_role(&self.pool, community, pubkey, new_role).await + } + + /// Ensures the owner pubkey exists with role `"owner"` in `community`. Called at startup. + #[datastore_span(name = "bootstrap_owner", system = "postgresql")] + pub async fn bootstrap_owner(&self, community: CommunityId, owner_pubkey: &str) -> Result<()> { + bootstrap_owner(&self.pool, community, owner_pubkey).await + } + + /// Returns `true` if any member of `community` holds the `admin` or + /// `owner` role. + #[datastore_span(name = "has_admin_or_owner", system = "postgresql")] + pub async fn has_admin_or_owner(&self, community: CommunityId) -> Result { + has_admin_or_owner(&self.pool, community).await + } + + /// Atomically transfers ownership of `community` to `new_owner_pubkey`, + /// demoting the previous owner(s) to `member`. Verifies + /// `expected_owner_pubkey` matches the current owner inside the same + /// transaction to prevent stale-owner races. + #[datastore_span(name = "transfer_ownership", system = "postgresql")] + pub async fn transfer_ownership( + &self, + community: CommunityId, + new_owner_pubkey: &str, + expected_owner_pubkey: &str, + ) -> Result { + transfer_ownership( + &self.pool, + community, + new_owner_pubkey, + expected_owner_pubkey, + ) + .await + } + + /// Migrates existing `pubkey_allowlist` entries into `relay_members` for `community`. + /// + /// Idempotent — uses `ON CONFLICT DO NOTHING`. Returns the number of rows + /// inserted, or 0 if the `pubkey_allowlist` table doesn't exist. + #[datastore_span(name = "backfill_from_allowlist", system = "postgresql")] + pub async fn backfill_from_allowlist(&self, community: CommunityId) -> Result { + backfill_from_allowlist(&self.pool, community).await + } + + /// Returns whether the relay-authored NIP-43 snapshot is absent or differs + /// from the canonical membership rows for `community_id`. + /// + /// Snapshot and canonical rows are compared directly rather than by + /// timestamp: relay membership events use whole-second Nostr timestamps, + /// and multiple mutations within one second must still be repaired. + #[datastore_span( + name = "nip43_membership_snapshot_needs_reconciliation", + system = "postgresql" + )] + pub async fn nip43_membership_snapshot_needs_reconciliation( + &self, + community_id: CommunityId, + relay_pubkey: &nostr::PublicKey, + ) -> Result { + let snapshot = self + .query_events(&crate::event::EventQuery { + kinds: Some(vec![buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32]), + pubkey: Some(relay_pubkey.to_bytes().to_vec()), + global_only: true, + limit: Some(1), + ..crate::event::EventQuery::for_community(community_id) + }) + .await? + .into_iter() + .next(); + let members = self.list_relay_members(community_id).await?; + + let Some(snapshot) = snapshot else { + return Ok(true); + }; + let mut snapshot_members = snapshot + .event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some("member") && parts.len() >= 3) + .then(|| (parts[1].to_ascii_lowercase(), parts[2].clone())) + }) + .collect::>(); + let mut canonical_members = members + .into_iter() + .map(|member| (member.pubkey.to_ascii_lowercase(), member.role)) + .collect::>(); + snapshot_members.sort_unstable(); + canonical_members.sort_unstable(); + + Ok(snapshot_members != canonical_members) + } + + /// Atomically publish a NIP-43 membership snapshot under a single + /// transaction-scoped advisory lock. + /// + /// This method acquires the per-community snapshot lock, reads the + /// current membership, builds the event, and replaces the prior snapshot + /// — all inside one transaction on one database connection. This + /// prevents the stale-snapshot race where a concurrent publication reads + /// older state and overwrites a newer snapshot by arrival order. + #[datastore_span(name = "publish_nip43_membership_locked", system = "postgresql")] + pub async fn publish_nip43_membership_locked( + &self, + community_id: CommunityId, + relay_keypair: &nostr::Keys, + ) -> Result<(StoredEvent, bool, usize)> { + use nostr::{EventBuilder, Kind, Tag}; + + let kind_i32 = buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32; + let pubkey_bytes = relay_keypair.public_key().to_bytes(); + + let lock_key = replaceable::event_replacement_lock_key( + community_id, + kind_i32, + pubkey_bytes.as_slice(), + None, + ); + + let (mut tx, transaction_timer) = observability::begin_transaction( + &self.pool, + observability::TransactionOperation::PublishNip43MembershipLocked, + ) + .await?; + let (event, received_at, was_inserted, member_count) = transaction_timer + .observe(async { + + // Acquire the per-community snapshot lock BEFORE reading members. + // This serializes the entire read-build-write cycle: a concurrent + // publication will block here until our transaction commits, then + // read the updated membership state. + observability::observe_advisory_lock( + observability::LockType::Membership, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *tx), + ) + .await?; + + // Read current members inside the locked transaction. + let rows = sqlx::query( + "SELECT pubkey, role FROM relay_members \ + WHERE community_id = $1 ORDER BY created_at ASC", + ) + .bind(community_id.as_uuid()) + .fetch_all(&mut *tx) + .await?; + + let member_count = rows.len(); + + // Build the NIP-43 event from the locked member rows. + let mut tags: Vec = Vec::with_capacity(member_count + 1); + // NIP-70 protected-event marker. + tags.push(Tag::parse(["-"]).map_err(|e| { + crate::error::DbError::InvalidData(format!("failed to build '-' tag: {e}")) + })?); + for row in &rows { + let pubkey: String = row.try_get("pubkey")?; + let role: String = row.try_get("role")?; + tags.push(Tag::parse(["member", &pubkey, &role]).map_err(|e| { + crate::error::DbError::InvalidData(format!("failed to build member tag: {e}")) + })?); + } + + let event = EventBuilder::new(Kind::Custom(kind_i32 as u16), "") + .tags(tags) + .sign_with_keys(relay_keypair) + .map_err(|e| { + crate::error::DbError::InvalidData(format!("failed to sign kind:13534: {e}")) + })?; + + let created_at_secs = event.created_at.as_secs() as i64; + let created_at = chrono::DateTime::from_timestamp(created_at_secs, 0) + .ok_or(DbError::InvalidTimestamp(created_at_secs))?; + let sig_bytes = event.sig.serialize(); + let tags_json = serde_json::to_value(&event.tags)?; + let received_at = chrono::Utc::now(); + let d_tag = crate::event::extract_d_tag(&event); + + // Soft-delete prior snapshots — unconditional, the relay is authoritative. + sqlx::query( + "UPDATE events SET deleted_at = NOW() \ + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 \ + AND channel_id IS NULL \ + AND deleted_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(kind_i32) + .bind(pubkey_bytes.as_slice()) + .execute(&mut *tx) + .await?; + + let insert_result = sqlx::query( + "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \ + ON CONFLICT DO NOTHING", + ) + .bind(community_id.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .bind(pubkey_bytes.as_slice()) + .bind(created_at) + .bind(kind_i32) + .bind(&tags_json) + .bind(&event.content) + .bind(sig_bytes.as_slice()) + .bind(received_at) + .bind::>(None) + .bind(d_tag.as_deref()) + .execute(&mut *tx) + .await?; + + let was_inserted = insert_result.rows_affected() > 0; + if was_inserted { + tx.commit().await?; + } else { + tx.rollback().await?; + } + Ok::<_, DbError>((event, received_at, was_inserted, member_count)) + }) + .await?; + + if was_inserted { + if let Err(e) = crate::insert_mentions(&self.pool, community_id, &event, None).await { + tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); + } + } + + Ok(( + StoredEvent::with_received_at(event, received_at, None, was_inserted), + was_inserted, + member_count, + )) + } +} + #[cfg(test)] mod tests { #[test] diff --git a/crates/buzz-db/src/relay_operators.rs b/crates/buzz-db/src/store/relay_operators.rs similarity index 93% rename from crates/buzz-db/src/relay_operators.rs rename to crates/buzz-db/src/store/relay_operators.rs index b9beb68b02..3670a2f142 100644 --- a/crates/buzz-db/src/relay_operators.rs +++ b/crates/buzz-db/src/store/relay_operators.rs @@ -9,6 +9,7 @@ //! //! Lane ownership: relay admin API (Duncan). +use buzz_datastore_tracing::datastore_span; use chrono::{DateTime, Utc}; use sqlx::{PgPool, Postgres, Row as _, Transaction}; @@ -278,6 +279,52 @@ pub async fn list(pool: &PgPool) -> Result> { .map_err(crate::error::DbError::from) } +impl crate::Db { + /// Fetch one relay operator/moderator row by pubkey (32-byte binary). + #[datastore_span(name = "get_relay_operator", system = "postgresql")] + pub async fn get_relay_operator(&self, pubkey: &[u8]) -> Result> { + get(&self.pool, pubkey).await + } + + /// List all relay operator/moderator rows ordered by creation time. + #[datastore_span(name = "list_relay_operators", system = "postgresql")] + pub async fn list_relay_operators(&self) -> Result> { + list(&self.pool).await + } + + /// Insert or update a relay operator/moderator row (upsert by pubkey). + /// + /// `config_operator_exists` is the caller's request-time snapshot of + /// whether a config-backed operator is effective; a demotion that would + /// leave no effective operator is rejected with [`DbError::LastOperator`]. + #[datastore_span(name = "upsert_relay_operator", system = "postgresql")] + pub async fn upsert_relay_operator( + &self, + pubkey: &[u8], + role: &str, + added_by: &[u8], + config_operator_exists: bool, + ) -> Result<()> { + upsert(&self.pool, pubkey, role, added_by, config_operator_exists).await + } + + /// Remove a relay operator/moderator row. Returns `true` if deleted. + /// Records the revocation in the append-only audit trail; `actor` is the + /// authenticated operator performing the removal. `config_operator_exists` + /// is the caller's request-time snapshot of whether a config-backed + /// operator is effective; deleting the sole effective operator is rejected + /// with [`DbError::LastOperator`]. + #[datastore_span(name = "remove_relay_operator", system = "postgresql")] + pub async fn remove_relay_operator( + &self, + pubkey: &[u8], + actor: &[u8], + config_operator_exists: bool, + ) -> Result { + remove(&self.pool, pubkey, actor, config_operator_exists).await + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/buzz-db/src/store/reminder.rs b/crates/buzz-db/src/store/reminder.rs new file mode 100644 index 0000000000..20f503f400 --- /dev/null +++ b/crates/buzz-db/src/store/reminder.rs @@ -0,0 +1,509 @@ +//! Event-reminder delivery query, claim, and release persistence. + +use buzz_core::kind::KIND_EVENT_REMINDER; +use buzz_core::CommunityId; +use buzz_datastore_tracing::datastore_span; +use chrono::{DateTime, Utc}; +use sqlx::{PgPool, Row}; +use uuid::Uuid; + +use crate::error::Result; +use crate::Db; + +/// A due reminder row returned by [`query_due_reminders`]. +#[derive(Debug)] +pub struct DueReminder { + /// Server-resolved community this reminder row belongs to. + pub community_id: CommunityId, + /// Normalized host mapped to that community. + pub host: String, + /// The event's raw ID bytes. + pub id: Vec, + /// The event's pubkey bytes. + pub pubkey: Vec, + /// The event's `created_at` timestamp. + pub created_at: DateTime, + /// The event's kind (always 30300). + pub kind: i32, + /// The event's JSONB tags. + pub tags: serde_json::Value, + /// The event's encrypted content. + pub content: String, + /// The event's signature bytes. + pub sig: Vec, + /// The channel ID (always None for reminders — global events). + pub channel_id: Option, +} + +/// Query due reminders: latest-per-address `kind:30300` rows where +/// `not_before <= now`, `deleted_at IS NULL`, `delivered_at IS NULL`. +/// +/// Returns the latest head per `(pubkey, d_tag)` using canonical NIP-16 +/// ordering (`created_at DESC, id ASC`). +pub async fn query_due_reminders( + pool: &PgPool, + now_secs: i64, + batch_limit: i64, +) -> Result> { + let kind_i32 = KIND_EVENT_REMINDER as i32; + let rows = sqlx::query( + r#" + SELECT DISTINCT ON (e.community_id, e.pubkey, e.d_tag) + e.community_id, c.host, e.id, e.pubkey, e.created_at, e.kind, e.tags, e.content, e.sig, e.channel_id + FROM events AS e + JOIN communities AS c ON c.id = e.community_id + WHERE e.kind = $1 + AND e.not_before IS NOT NULL + AND e.not_before <= $2 + AND e.deleted_at IS NULL + AND e.delivered_at IS NULL + AND c.archived_at IS NULL + ORDER BY e.community_id, e.pubkey, e.d_tag, e.created_at DESC, e.id ASC + LIMIT $3 + "#, + ) + .bind(kind_i32) + .bind(now_secs) + .bind(batch_limit) + .fetch_all(pool) + .await?; + + let results = rows + .into_iter() + .map(|row| DueReminder { + community_id: CommunityId::from_uuid(row.get("community_id")), + host: row.get("host"), + id: row.get("id"), + pubkey: row.get("pubkey"), + created_at: row.get("created_at"), + kind: row.get("kind"), + tags: row.get("tags"), + content: row.get("content"), + sig: row.get("sig"), + channel_id: row.get("channel_id"), + }) + .collect(); + + Ok(results) +} + +/// Atomically claim a due reminder for delivery. Returns `Some(id)` if this +/// caller won the claim (set `delivered_at`), or `None` if another pod already +/// claimed it. Mirrors the reaper's `archived_at IS NULL` guard for cross-pod +/// idempotency. +pub async fn claim_due_reminder( + pool: &PgPool, + community_id: CommunityId, + event_id: &[u8], + event_created_at: DateTime, +) -> Result { + claim_due_reminder_with_stamp( + pool, + community_id, + event_id, + event_created_at, + Utc::now().timestamp(), + ) + .await +} + +/// Atomically claim a due reminder using a caller-supplied delivery stamp. +/// +/// The same stamp should be passed to [`release_due_reminder`] if the publish +/// side effect fails, so rollback can compare-and-clear only this pod's claim. +/// +/// Scoped by `community_id`: `events` is keyed `(community_id, created_at, id)`, +/// and the same Nostr event id (hence the same `id`/`created_at` pair) is +/// allowed across communities. Without the community predicate a claim for +/// `A/X` would also mark `B/X` delivered. The caller already holds the owning +/// community on the `DueReminder` row. +pub async fn claim_due_reminder_with_stamp( + pool: &PgPool, + community_id: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + delivery_stamp: i64, +) -> Result { + let result = sqlx::query( + r#" + UPDATE events + SET delivered_at = $1 + WHERE community_id = $2 AND created_at = $3 AND id = $4 AND delivered_at IS NULL + "#, + ) + .bind(delivery_stamp) + .bind(community_id.as_uuid()) + .bind(event_created_at) + .bind(event_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Release a previously claimed reminder when publish fails. +/// +/// The `delivery_stamp` must be the exact value written by the claiming pod; +/// that compare-and-clear prevents one pod from rolling back another pod's +/// later claim after a retry/race. +/// +/// Scoped by `community_id` for the same reason as the claim: a release for +/// `A/X` must not clear `B/X` even when their `id`/`created_at`/stamp coincide. +pub async fn release_due_reminder( + pool: &PgPool, + community_id: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + delivery_stamp: i64, +) -> Result { + let result = sqlx::query( + r#" + UPDATE events + SET delivered_at = NULL + WHERE community_id = $1 + AND created_at = $2 + AND id = $3 + AND delivered_at = $4 + "#, + ) + .bind(community_id.as_uuid()) + .bind(event_created_at) + .bind(event_id) + .bind(delivery_stamp) + .execute(pool) + .await?; + + Ok(result.rows_affected() == 1) +} + +impl Db { + /// Query due reminders ready for delivery. + #[datastore_span(name = "query_due_reminders", system = "postgresql")] + pub async fn query_due_reminders( + &self, + now_secs: i64, + batch_limit: i64, + ) -> Result> { + crate::reminder::query_due_reminders(&self.pool, now_secs, batch_limit).await + } + + /// Atomically claim a due reminder for delivery (cross-pod dedup). + #[datastore_span(name = "claim_due_reminder", system = "postgresql")] + pub async fn claim_due_reminder( + &self, + community_id: CommunityId, + event_id: &[u8], + event_created_at: chrono::DateTime, + ) -> Result { + crate::reminder::claim_due_reminder(&self.pool, community_id, event_id, event_created_at) + .await + } + + /// Atomically claim a due reminder using a caller-supplied delivery stamp. + #[datastore_span(name = "claim_due_reminder_with_stamp", system = "postgresql")] + pub async fn claim_due_reminder_with_stamp( + &self, + community_id: CommunityId, + event_id: &[u8], + event_created_at: chrono::DateTime, + delivery_stamp: i64, + ) -> Result { + crate::reminder::claim_due_reminder_with_stamp( + &self.pool, + community_id, + event_id, + event_created_at, + delivery_stamp, + ) + .await + } + + /// Release a claimed due reminder after a publish failure. + #[datastore_span(name = "release_due_reminder", system = "postgresql")] + pub async fn release_due_reminder( + &self, + community_id: CommunityId, + event_id: &[u8], + event_created_at: chrono::DateTime, + delivery_stamp: i64, + ) -> Result { + crate::reminder::release_due_reminder( + &self.pool, + community_id, + event_id, + event_created_at, + delivery_stamp, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::event::insert_event; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials + + async fn setup_pool() -> PgPool { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + + PgPool::connect(&database_url) + .await + .expect("connect to test DB") + } + + async fn make_test_community(pool: &PgPool) -> Uuid { + let id = Uuid::new_v4(); + let host = format!("event-test-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert test community"); + id + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn query_due_reminders_returns_row_community_and_host_per_tenant() { + let pool = setup_pool().await; + let community_a_uuid = make_test_community(&pool).await; + let community_b_uuid = make_test_community(&pool).await; + let community_a = CommunityId::from_uuid(community_a_uuid); + let community_b = CommunityId::from_uuid(community_b_uuid); + let host_a: String = sqlx::query_scalar("SELECT host FROM communities WHERE id = $1") + .bind(community_a_uuid) + .fetch_one(&pool) + .await + .expect("load host A"); + let host_b: String = sqlx::query_scalar("SELECT host FROM communities WHERE id = $1") + .bind(community_b_uuid) + .fetch_one(&pool) + .await + .expect("load host B"); + + let not_before = Utc::now().timestamp() - 1; + let keys_a = Keys::generate(); + let keys_b = Keys::generate(); + let event_a = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "a") + .tags([ + Tag::parse(["d", "due-reminder-scope-a"]).unwrap(), + Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), + ]) + .sign_with_keys(&keys_a) + .expect("sign A"); + let event_b = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "b") + .tags([ + Tag::parse(["d", "due-reminder-scope-b"]).unwrap(), + Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), + ]) + .sign_with_keys(&keys_b) + .expect("sign B"); + + insert_event(&pool, community_a, &event_a, None) + .await + .expect("insert A"); + insert_event(&pool, community_b, &event_b, None) + .await + .expect("insert B"); + + let due = query_due_reminders(&pool, Utc::now().timestamp(), 100) + .await + .expect("query due reminders"); + + assert!(due.iter().any(|row| { + row.id == event_a.id.as_bytes() && row.community_id == community_a && row.host == host_a + })); + assert!(due.iter().any(|row| { + row.id == event_b.id.as_bytes() && row.community_id == community_b && row.host == host_b + })); + } + + /// Two pods race to claim the same due reminder: exactly one wins. The + /// scheduler publishes only on a winning claim (`Ok(true)`) and `continue`s + /// on the loser (`Ok(false)`), so a single winning claim *is* the proof of + /// exactly one publish side effect across N pods. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn claim_due_reminder_is_won_by_exactly_one_of_two_racing_pods() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let not_before = Utc::now().timestamp() - 1; + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "due") + .tags([ + Tag::parse(["d", "due-reminder-claim-race"]).unwrap(), + Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), + ]) + .sign_with_keys(&keys) + .expect("sign reminder"); + insert_event(&pool, community, &event, None) + .await + .expect("insert reminder"); + + let id = event.id.as_bytes().to_vec(); + let created_at = event.created_at.as_secs() as i64; + let created_at = chrono::DateTime::from_timestamp(created_at, 0).expect("created_at"); + + // Two pods, two distinct per-attempt stamps, same reminder. + let stamp_p1: i64 = 0x1111_1111_1111_1111; + let stamp_p2: i64 = 0x2222_2222_2222_2222; + let won_p1 = claim_due_reminder_with_stamp(&pool, community, &id, created_at, stamp_p1) + .await + .expect("p1 claim"); + let won_p2 = claim_due_reminder_with_stamp(&pool, community, &id, created_at, stamp_p2) + .await + .expect("p2 claim"); + + assert!( + won_p1 ^ won_p2, + "exactly one pod must win the claim (p1={won_p1}, p2={won_p2}) — \ + the loser never reaches the publish side effect" + ); + } + + /// A failed publish releases the claim so the reminder is redeliverable, + /// and the compare-and-clear stamp guard prevents one pod from rolling back + /// another pod's claim. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn release_due_reminder_rolls_back_only_the_matching_stamp() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let not_before = Utc::now().timestamp() - 1; + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "due") + .tags([ + Tag::parse(["d", "due-reminder-release"]).unwrap(), + Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), + ]) + .sign_with_keys(&keys) + .expect("sign reminder"); + insert_event(&pool, community, &event, None) + .await + .expect("insert reminder"); + + let id = event.id.as_bytes().to_vec(); + let created_at = event.created_at.as_secs() as i64; + let created_at = chrono::DateTime::from_timestamp(created_at, 0).expect("created_at"); + let stamp: i64 = 0x3333_3333_3333_3333; + + assert!( + claim_due_reminder_with_stamp(&pool, community, &id, created_at, stamp) + .await + .expect("claim"), + "first claim wins" + ); + + // A release with the *wrong* stamp must be a no-op (does not clear + // another pod's claim). + assert!( + !release_due_reminder(&pool, community, &id, created_at, stamp ^ 0xFFFF) + .await + .expect("wrong-stamp release"), + "release with a non-matching stamp must not clear the claim" + ); + assert!( + !claim_due_reminder_with_stamp(&pool, community, &id, created_at, stamp) + .await + .expect("re-claim after no-op release"), + "reminder must still be claimed after a no-op release" + ); + + // The matching-stamp release rolls the claim back; the reminder is + // redeliverable and a subsequent claim wins again. + assert!( + release_due_reminder(&pool, community, &id, created_at, stamp) + .await + .expect("matching-stamp release"), + "release with the claiming stamp must clear the claim" + ); + assert!( + claim_due_reminder_with_stamp(&pool, community, &id, created_at, stamp) + .await + .expect("re-claim after release"), + "released reminder must be reclaimable for retry" + ); + } + + /// Cross-community confinement: the same Nostr reminder event (identical + /// `id` and `created_at`) inserted into communities A and B must claim and + /// release independently. A claim/release for `A/X` must never touch `B/X`. + /// + /// This is the primitive the scheduler's exactly-once-publish proof rests + /// on: `events` is keyed `(community_id, created_at, id)`, so without the + /// community predicate a claim for A would mark B delivered (suppressing + /// B's reminder) and a matching-stamp release for A would clear B. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn reminder_claim_and_release_are_confined_to_their_community() { + let pool = setup_pool().await; + let community_a = CommunityId::from_uuid(make_test_community(&pool).await); + let community_b = CommunityId::from_uuid(make_test_community(&pool).await); + + // One signed event, inserted into both communities — same id/created_at. + let not_before = Utc::now().timestamp() - 1; + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "due") + .tags([ + Tag::parse(["d", "due-reminder-cross-community"]).unwrap(), + Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), + ]) + .sign_with_keys(&keys) + .expect("sign reminder"); + insert_event(&pool, community_a, &event, None) + .await + .expect("insert A/X"); + insert_event(&pool, community_b, &event, None) + .await + .expect("insert B/X"); + + let id = event.id.as_bytes().to_vec(); + let created_at = event.created_at.as_secs() as i64; + let created_at = chrono::DateTime::from_timestamp(created_at, 0).expect("created_at"); + let stamp: i64 = 0x4444_4444_4444_4444; + + // Claim A/X. B/X must remain claimable — A's claim did not mark B. + assert!( + claim_due_reminder_with_stamp(&pool, community_a, &id, created_at, stamp) + .await + .expect("claim A"), + "A/X claim wins" + ); + assert!( + claim_due_reminder_with_stamp(&pool, community_b, &id, created_at, stamp) + .await + .expect("claim B"), + "B/X must still be claimable after A/X is claimed — \ + a claim for A must not mark B delivered" + ); + + // Both are now claimed under the same stamp. A matching-stamp release + // for A/X must clear only A/X; B/X must stay claimed. + assert!( + release_due_reminder(&pool, community_a, &id, created_at, stamp) + .await + .expect("release A"), + "A/X release with the claiming stamp clears A/X" + ); + assert!( + !claim_due_reminder_with_stamp(&pool, community_b, &id, created_at, stamp) + .await + .expect("re-claim B after A release"), + "B/X must remain claimed after A/X is released — \ + a release for A must not clear B" + ); + // And A/X is genuinely redeliverable (the release was real, not a no-op). + assert!( + claim_due_reminder_with_stamp(&pool, community_a, &id, created_at, stamp) + .await + .expect("re-claim A after release"), + "A/X must be reclaimable after its own release" + ); + } +} diff --git a/crates/buzz-db/src/replaceable.rs b/crates/buzz-db/src/store/replaceable.rs similarity index 100% rename from crates/buzz-db/src/replaceable.rs rename to crates/buzz-db/src/store/replaceable.rs diff --git a/crates/buzz-db/src/thread.rs b/crates/buzz-db/src/store/thread.rs similarity index 84% rename from crates/buzz-db/src/thread.rs rename to crates/buzz-db/src/store/thread.rs index 007677e258..d7a2d239ef 100644 --- a/crates/buzz-db/src/thread.rs +++ b/crates/buzz-db/src/store/thread.rs @@ -9,9 +9,14 @@ use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row}; use uuid::Uuid; +use buzz_datastore_tracing::datastore_span; + use buzz_core::CommunityId; -use crate::{error::Result, event::row_to_stored_event}; +use crate::{ + error::Result, event::row_to_stored_event, route_proof::ChannelScoped, Db, ReadSession, + ReadSessionInner, RouteDecision, RoutePredicate, +}; // -- Structs ------------------------------------------------------------------ @@ -856,6 +861,296 @@ pub async fn get_thread_metadata_by_event( })) } +// -- Db API ------------------------------------------------------------------- + +impl Db { + /// Insert thread metadata. + #[allow(clippy::too_many_arguments)] + #[datastore_span(name = "insert_thread_metadata", system = "postgresql")] + pub async fn insert_thread_metadata( + &self, + community_id: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + channel_id: Uuid, + parent_event_id: Option<&[u8]>, + parent_event_created_at: Option>, + root_event_id: Option<&[u8]>, + root_event_created_at: Option>, + depth: i32, + broadcast: bool, + ) -> Result<()> { + crate::thread::insert_thread_metadata( + &self.pool, + community_id, + event_id, + event_created_at, + channel_id, + parent_event_id, + parent_event_created_at, + root_event_id, + root_event_created_at, + depth, + broadcast, + ) + .await + } + + /// Fetch replies under a root event. + /// + /// Routing mirrors [`Db::get_channel_window_with_session`]: a head + /// fetch (`cursor: None`) is Predicate A (bounded staleness, gated by + /// the default-off head budget); cursor pages are Predicate B + /// (completeness). Thread pagination walks **forward** from oldest to + /// newest, so a cursor carries no upper bound — instead the served page + /// is post-verified against the wall the serving session proved: + /// + /// - an under-`limit` page is a candidate terminal page — the client + /// treats it as EOF, so it is re-run on the writer to keep the EOF + /// decision authoritative (a lagged replica could truncate the tail); + /// - a full page whose newest row exceeds the proved fence wall could + /// straddle a row the session has not replayed (commit order is not + /// `created_at` order), so it is also re-run on the writer. Only a + /// full page that sits entirely at or below the proved wall is served + /// from the replica. + /// + /// A head fetch routed under Predicate A skips the re-run: bounded + /// staleness (missing at most the freshest budget-window of replies) is + /// exactly the semantic the head gate accepts. + #[datastore_span(name = "get_thread_replies", system = "postgresql")] + pub async fn get_thread_replies( + &self, + community_id: CommunityId, + root_event_id: &[u8], + depth_limit: Option, + limit: u32, + cursor: Option<&[u8]>, + ) -> Result> { + let (path, predicate): (&'static str, RoutePredicate) = match cursor { + Some(_) => ( + "thread_cursor", + RoutePredicate::CoveredPostVerified { + proof: ChannelScoped::from_thread_metadata_join(), + }, + ), + None => ("thread_head", RoutePredicate::Bounded), + }; + if let RouteDecision::Replica(mut tx, entry, reason) = + self.route_read(path, predicate).await + { + match crate::thread::get_thread_replies_on( + &mut tx, + community_id, + root_event_id, + depth_limit, + limit, + cursor, + ) + .await + { + Ok(replies) => { + if cursor.is_none() { + // Predicate A: bounded-stale head page, served as proved. + Self::record_route(path, "replica", reason); + return Ok(replies); + } + let full = replies.len() >= limit as usize; + let below_fence = replies + .last() + .is_some_and(|tail| tail.created_at <= entry.fence_wall); + if full && below_fence { + Self::record_route(path, "replica", reason); + return Ok(replies); + } + // Candidate terminal page, or page reaching above the + // proved wall — verify against the writer. Recorded as + // the request's ONLY route event: the replica leg was + // discarded, so counting it would overstate offload. + Self::record_route("thread_eof", "writer", "stale"); + } + Err(e) => { + // Mid-request replica failure (e.g. a hot-standby + // recovery conflict) fails closed to the writer. + tracing::warn!( + error = %e, + path, + "replica thread query failed; re-running on writer" + ); + Self::record_route(path, "writer", "replica_error"); + } + } + } + crate::thread::get_thread_replies( + &self.pool, + community_id, + root_event_id, + depth_limit, + limit, + cursor, + ) + .await + } + + /// Fetch aggregated thread stats. + #[datastore_span(name = "get_thread_summary", system = "postgresql")] + pub async fn get_thread_summary( + &self, + community_id: CommunityId, + event_id: &[u8], + ) -> Result> { + crate::thread::get_thread_summary(&self.pool, community_id, event_id).await + } + + /// One channel window: top-level rows + summaries + server `has_more`. + /// + /// Convenience wrapper over [`Db::get_channel_window_with_session`] for + /// callers with no follow-up queries; the serving session is released. + pub async fn get_channel_window( + &self, + community_id: CommunityId, + channel_id: Uuid, + limit: u32, + cursor: Option<(DateTime, Vec)>, + kind_filter: Option<&[u32]>, + ) -> Result { + self.get_channel_window_with_session(community_id, channel_id, limit, cursor, kind_filter) + .await + .map(|(window, _session)| window) + } + + /// [`Db::get_channel_window`], additionally returning the session that + /// served the page so request-scoped follow-ups (the aux closure) run on + /// the same proved connection. + /// + /// Routing: + /// + /// - **Cursor page** (Predicate B — completeness): scrolls *backward* + /// into history bounded above by the cursor timestamp (`created_at < + /// ts`, or `= ts` with the id tiebreak), so it may be served by a + /// replica session when one is configured AND that session **proves** + /// coverage of the cursor timestamp: the heartbeat token/epoch is + /// observed on the exact connection that will serve the page and + /// resolved against the fence's retained ring ([`crate::replica_fence`]). + /// - **Head fetch** (Predicate A — bounded staleness): served by a + /// proved replica session only when the head gate is configured + /// ([`crate::DbConfig::replica_read_max_age_ms`], default off) and the + /// proved entry is within the budget. This trades a bounded staleness + /// window (budget plus probe cadence) on the GET leg for writer + /// offload. NOTE: enabling the budget also breaks read-your-own-writes + /// on the GET leg; the client-side WS `since`-overlap union intended + /// to cover fresh events has NOT shipped yet — do not enable + /// `BUZZ_REPLICA_HEAD_MAX_AGE_SECS` until it has, proven by a + /// post-then-immediately-refetch test. + /// + /// Every failure fails closed to the writer and is recorded in + /// `buzz_db_route_decision`. + #[datastore_span(name = "get_channel_window", system = "postgresql")] + pub async fn get_channel_window_with_session( + &self, + community_id: CommunityId, + channel_id: Uuid, + limit: u32, + cursor: Option<(DateTime, Vec)>, + kind_filter: Option<&[u32]>, + ) -> Result<(crate::thread::ChannelWindow, ReadSession)> { + let path: &'static str = if cursor.is_some() { + "channel_cursor" + } else { + "channel_head" + }; + match self + .route_read( + path, + RoutePredicate::from_channel_cursor(channel_id, &cursor), + ) + .await + { + RouteDecision::Replica(mut tx, _entry, reason) => { + match crate::thread::get_channel_window_on( + &mut tx, + community_id, + channel_id, + limit, + cursor.clone(), + kind_filter, + ) + .await + { + Ok(window) => { + Self::record_route(path, "replica", reason); + return Ok(( + window, + ReadSession { + inner: ReadSessionInner::Replica { + tx, + writer: self.pool.clone(), + }, + }, + )); + } + Err(e) => { + // A mid-request replica failure (e.g. a hot-standby + // recovery conflict cancelling the held snapshot) + // fails closed to the writer: a stale-but-served + // page, never an error the writer could have + // answered. Dropping `tx` rolls the reader + // transaction back. + tracing::warn!( + error = %e, + path, + "replica window query failed; re-running on writer" + ); + Self::record_route(path, "writer", "replica_error"); + } + } + } + RouteDecision::Writer => {} + } + let window = crate::thread::get_channel_window( + &self.pool, + community_id, + channel_id, + limit, + cursor, + kind_filter, + ) + .await?; + Ok(( + window, + ReadSession { + inner: ReadSessionInner::Writer(self.pool.clone()), + }, + )) + } + + /// Look up a single thread_metadata row by event_id. + #[datastore_span(name = "get_thread_metadata_by_event", system = "postgresql")] + pub async fn get_thread_metadata_by_event( + &self, + community_id: CommunityId, + event_id: &[u8], + ) -> Result> { + crate::thread::get_thread_metadata_by_event(&self.pool, community_id, event_id).await + } + + /// Decrement reply counts. + #[datastore_span(name = "decrement_reply_count", system = "postgresql")] + pub async fn decrement_reply_count( + &self, + community_id: CommunityId, + parent_event_id: &[u8], + root_event_id: Option<&[u8]>, + ) -> Result<()> { + crate::thread::decrement_reply_count( + &self.pool, + community_id, + parent_event_id, + root_event_id, + ) + .await + } +} + #[cfg(test)] mod tests { use super::*; @@ -865,7 +1160,7 @@ mod tests { }; use nostr::{EventBuilder, Keys, Kind}; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 async fn setup_pool() -> PgPool { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") diff --git a/crates/buzz-db/src/usage.rs b/crates/buzz-db/src/store/usage.rs similarity index 74% rename from crates/buzz-db/src/usage.rs rename to crates/buzz-db/src/store/usage.rs index f009dc6e05..97235f0b26 100644 --- a/crates/buzz-db/src/usage.rs +++ b/crates/buzz-db/src/store/usage.rs @@ -12,10 +12,35 @@ //! Returned structs are plain data; the caller (relay poller) maps them //! to Prometheus labels and calls `metrics::gauge!(...).set(...)`. -use crate::error::Result; -use sqlx::PgPool; +use buzz_datastore_tracing::datastore_span; +use sqlx::postgres::PgConnection; +use sqlx::{Connection as _, PgPool}; use uuid::Uuid; +use crate::error::Result; +use crate::{observability, Db}; + +/// Owns the detached Postgres session holding the relay usage-metrics advisory lock. +/// +/// The connection deliberately does not return to the main pool: session advisory +/// locks must remain bound to this exact physical connection, and the poller +/// pings it before each leader-only collection tick. +pub struct UsageMetricsLeader { + connection: PgConnection, +} + +impl UsageMetricsLeader { + /// Returns whether the lock-owning session is still reachable. + /// + /// Bounded to 5 seconds — a blackholed connection (no RST) would otherwise + /// stall the entire poller tick until the OS TCP timeout. + pub async fn is_live(&mut self) -> bool { + tokio::time::timeout(std::time::Duration::from_secs(5), self.connection.ping()) + .await + .is_ok_and(|r| r.is_ok()) + } +} + /// Total number of communities registered on this relay. pub async fn community_count(pool: &PgPool) -> Result { let row = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM communities") @@ -354,14 +379,111 @@ pub async fn community_hosts(pool: &PgPool) -> Result> { .collect()) } +impl Db { + /// Try to acquire the detached session advisory lock for relay usage metrics. + /// + /// The returned guard owns the exact connection that acquired the lock. It is + /// detached from the shared pool so a stable leader neither returns a locked + /// session to other callers nor permanently consumes a pool slot. Dropping the + /// guard closes the connection and releases the session-scoped lock. + #[datastore_span(name = "try_lock_usage_metrics", system = "postgresql")] + pub async fn try_lock_usage_metrics( + &self, + lock_key: i64, + ) -> Result> { + let mut connection = + observability::acquire(&self.pool, observability::PoolRole::Writer).await?; + let acquired = sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)") + .bind(lock_key) + .fetch_one(&mut *connection) + .await?; + if acquired { + Ok(Some(UsageMetricsLeader { + connection: connection.detach(), + })) + } else { + Ok(None) + } + } + + /// Return total number of communities on this relay. + #[datastore_span(name = "usage_community_count", system = "postgresql")] + pub async fn usage_community_count(&self) -> Result { + community_count(&self.pool).await + } + + /// Return per-community user counts split by human/agent. + #[datastore_span(name = "usage_user_counts", system = "postgresql")] + pub async fn usage_user_counts(&self) -> Result> { + user_counts(&self.pool).await + } + + /// Return per-community channel counts by type. + #[datastore_span(name = "usage_channel_counts", system = "postgresql")] + pub async fn usage_channel_counts(&self) -> Result> { + channel_counts(&self.pool).await + } + + /// Return per-community kind=9 message counts. + #[datastore_span(name = "usage_message_counts", system = "postgresql")] + pub async fn usage_message_counts(&self) -> Result> { + message_counts(&self.pool).await + } + + /// Return per-community relay-member counts by role. + #[datastore_span(name = "usage_relay_member_counts", system = "postgresql")] + pub async fn usage_relay_member_counts(&self) -> Result> { + relay_member_counts(&self.pool).await + } + + /// Return per-community workflow counts by status. + #[datastore_span(name = "usage_workflow_counts", system = "postgresql")] + pub async fn usage_workflow_counts(&self) -> Result> { + workflow_counts(&self.pool).await + } + + /// Return per-community git-repo counts. + #[datastore_span(name = "usage_git_repo_counts", system = "postgresql")] + pub async fn usage_git_repo_counts(&self) -> Result> { + git_repo_counts(&self.pool).await + } + + /// Return per-community distinct active-user counts for a given SQL interval. + /// + /// `interval_sql` must be a trusted literal such as `"1 day"` or `"7 days"`. + #[datastore_span(name = "usage_active_user_counts", system = "postgresql")] + pub async fn usage_active_user_counts( + &self, + interval_sql: &'static str, + ) -> Result> { + active_user_counts(&self.pool, interval_sql).await + } + + /// Return per-community active-channel counts for a given SQL interval. + #[datastore_span(name = "usage_active_channel_counts", system = "postgresql")] + pub async fn usage_active_channel_counts( + &self, + interval_sql: &'static str, + ) -> Result> { + active_channel_counts(&self.pool, interval_sql).await + } + + /// Return all community id → host mappings. + #[datastore_span(name = "usage_community_hosts", system = "postgresql")] + pub async fn usage_community_hosts(&self) -> Result> { + community_hosts(&self.pool).await + } +} + #[cfg(test)] mod tests { use super::*; use buzz_core::CommunityId; use nostr::Keys; + use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 async fn get_pool() -> PgPool { PgPool::connect(TEST_DB_URL) @@ -369,6 +491,84 @@ mod tests { .expect("connect to test DB") } + async fn create_scratch_db(admin: &PgPool, prefix: &str) -> (PgPool, String) { + let name = format!("{}_{}", prefix, Uuid::new_v4().simple()); + sqlx::query(sqlx::AssertSqlSafe(format!("CREATE DATABASE {name}"))) + .execute(admin) + .await + .expect("create scratch db"); + let base = std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); + let idx = base.rfind('/').expect("db url has a path segment"); + let scratch_url = format!("{}/{}", &base[..idx], name); + let pool = PgPool::connect(&scratch_url) + .await + .expect("connect scratch db"); + crate::migration::run_migrations(&pool) + .await + .expect("migrate scratch db"); + (pool, name) + } + + async fn drop_scratch_db(admin: &PgPool, pool: PgPool, name: &str) { + pool.close().await; + let _ = sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP DATABASE IF EXISTS {name} WITH (FORCE)" + ))) + .execute(admin) + .await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_usage_metrics_lock_has_single_owner_and_releases_on_drop() { + // Use a private scratch database — not the shared TEST_DATABASE_URL. + // Postgres advisory locks are per-database; hardcoding the production + // USAGE_METRICS_LOCK_KEY (0x4255_5A5A_4D45_5452) on the shared test DB + // races any live buzz-relay on the same database (see #3619). + let admin_url = std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); + let admin = PgPoolOptions::new() + .max_connections(1) + .connect(&admin_url) + .await + .expect("connect admin to create scratch db"); + let (pool, scratch_name) = create_scratch_db(&admin, "usage_metrics_lock").await; + let first = Db::from_pool(pool.clone()); + let second = Db::from_pool(pool.clone()); + // Same key as production (`buzz-relay` USAGE_METRICS_LOCK_KEY) — safe here + // because the scratch DB is empty of other holders. + let key = 0x4255_5A5A_4D45_5452; + + let mut leader = first + .try_lock_usage_metrics(key) + .await + .expect("first lock attempt") + .expect("first database handle becomes leader"); + assert!(leader.is_live().await, "lock owner remains reachable"); + assert!( + second + .try_lock_usage_metrics(key) + .await + .expect("second lock attempt") + .is_none(), + "another session cannot become leader while the guard exists" + ); + + drop(leader); + assert!( + second + .try_lock_usage_metrics(key) + .await + .expect("lock attempt after leader drop") + .is_some(), + "dropping the detached session releases its advisory lock" + ); + + // Release any remaining session state before DROP DATABASE. + drop(first); + drop(second); + drop_scratch_db(&admin, pool, &scratch_name).await; + } + fn random_pubkey() -> Vec { Keys::generate().public_key().to_bytes().to_vec() } diff --git a/crates/buzz-db/src/user.rs b/crates/buzz-db/src/store/user.rs similarity index 84% rename from crates/buzz-db/src/user.rs rename to crates/buzz-db/src/store/user.rs index 066fb5f5c0..140a722a21 100644 --- a/crates/buzz-db/src/user.rs +++ b/crates/buzz-db/src/store/user.rs @@ -1,7 +1,9 @@ //! User CRUD operations. use crate::error::Result; +use crate::Db; use buzz_core::CommunityId; +use buzz_datastore_tracing::datastore_span; use sqlx::PgPool; use sqlx::Row; @@ -398,13 +400,124 @@ pub async fn set_channel_add_policy( Ok(()) } +impl Db { + /// Ensure a user record exists (upsert). + /// + /// Returns `true` if a new row was inserted (first time), `false` if it + /// already existed. Callers use the `true` return to increment + /// `buzz_users_created_total`. + #[datastore_span(name = "ensure_user", system = "postgresql")] + pub async fn ensure_user(&self, community_id: CommunityId, pubkey: &[u8]) -> Result { + crate::user::ensure_user(&self.pool, community_id, pubkey).await + } + + /// Get a single user record by pubkey. + #[datastore_span(name = "get_user", system = "postgresql")] + pub async fn get_user( + &self, + community_id: CommunityId, + pubkey: &[u8], + ) -> Result> { + crate::user::get_user(&self.pool, community_id, pubkey).await + } + + /// Update a user's profile fields. + #[datastore_span(name = "update_user_profile", system = "postgresql")] + pub async fn update_user_profile( + &self, + community_id: CommunityId, + pubkey: &[u8], + display_name: Option<&str>, + avatar_url: Option<&str>, + about: Option<&str>, + nip05_handle: Option<&str>, + ) -> Result<()> { + crate::user::update_user_profile( + &self.pool, + community_id, + pubkey, + display_name, + avatar_url, + about, + nip05_handle, + ) + .await + } + + /// Look up a user by NIP-05 handle. + #[datastore_span(name = "get_user_by_nip05", system = "postgresql")] + pub async fn get_user_by_nip05( + &self, + community_id: CommunityId, + local_part: &str, + domain: &str, + ) -> Result> { + crate::user::get_user_by_nip05(&self.pool, community_id, local_part, domain).await + } + + /// Search users by display name, NIP-05 handle, or pubkey prefix. + #[datastore_span(name = "search_users", system = "postgresql")] + pub async fn search_users( + &self, + community_id: CommunityId, + query: &str, + limit: u32, + ) -> Result> { + crate::user::search_users(&self.pool, community_id, query, limit).await + } + + /// Atomically set agent owner — only if no owner is currently assigned. + /// Returns Ok(true) if set, Ok(false) if an owner already exists. + #[datastore_span(name = "set_agent_owner", system = "postgresql")] + pub async fn set_agent_owner( + &self, + community_id: CommunityId, + agent_pubkey: &[u8], + owner_pubkey: &[u8], + ) -> Result { + crate::user::set_agent_owner(&self.pool, community_id, agent_pubkey, owner_pubkey).await + } + + /// Get the channel_add_policy and agent_owner_pubkey for a user. + #[datastore_span(name = "get_agent_channel_policy", system = "postgresql")] + pub async fn get_agent_channel_policy( + &self, + community_id: CommunityId, + pubkey: &[u8], + ) -> Result>)>> { + crate::user::get_agent_channel_policy(&self.pool, community_id, pubkey).await + } + + /// Check whether `actor_pubkey` is the agent owner of `target_pubkey`. + #[datastore_span(name = "is_agent_owner", system = "postgresql")] + pub async fn is_agent_owner( + &self, + community_id: CommunityId, + target_pubkey: &[u8], + actor_pubkey: &[u8], + ) -> Result { + crate::user::is_agent_owner(&self.pool, community_id, target_pubkey, actor_pubkey).await + } + + /// Set the channel_add_policy for a user. + #[datastore_span(name = "set_channel_add_policy", system = "postgresql")] + pub async fn set_channel_add_policy( + &self, + community_id: CommunityId, + pubkey: &[u8], + policy: &str, + ) -> Result<()> { + crate::user::set_channel_add_policy(&self.pool, community_id, pubkey, policy).await + } +} + #[cfg(test)] mod tests { use super::*; use crate::Db; use nostr::Keys; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials async fn setup_db() -> Db { let pool = PgPool::connect(TEST_DB_URL) diff --git a/crates/buzz-db/src/workflow.rs b/crates/buzz-db/src/store/workflow.rs similarity index 85% rename from crates/buzz-db/src/workflow.rs rename to crates/buzz-db/src/store/workflow.rs index e970e978aa..0ae1b62376 100644 --- a/crates/buzz-db/src/workflow.rs +++ b/crates/buzz-db/src/store/workflow.rs @@ -18,6 +18,8 @@ use uuid::Uuid; use buzz_core::CommunityId; use crate::error::{DbError, Result}; +use crate::Db; +use buzz_datastore_tracing::datastore_span; // -- Token hashing ------------------------------------------------------------ @@ -1266,6 +1268,421 @@ pub async fn find_by_owner_and_name( } } +// -- Run and approval Db API -------------------------------------------------- + +impl Db { + /// Create a new workflow run. + #[datastore_span(name = "create_workflow_run", system = "postgresql")] + pub async fn create_workflow_run( + &self, + community_id: CommunityId, + workflow_id: Uuid, + trigger_event_id: Option<&[u8]>, + trigger_context: Option<&serde_json::Value>, + ) -> Result { + crate::workflow::create_workflow_run( + &self.pool, + community_id, + workflow_id, + trigger_event_id, + trigger_context, + ) + .await + } + + /// Fetch a single workflow run, scoped to its community. + #[datastore_span(name = "get_workflow_run", system = "postgresql")] + pub async fn get_workflow_run( + &self, + community_id: CommunityId, + id: Uuid, + ) -> Result { + crate::workflow::get_workflow_run(&self.pool, community_id, id).await + } + + /// List runs for a workflow. + #[datastore_span(name = "list_workflow_runs", system = "postgresql")] + pub async fn list_workflow_runs( + &self, + community_id: CommunityId, + workflow_id: Uuid, + limit: i64, + ) -> Result> { + crate::workflow::list_workflow_runs(&self.pool, community_id, workflow_id, limit).await + } + + /// List one keyset-paginated page of workflow runs. + #[datastore_span(name = "list_workflow_runs_page", system = "postgresql")] + pub async fn list_workflow_runs_page( + &self, + community_id: CommunityId, + workflow_id: Uuid, + before: Option>, + before_id: Option, + limit: i64, + ) -> Result> { + crate::workflow::list_workflow_runs_page( + &self.pool, + community_id, + workflow_id, + before, + before_id, + limit, + ) + .await + } + + /// Update a workflow run's status. + #[datastore_span(name = "update_workflow_run", system = "postgresql")] + pub async fn update_workflow_run( + &self, + community_id: CommunityId, + id: Uuid, + status: crate::workflow::RunStatus, + current_step: i32, + trace: &serde_json::Value, + failure: Option>, + ) -> Result<()> { + crate::workflow::update_workflow_run( + &self.pool, + community_id, + id, + status, + current_step, + trace, + failure, + ) + .await + } + + /// Create an approval request. + #[datastore_span(name = "create_approval", system = "postgresql")] + pub async fn create_approval( + &self, + params: crate::workflow::CreateApprovalParams<'_>, + ) -> Result<()> { + crate::workflow::create_approval(&self.pool, params).await + } + + /// Fetch an approval by raw token. + #[datastore_span(name = "get_approval", system = "postgresql")] + pub async fn get_approval( + &self, + community_id: CommunityId, + token: &str, + ) -> Result { + crate::workflow::get_approval(&self.pool, community_id, token).await + } + + /// Fetch an approval by its already-hashed token (no re-hashing). + #[datastore_span(name = "get_approval_by_stored_hash", system = "postgresql")] + pub async fn get_approval_by_stored_hash( + &self, + community_id: CommunityId, + token_hash: &[u8], + ) -> Result { + crate::workflow::get_approval_by_stored_hash(&self.pool, community_id, token_hash).await + } + + /// Fetch all approvals for a workflow run. + #[datastore_span(name = "get_run_approvals", system = "postgresql")] + pub async fn get_run_approvals( + &self, + community_id: CommunityId, + workflow_id: uuid::Uuid, + run_id: uuid::Uuid, + ) -> Result> { + crate::workflow::get_run_approvals(&self.pool, community_id, workflow_id, run_id).await + } + + /// Update an approval's status. + #[datastore_span(name = "update_approval", system = "postgresql")] + pub async fn update_approval( + &self, + community_id: CommunityId, + token: &str, + status: crate::workflow::ApprovalStatus, + approver_pubkey: Option<&[u8]>, + note: Option<&str>, + ) -> Result { + crate::workflow::update_approval( + &self.pool, + community_id, + token, + status, + approver_pubkey, + note, + ) + .await + } + + /// Update an approval by its already-hashed token (no re-hashing). + #[datastore_span(name = "update_approval_by_stored_hash", system = "postgresql")] + pub async fn update_approval_by_stored_hash( + &self, + community_id: CommunityId, + token_hash: &[u8], + status: crate::workflow::ApprovalStatus, + approver_pubkey: Option<&[u8]>, + note: Option<&str>, + ) -> Result { + crate::workflow::update_approval_by_stored_hash( + &self.pool, + community_id, + token_hash, + status, + approver_pubkey, + note, + ) + .await + } +} + +// -- Workflow lifecycle Db API ------------------------------------------------ + +impl Db { + /// Create a new workflow. + #[datastore_span(name = "create_workflow", system = "postgresql")] + pub async fn create_workflow( + &self, + community_id: CommunityId, + channel_id: Option, + owner_pubkey: &[u8], + name: &str, + definition_json: &str, + definition_hash: &[u8], + ) -> Result { + crate::workflow::create_workflow( + &self.pool, + community_id, + channel_id, + owner_pubkey, + name, + definition_json, + definition_hash, + ) + .await + } + + /// Insert or update a workflow using its NIP-33 `d`-tag UUID. + #[allow(clippy::too_many_arguments)] + #[datastore_span(name = "upsert_workflow", system = "postgresql")] + pub async fn upsert_workflow( + &self, + community_id: CommunityId, + id: Uuid, + channel_id: Option, + owner_pubkey: &[u8], + name: &str, + definition_json: &str, + definition_hash: &[u8], + ) -> Result<()> { + crate::workflow::upsert_workflow( + &self.pool, + community_id, + id, + channel_id, + owner_pubkey, + name, + definition_json, + definition_hash, + ) + .await + } + + /// Fetch a single workflow by ID, scoped to its community. + #[datastore_span(name = "get_workflow", system = "postgresql")] + pub async fn get_workflow( + &self, + community_id: CommunityId, + id: Uuid, + ) -> Result { + crate::workflow::get_workflow(&self.pool, community_id, id).await + } + + /// List workflows for a channel. + #[datastore_span(name = "list_channel_workflows", system = "postgresql")] + pub async fn list_channel_workflows( + &self, + community_id: CommunityId, + channel_id: Uuid, + limit: Option, + offset: Option, + ) -> Result> { + crate::workflow::list_channel_workflows(&self.pool, community_id, channel_id, limit, offset) + .await + } + + /// List active, enabled workflows for a channel. + #[datastore_span(name = "list_enabled_channel_workflows", system = "postgresql")] + pub async fn list_enabled_channel_workflows( + &self, + community_id: CommunityId, + channel_id: Uuid, + ) -> Result> { + crate::workflow::list_enabled_channel_workflows(&self.pool, community_id, channel_id).await + } + + /// List all active, enabled schedule-triggered workflows. + #[datastore_span(name = "list_all_enabled_workflows", system = "postgresql")] + pub async fn list_all_enabled_workflows(&self) -> Result> { + crate::workflow::list_all_enabled_workflows(&self.pool).await + } + + /// Claim a scheduled workflow fire for an authoritative schedule instant. + /// + /// Returns `Some` only for the first pod to claim `(community_id, + /// workflow_id, scheduled_for)`; all other pods must skip creating a run. + /// `community_id` is server provenance (the workflow row's own community + /// from the scheduler scan), never client-supplied — `workflows` is keyed + /// `(community_id, id)`, so the claim must bind both to avoid fanning + /// across communities that share the workflow UUID. + #[datastore_span(name = "claim_scheduled_workflow_fire", system = "postgresql")] + pub async fn claim_scheduled_workflow_fire( + &self, + community_id: CommunityId, + workflow_id: Uuid, + scheduled_for: chrono::DateTime, + ) -> Result> { + crate::workflow::claim_scheduled_workflow_fire( + &self.pool, + community_id, + workflow_id, + scheduled_for, + ) + .await + } + + /// Fetch the latest claimed schedule instant for interval trigger anchoring. + #[datastore_span(name = "latest_scheduled_workflow_fire", system = "postgresql")] + pub async fn latest_scheduled_workflow_fire( + &self, + community_id: CommunityId, + workflow_id: Uuid, + ) -> Result>> { + crate::workflow::latest_scheduled_workflow_fire(&self.pool, community_id, workflow_id).await + } + + /// Attach the workflow run id created from a won scheduled-fire claim. + #[datastore_span(name = "attach_scheduled_workflow_run", system = "postgresql")] + pub async fn attach_scheduled_workflow_run( + &self, + community_id: CommunityId, + workflow_id: Uuid, + scheduled_for: chrono::DateTime, + workflow_run_id: Uuid, + ) -> Result { + crate::workflow::attach_scheduled_workflow_run( + &self.pool, + community_id, + workflow_id, + scheduled_for, + workflow_run_id, + ) + .await + } + + /// Delete old scheduled workflow fire claims before a retention cutoff. + #[datastore_span(name = "prune_scheduled_workflow_fires_before", system = "postgresql")] + pub async fn prune_scheduled_workflow_fires_before( + &self, + older_than: chrono::DateTime, + ) -> Result { + crate::workflow::prune_scheduled_workflow_fires_before(&self.pool, older_than).await + } + + /// Update a workflow's name, definition, and hash. + #[datastore_span(name = "update_workflow", system = "postgresql")] + pub async fn update_workflow( + &self, + community_id: CommunityId, + id: Uuid, + name: &str, + definition_json: &str, + definition_hash: &[u8], + ) -> Result<()> { + crate::workflow::update_workflow( + &self.pool, + community_id, + id, + name, + definition_json, + definition_hash, + ) + .await + } + + /// Update a workflow's status. + #[datastore_span(name = "update_workflow_status", system = "postgresql")] + pub async fn update_workflow_status( + &self, + community_id: CommunityId, + id: Uuid, + status: crate::workflow::WorkflowStatus, + ) -> Result<()> { + crate::workflow::update_workflow_status(&self.pool, community_id, id, status).await + } + + /// Enable or disable a workflow. + #[datastore_span(name = "set_workflow_enabled", system = "postgresql")] + pub async fn set_workflow_enabled( + &self, + community_id: CommunityId, + id: Uuid, + enabled: bool, + ) -> Result<()> { + crate::workflow::set_workflow_enabled(&self.pool, community_id, id, enabled).await + } + + /// Disable all of an owner's workflows in a channel (SEC-006, on + /// membership loss). Returns the number of workflows disabled. + #[datastore_span(name = "disable_workflows_for_owner_in_channel", system = "postgresql")] + pub async fn disable_workflows_for_owner_in_channel( + &self, + community_id: CommunityId, + channel_id: Uuid, + owner_pubkey: &[u8], + ) -> Result { + crate::workflow::disable_workflows_for_owner_in_channel( + &self.pool, + community_id, + channel_id, + owner_pubkey, + ) + .await + } + + /// Delete a workflow and all its runs/approvals. + #[datastore_span(name = "delete_workflow", system = "postgresql")] + pub async fn delete_workflow(&self, community_id: CommunityId, id: Uuid) -> Result<()> { + crate::workflow::delete_workflow(&self.pool, community_id, id).await + } + + /// Delete a workflow only when it belongs to the provided owner. + /// Returns the deleted workflow's `channel_id`. + #[datastore_span(name = "delete_workflow_for_owner", system = "postgresql")] + pub async fn delete_workflow_for_owner( + &self, + community_id: CommunityId, + id: Uuid, + owner_pubkey: &[u8], + ) -> Result> { + crate::workflow::delete_workflow_for_owner(&self.pool, community_id, id, owner_pubkey).await + } + + /// Find a workflow by owner pubkey and name within a community. Used for + /// NIP-09 a-tag deletion where the d-tag is the workflow name (not UUID). + #[datastore_span(name = "find_workflow_by_owner_and_name", system = "postgresql")] + pub async fn find_workflow_by_owner_and_name( + &self, + community_id: CommunityId, + owner_pubkey: &[u8], + name: &str, + ) -> Result> { + crate::workflow::find_by_owner_and_name(&self.pool, community_id, owner_pubkey, name).await + } +} + // -- Tests -------------------------------------------------------------------- #[cfg(test)] @@ -1774,7 +2191,7 @@ mod tests { use crate::user::ensure_user; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials async fn setup_pool() -> PgPool { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") diff --git a/crates/buzz-db/tests/observability_source.rs b/crates/buzz-db/tests/observability_source.rs index 724a9b47ed..9e37186009 100644 --- a/crates/buzz-db/tests/observability_source.rs +++ b/crates/buzz-db/tests/observability_source.rs @@ -1,6 +1,6 @@ #[test] fn database_metrics_and_slow_logs_exclude_sensitive_or_unbounded_fields() { - let implementation = include_str!("../src/observability.rs"); + let implementation = include_str!("../src/runtime/observability.rs"); let datastore_macro = include_str!("../../buzz-datastore-tracing/src/lib.rs"); let instrumentation = format!("{implementation}\n{datastore_macro}"); @@ -38,3 +38,44 @@ fn database_metrics_and_slow_logs_exclude_sensitive_or_unbounded_fields() { // The runtime tracing-layer assertion covers field names because a source // search would also match ordinary local variables such as `record_error`. } + +#[test] +fn relay_admin_db_wrappers_have_exactly_one_datastore_span() { + for (domain, source) in [ + ( + "relay_admin_actions", + include_str!("../src/store/relay_admin_actions.rs"), + ), + ( + "relay_operators", + include_str!("../src/store/relay_operators.rs"), + ), + ] { + let db_impl = source + .split_once("impl crate::Db {") + .unwrap_or_else(|| panic!("{domain} must own its Db wrappers")) + .1 + .split_once("\n#[cfg(test)]") + .unwrap_or_else(|| panic!("{domain} Db wrappers must precede focused tests")) + .0; + let mut pending_spans = 0; + let mut methods = 0; + + for line in db_impl.lines() { + if line.contains("#[datastore_span(") { + pending_spans += 1; + } + if line.trim_start().starts_with("pub async fn ") { + assert_eq!(pending_spans, 1, "{domain} wrapper `{line}` span count"); + pending_spans = 0; + methods += 1; + } + } + + assert!(methods > 0, "{domain} must own public Db wrappers"); + assert_eq!( + pending_spans, 0, + "{domain} has an unattached datastore span" + ); + } +} diff --git a/desktop/src/shared/api/relayReconnectReplay.test.mjs b/desktop/src/shared/api/relayReconnectReplay.test.mjs index 8ecffdf04d..7c7d96f44c 100644 --- a/desktop/src/shared/api/relayReconnectReplay.test.mjs +++ b/desktop/src/shared/api/relayReconnectReplay.test.mjs @@ -107,7 +107,7 @@ function numericList(source, pattern) { test("channel replay lookback stays coupled to relay and DB source constants", async () => { const [ingest, fence] = await Promise.all([ readFile("../crates/buzz-relay/src/handlers/ingest.rs", "utf8"), - readFile("../crates/buzz-db/src/replica_fence.rs", "utf8"), + readFile("../crates/buzz-db/src/runtime/replica_fence.rs", "utf8"), ]); assert.match( ingest,