mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
## Summary Route `Db::is_relay_member` — the membership check that runs on every authenticated HTTP request and WS AUTH — through the standard `route_read` machinery on the bounded arm, instead of adding a bespoke cache (replaces #3844). - `crates/buzz-db/src/relay_members.rs`: add `is_relay_member_on(&mut PgConnection, ...)` executor seam; the pool version delegates to it. - `crates/buzz-db/src/lib.rs`: `Db::is_relay_member` now routes via `route_read("relay_membership", RoutePredicate::Bounded)` — replica only on a proved fresh session, writer on any route rejection, writer re-run on replica query error. Exactly the shape of every other routed read. This is the one permission read served from the replica, by explicit product decision (Tyler accepted ≤1s bounded staleness on reads we choose): the fleet-wide fence guarantee (`BUZZ_REPLICA_READ_MAX_AGE_MS`, deploy target 1s) is an order of magnitude tighter than the 10s TTL proposed in #3844 and needs no invalidation machinery. Staleness is symmetric for admits and revokes. `BUZZ_REPLICA_READ_MAX_AGE_MS` unset = writer-only = kill switch. It is not precedent for routing other permission reads. ## Validation At this exact commit (`git rev-parse HEAD` confirmed in the same shell, rustc 1.95): - `cargo test -p buzz-db` — 94 passed, 0 failed - PG-gated suite single-threaded — **151 passed, 2 failed**; the 2 failures are the per-owner-limit tests broken on main by #3829 (limit 3→5, tests still seed 3) — they fail identically at base `19d57b0d4` in a pristine control checkout; separate trivial fix to follow - New PG-gated test `is_relay_member_is_bounded_routed_and_fails_closed` — divergent writer/replica fixtures prove: budget unset ⇒ writer; budget set + fresh proof ⇒ replica; over-budget entry ⇒ writer - clippy `-D warnings` + fmt clean; pre-push hooks green (desktop check/test, rust tests, tauri checks) - **Live-local pass** (TESTING.md, release binary, `BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`, fresh DB): - writer-only (no `READ_DATABASE_URL`): member accepted, outsider 403 `relay_membership_required`; metrics `route_decision{path="relay_membership",decision="writer",reason="disabled"}` - replica configured + `BUZZ_REPLICA_READ_MAX_AGE_MS=1000`: member accepted / outsider denied via `decision="replica",reason="fresh"`; admit visible to the routed check within ~1.2s; revoke enforced within ~1.2s - reader outage mid-flight (TCP proxy killed): member send still succeeds in <200ms via `decision="writer",reason="reader_acquire_timeout"`; outsider still denied — fails closed, no availability loss Reviewed by Wren: 9/10 minimalness, 9/10 elegance, 9.5/10 correctness at this SHA. Supersedes the 10s-cache approach in PR 3844, which should be closed unmerged once this lands. Signed-off-by: Tyler <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
8586 lines
327 KiB
Rust
8586 lines
327 KiB
Rust
#![deny(unsafe_code)]
|
||
#![warn(missing_docs)]
|
||
//! buzz-db — Postgres event store for Buzz.
|
||
//!
|
||
//! ## Design invariants
|
||
//! - AUTH events (kind 22242) are never stored — they carry bearer tokens.
|
||
//! - Ephemeral events (20000–29999) are never stored — Redis pub/sub only.
|
||
//! - Events table is partitioned by month on `created_at`.
|
||
//! - No FK references to partitioned tables.
|
||
//! - Uses `sqlx::query()` (runtime) not `sqlx::query!()` (compile-time).
|
||
|
||
/// 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 and membership persistence.
|
||
pub mod channel;
|
||
/// 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;
|
||
/// 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;
|
||
/// Use-limited relay invite persistence (v2 opaque tokens).
|
||
pub mod relay_invite;
|
||
/// Relay-level membership persistence (NIP-43).
|
||
pub mod relay_members;
|
||
/// 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 error::{DbError, Result};
|
||
pub use event::{EventQuery, ReactionEventInsertOutcome, DEFAULT_MAX_PAGE_LIMIT};
|
||
|
||
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};
|
||
|
||
fn event_replacement_lock_key(
|
||
community_id: CommunityId,
|
||
kind: i32,
|
||
pubkey: &[u8],
|
||
coordinate: Option<&[u8]>,
|
||
) -> i64 {
|
||
let mut hash: u64 = 0xcbf29ce484222325;
|
||
let kind_bytes = kind.to_le_bytes();
|
||
for bytes in [
|
||
community_id.as_uuid().as_bytes().as_slice(),
|
||
kind_bytes.as_slice(),
|
||
pubkey,
|
||
] {
|
||
for byte in bytes {
|
||
hash ^= *byte as u64;
|
||
hash = hash.wrapping_mul(0x100000001b3);
|
||
}
|
||
}
|
||
if let Some(coordinate) = coordinate {
|
||
for byte in coordinate {
|
||
hash ^= *byte as u64;
|
||
hash = hash.wrapping_mul(0x100000001b3);
|
||
}
|
||
}
|
||
hash as i64
|
||
}
|
||
|
||
/// Extract p-tag mentions from an event and insert into the `event_mentions` table.
|
||
///
|
||
/// Called after event insertion. Failures are logged but do not block event storage.
|
||
/// Uses `INSERT ... ON CONFLICT DO NOTHING` so duplicate inserts are silently skipped.
|
||
pub async fn insert_mentions(
|
||
pool: &PgPool,
|
||
community_id: CommunityId,
|
||
event: &nostr::Event,
|
||
channel_id: Option<Uuid>,
|
||
) -> 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<String> = 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(());
|
||
}
|
||
|
||
// Single multi-row INSERT ... ON CONFLICT DO NOTHING — one round-trip regardless of mention count.
|
||
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(
|
||
"INSERT INTO event_mentions \
|
||
(community_id, pubkey_hex, event_id, event_created_at, channel_id, event_kind) ",
|
||
);
|
||
|
||
qb.push_values(&valid_pubkeys, |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(pool).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<PgPool>,
|
||
/// 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<replica_fence::ReplicaFence>,
|
||
/// 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<Duration>,
|
||
/// 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<std::sync::OnceLock<bool>>,
|
||
}
|
||
|
||
/// 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.
|
||
pub async fn query_events(&mut self, q: &EventQuery) -> Result<Vec<StoredEvent>> {
|
||
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 = <uuid>` 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<Self> {
|
||
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<Utc>,
|
||
/// 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<Utc>,
|
||
/// 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<Utc>, Vec<u8>)>) -> 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<Duration> {
|
||
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<String>,
|
||
/// 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<u32>,
|
||
/// 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,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Community host-map row returned by [`Db::lookup_community_by_host`].
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct CommunityRecord {
|
||
/// Stable server-resolved community id.
|
||
pub id: CommunityId,
|
||
/// Normalized host that maps to this community.
|
||
pub host: String,
|
||
}
|
||
|
||
/// Community row returned by idempotent community ensure/create operations.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct EnsuredCommunityRecord {
|
||
/// Stable server-resolved community id.
|
||
pub id: CommunityId,
|
||
/// Normalized host that maps to this community.
|
||
pub host: String,
|
||
/// True only when this call inserted the `communities` row.
|
||
pub created: bool,
|
||
}
|
||
|
||
/// Community row returned by an atomic create-with-owner operation.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct CreatedCommunityRecord {
|
||
/// Stable server-resolved community id.
|
||
pub id: CommunityId,
|
||
/// Normalized host stored for the community.
|
||
pub host: String,
|
||
}
|
||
|
||
/// Result of atomically creating a community with its initial owner.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum CreateCommunityWithOwnerResult {
|
||
/// The community was created, or an identical retried create found it.
|
||
Created(CreatedCommunityRecord),
|
||
/// The host already belongs to another owner.
|
||
HostExists,
|
||
/// The intended owner already owns the maximum number of communities.
|
||
LimitReached,
|
||
}
|
||
|
||
/// Community row returned by operator-plane ownership reads.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct OwnedCommunityRecord {
|
||
/// Stable server-resolved community id.
|
||
pub id: CommunityId,
|
||
/// Normalized host that maps to this community.
|
||
pub host: String,
|
||
/// When the community row was created.
|
||
pub created_at: DateTime<Utc>,
|
||
/// When the community was archived; absent while active.
|
||
pub archived_at: Option<DateTime<Utc>>,
|
||
}
|
||
|
||
/// Community row returned by an owner-authorized archive operation.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct ArchivedCommunityRecord {
|
||
/// Stable server-resolved community id.
|
||
pub id: CommunityId,
|
||
/// Reserved canonical host.
|
||
pub host: String,
|
||
/// Durable first-archive timestamp.
|
||
pub archived_at: DateTime<Utc>,
|
||
}
|
||
|
||
/// Community row returned by an owner-authorized unarchive operation.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct UnarchivedCommunityRecord {
|
||
/// Stable server-resolved community id.
|
||
pub id: CommunityId,
|
||
/// Reserved canonical host restored to active admission.
|
||
pub host: String,
|
||
}
|
||
|
||
/// 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<u8>,
|
||
/// Permission scopes granted to this token.
|
||
pub scopes: Vec<String>,
|
||
/// When the token was created.
|
||
pub created_at: DateTime<Utc>,
|
||
/// Optional expiry timestamp; `None` means no expiry.
|
||
pub expires_at: Option<DateTime<Utc>>,
|
||
}
|
||
|
||
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<Self> {
|
||
let pool = Self::connect_pool(config, &config.database_url, true).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 one pool with the sizing knobs from `config`.
|
||
///
|
||
/// `arm_floor_guard` sets the `buzz.created_at_floor` session GUC on
|
||
/// every connection, arming the deferred commit-time trigger from
|
||
/// migration 0021. Writer pools must arm it; replica pools are read-only
|
||
/// so the trigger never fires there.
|
||
async fn connect_pool(config: &DbConfig, url: &str, arm_floor_guard: bool) -> Result<PgPool> {
|
||
let mut 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));
|
||
if arm_floor_guard {
|
||
options = options.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(conn)
|
||
.await?;
|
||
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: replica sessions are read-only, the trigger never
|
||
/// fires there (see [`Db::connect_pool`]).
|
||
fn connect_read_pool(config: &DbConfig, url: &str, max_connections: u32) -> Result<PgPool> {
|
||
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 read_pool.acquire().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<Duration>) {
|
||
self.replica_read_max_age = budget;
|
||
}
|
||
|
||
/// The freshness fence gating replica routing (see [`replica_fence`]).
|
||
pub fn fence(&self) -> &std::sync::Arc<replica_fence::ReplicaFence> {
|
||
&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<bool> {
|
||
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 read_pool.acquire().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<sqlx::Postgres>,
|
||
) -> 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.
|
||
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<DbPoolStats> {
|
||
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.
|
||
pub async fn try_lock_usage_metrics(
|
||
&self,
|
||
lock_key: i64,
|
||
) -> Result<Option<UsageMetricsLeader>> {
|
||
let mut connection = self.pool.acquire().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)]
|
||
pub async fn admin_list_reports(
|
||
&self,
|
||
community_id: Option<Uuid>,
|
||
status: Option<&str>,
|
||
report_type: Option<&str>,
|
||
target_kind: Option<&str>,
|
||
after: Option<DateTime<Utc>>,
|
||
before: Option<DateTime<Utc>>,
|
||
cursor: Option<(DateTime<Utc>, Uuid)>,
|
||
limit: i64,
|
||
) -> Result<Vec<admin_moderation::AdminReport>> {
|
||
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.
|
||
pub async fn admin_get_report(
|
||
&self,
|
||
id: Uuid,
|
||
) -> Result<Option<admin_moderation::AdminReportDetail>> {
|
||
admin_moderation::get_report(&self.pool, id).await
|
||
}
|
||
|
||
/// List feedback for the deployment-global read-only admin plane.
|
||
pub async fn admin_list_feedback(
|
||
&self,
|
||
limit: i64,
|
||
) -> Result<Vec<admin_moderation::AdminFeedback>> {
|
||
admin_moderation::list_feedback(&self.pool, limit).await
|
||
}
|
||
|
||
/// Fetch one feedback submission for the deployment-global admin plane.
|
||
pub async fn admin_get_feedback(
|
||
&self,
|
||
id: Uuid,
|
||
) -> Result<Option<admin_moderation::AdminFeedback>> {
|
||
admin_moderation::get_feedback(&self.pool, id).await
|
||
}
|
||
|
||
/// Return total number of communities on this relay.
|
||
pub async fn usage_community_count(&self) -> Result<i64> {
|
||
usage::community_count(&self.pool).await
|
||
}
|
||
|
||
/// Return per-community user counts split by human/agent.
|
||
pub async fn usage_user_counts(&self) -> Result<Vec<usage::CommunityUserCounts>> {
|
||
usage::user_counts(&self.pool).await
|
||
}
|
||
|
||
/// Return per-community channel counts by type.
|
||
pub async fn usage_channel_counts(&self) -> Result<Vec<usage::CommunityChannelCount>> {
|
||
usage::channel_counts(&self.pool).await
|
||
}
|
||
|
||
/// Return per-community kind=9 message counts.
|
||
pub async fn usage_message_counts(&self) -> Result<Vec<usage::CommunityMessageCount>> {
|
||
usage::message_counts(&self.pool).await
|
||
}
|
||
|
||
/// Return per-community relay-member counts by role.
|
||
pub async fn usage_relay_member_counts(&self) -> Result<Vec<usage::CommunityMemberCount>> {
|
||
usage::relay_member_counts(&self.pool).await
|
||
}
|
||
|
||
/// Return per-community workflow counts by status.
|
||
pub async fn usage_workflow_counts(&self) -> Result<Vec<usage::CommunityWorkflowCount>> {
|
||
usage::workflow_counts(&self.pool).await
|
||
}
|
||
|
||
/// Return per-community git-repo counts.
|
||
pub async fn usage_git_repo_counts(&self) -> Result<Vec<usage::CommunityGitRepoCount>> {
|
||
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"`.
|
||
pub async fn usage_active_user_counts(
|
||
&self,
|
||
interval_sql: &'static str,
|
||
) -> Result<Vec<usage::CommunityActiveUsers>> {
|
||
usage::active_user_counts(&self.pool, interval_sql).await
|
||
}
|
||
|
||
/// Return per-community active-channel counts for a given SQL interval.
|
||
pub async fn usage_active_channel_counts(
|
||
&self,
|
||
interval_sql: &'static str,
|
||
) -> Result<Vec<usage::CommunityActiveChannels>> {
|
||
usage::active_channel_counts(&self.pool, interval_sql).await
|
||
}
|
||
|
||
/// Return all community id → host mappings.
|
||
pub async fn usage_community_hosts(&self) -> Result<Vec<usage::CommunityHost>> {
|
||
usage::community_hosts(&self.pool).await
|
||
}
|
||
|
||
/// 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<sqlx::Transaction<'static, sqlx::Postgres>> {
|
||
self.pool.begin().await.map_err(Into::into)
|
||
}
|
||
|
||
/// Returns the community mapped to a normalized request host, if one exists.
|
||
///
|
||
/// The caller owns host normalization and turns `None` into the fail-closed
|
||
/// request/connection error. buzz-db only reads the durable host map.
|
||
pub async fn lookup_community_by_host(
|
||
&self,
|
||
normalized_host: &str,
|
||
) -> Result<Option<CommunityRecord>> {
|
||
let row = sqlx::query(
|
||
r#"
|
||
SELECT id, host
|
||
FROM communities
|
||
WHERE lower(host) = lower($1)
|
||
AND archived_at IS NULL
|
||
"#,
|
||
)
|
||
.bind(normalized_host)
|
||
.fetch_optional(&self.pool)
|
||
.await?;
|
||
|
||
row.map(|row| {
|
||
let id: Uuid = row.try_get("id")?;
|
||
let host: String = row.try_get("host")?;
|
||
|
||
Ok(CommunityRecord {
|
||
id: CommunityId::from_uuid(id),
|
||
host,
|
||
})
|
||
})
|
||
.transpose()
|
||
}
|
||
|
||
/// Returns whether a community id still exists in the active lifecycle state.
|
||
pub async fn is_community_active(&self, community_id: CommunityId) -> Result<bool> {
|
||
let active = sqlx::query_scalar::<_, bool>(
|
||
"SELECT EXISTS(SELECT 1 FROM communities WHERE id = $1 AND archived_at IS NULL)",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.fetch_one(&self.pool)
|
||
.await?;
|
||
Ok(active)
|
||
}
|
||
|
||
/// Returns a community by host regardless of lifecycle state. Operator-plane only.
|
||
pub async fn lookup_community_by_host_for_management(
|
||
&self,
|
||
normalized_host: &str,
|
||
) -> Result<Option<CommunityRecord>> {
|
||
let row = sqlx::query("SELECT id, host FROM communities WHERE lower(host) = lower($1)")
|
||
.bind(normalized_host)
|
||
.fetch_optional(&self.pool)
|
||
.await?;
|
||
row.map(|row| {
|
||
Ok(CommunityRecord {
|
||
id: CommunityId::from_uuid(row.try_get("id")?),
|
||
host: row.try_get("host")?,
|
||
})
|
||
})
|
||
.transpose()
|
||
}
|
||
|
||
/// Lists communities where `owner_pubkey` currently holds the `owner` role.
|
||
///
|
||
/// This is an operator-plane helper, not a tenant-scoped data-plane read:
|
||
/// callers must gate it on deployment-level operator auth before exposing it.
|
||
pub async fn list_communities_owned_by(
|
||
&self,
|
||
owner_pubkey: &str,
|
||
) -> Result<Vec<OwnedCommunityRecord>> {
|
||
let owner_pubkey = owner_pubkey.to_ascii_lowercase();
|
||
let rows = sqlx::query(
|
||
r#"
|
||
SELECT c.id, c.host, c.created_at, c.archived_at
|
||
FROM communities c
|
||
JOIN relay_members rm ON rm.community_id = c.id
|
||
WHERE rm.pubkey = $1
|
||
AND rm.role = 'owner'
|
||
ORDER BY c.created_at ASC, c.host ASC
|
||
"#,
|
||
)
|
||
.bind(owner_pubkey)
|
||
.fetch_all(&self.pool)
|
||
.await?;
|
||
|
||
rows.into_iter()
|
||
.map(|row| {
|
||
let id: Uuid = row.try_get("id")?;
|
||
let host: String = row.try_get("host")?;
|
||
let created_at: DateTime<Utc> = row.try_get("created_at")?;
|
||
let archived_at: Option<DateTime<Utc>> = row.try_get("archived_at")?;
|
||
Ok(OwnedCommunityRecord {
|
||
id: CommunityId::from_uuid(id),
|
||
host,
|
||
created_at,
|
||
archived_at,
|
||
})
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Returns the normalized host mapped to a community id, if the community
|
||
/// exists.
|
||
///
|
||
/// The reverse of [`lookup_community_by_host`]: used by side-effect
|
||
/// producers that already hold a server-resolved `CommunityId` (e.g. the
|
||
/// workflow action sink running a run owned by some community) and need a
|
||
/// fully-formed [`buzz_core::tenant::TenantContext`] — host included — to
|
||
/// fan out under *that* community rather than the deployment default. The
|
||
/// community is authoritative; the host is read back for labelling only and
|
||
/// is never used to re-derive the community.
|
||
pub async fn lookup_community_host(&self, community_id: CommunityId) -> Result<Option<String>> {
|
||
let row = sqlx::query(
|
||
r#"
|
||
SELECT host
|
||
FROM communities
|
||
WHERE id = $1
|
||
AND archived_at IS NULL
|
||
"#,
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.fetch_optional(&self.pool)
|
||
.await?;
|
||
|
||
row.map(|row| {
|
||
let host: String = row.try_get("host")?;
|
||
Ok(host)
|
||
})
|
||
.transpose()
|
||
}
|
||
|
||
/// Returns the community's workspace icon (NIP-11 `icon`), if set.
|
||
///
|
||
/// Set by relay admins/owners via the kind:9033 command; the value is
|
||
/// validated and size-capped at that write path.
|
||
pub async fn get_community_icon(&self, community_id: CommunityId) -> Result<Option<String>> {
|
||
let row = sqlx::query(
|
||
r#"
|
||
SELECT icon
|
||
FROM communities
|
||
WHERE id = $1
|
||
"#,
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.fetch_optional(&self.pool)
|
||
.await?;
|
||
|
||
Ok(row
|
||
.map(|row| row.try_get::<Option<String>, _>("icon"))
|
||
.transpose()?
|
||
.flatten()
|
||
.filter(|icon| !icon.is_empty()))
|
||
}
|
||
|
||
/// Sets or clears (`None`) the community's workspace icon.
|
||
pub async fn set_community_icon(
|
||
&self,
|
||
community_id: CommunityId,
|
||
icon: Option<&str>,
|
||
) -> Result<()> {
|
||
sqlx::query(
|
||
r#"
|
||
UPDATE communities
|
||
SET icon = $2
|
||
WHERE id = $1
|
||
"#,
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(icon)
|
||
.execute(&self.pool)
|
||
.await?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Ensure a configured community host exists and return its row.
|
||
///
|
||
/// This is the startup/config seeding path for N=1 deployments. Migrations
|
||
/// create the schema only; deployment-specific hosts are not hardcoded into
|
||
/// schema history.
|
||
pub async fn ensure_configured_community(
|
||
&self,
|
||
normalized_host: &str,
|
||
) -> Result<EnsuredCommunityRecord> {
|
||
let row = sqlx::query(
|
||
r#"
|
||
INSERT INTO communities (host)
|
||
VALUES ($1)
|
||
ON CONFLICT (lower(host)) DO UPDATE SET host = communities.host
|
||
RETURNING id, host, (xmax = 0) AS created
|
||
"#,
|
||
)
|
||
.bind(normalized_host)
|
||
.fetch_one(&self.pool)
|
||
.await?;
|
||
|
||
let id: Uuid = row.try_get("id")?;
|
||
let host: String = row.try_get("host")?;
|
||
let created: bool = row.try_get("created")?;
|
||
|
||
Ok(EnsuredCommunityRecord {
|
||
id: CommunityId::from_uuid(id),
|
||
host,
|
||
created,
|
||
})
|
||
}
|
||
|
||
/// Atomically creates a community and its initial owner.
|
||
///
|
||
/// Holds a per-owner advisory lock while enforcing the ownership limit.
|
||
/// Identical create retries return the original record; host collisions and
|
||
/// limit failures remain distinguishable to the operator API.
|
||
pub async fn create_community_with_owner(
|
||
&self,
|
||
normalized_host: &str,
|
||
owner_pubkey: &str,
|
||
) -> Result<CreateCommunityWithOwnerResult> {
|
||
let owner_pubkey = owner_pubkey.to_ascii_lowercase();
|
||
let mut tx = self.pool.begin().await?;
|
||
|
||
// Serialize on the owner pubkey so concurrent creates to the same
|
||
// owner cannot both pass the ownership count check.
|
||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||
.bind(relay_members::owner_count_advisory_lock_key(&owner_pubkey))
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
let row = sqlx::query(
|
||
r#"
|
||
INSERT INTO communities (host)
|
||
VALUES ($1)
|
||
ON CONFLICT (lower(host)) DO NOTHING
|
||
RETURNING id, host
|
||
"#,
|
||
)
|
||
.bind(normalized_host)
|
||
.fetch_optional(&mut *tx)
|
||
.await?;
|
||
|
||
let (id, host) = if let Some(row) = row {
|
||
let id: Uuid = row.try_get("id")?;
|
||
let host: String = row.try_get("host")?;
|
||
|
||
// Enforce the limit before inserting the new owner row.
|
||
let owned_count: i64 = sqlx::query_scalar(
|
||
"SELECT count(*) FROM relay_members WHERE pubkey = $1 AND role = 'owner'",
|
||
)
|
||
.bind(&owner_pubkey)
|
||
.fetch_one(&mut *tx)
|
||
.await?;
|
||
|
||
if owned_count >= relay_members::max_communities_per_owner() {
|
||
tx.rollback().await?;
|
||
return Ok(CreateCommunityWithOwnerResult::LimitReached);
|
||
}
|
||
|
||
sqlx::query(
|
||
"INSERT INTO relay_members (community_id, pubkey, role, added_by) VALUES ($1, $2, 'owner', NULL)",
|
||
)
|
||
.bind(id)
|
||
.bind(&owner_pubkey)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
(id, host)
|
||
} else {
|
||
let existing = sqlx::query(
|
||
r#"
|
||
SELECT c.id, c.host
|
||
FROM communities c
|
||
JOIN relay_members rm ON rm.community_id = c.id
|
||
WHERE lower(c.host) = lower($1)
|
||
AND lower(rm.pubkey) = lower($2)
|
||
AND rm.role = 'owner'
|
||
AND c.archived_at IS NULL
|
||
"#,
|
||
)
|
||
.bind(normalized_host)
|
||
.bind(&owner_pubkey)
|
||
.fetch_optional(&mut *tx)
|
||
.await?;
|
||
let Some(existing) = existing else {
|
||
tx.rollback().await?;
|
||
return Ok(CreateCommunityWithOwnerResult::HostExists);
|
||
};
|
||
(existing.try_get("id")?, existing.try_get("host")?)
|
||
};
|
||
|
||
tx.commit().await?;
|
||
Ok(CreateCommunityWithOwnerResult::Created(
|
||
CreatedCommunityRecord {
|
||
id: CommunityId::from_uuid(id),
|
||
host,
|
||
},
|
||
))
|
||
}
|
||
|
||
/// Idempotently archives a community when the asserted pubkey is its current owner.
|
||
pub async fn archive_community_owned_by(
|
||
&self,
|
||
normalized_host: &str,
|
||
owner_pubkey: &str,
|
||
protected_deployment_host: &str,
|
||
) -> Result<Option<ArchivedCommunityRecord>> {
|
||
let row = sqlx::query(
|
||
r#"UPDATE communities c
|
||
SET archived_at = COALESCE(c.archived_at, now())
|
||
FROM relay_members rm
|
||
WHERE lower(c.host) = lower($1)
|
||
AND rm.community_id = c.id
|
||
AND lower(rm.pubkey) = lower($2)
|
||
AND rm.role = 'owner'
|
||
AND lower(c.host) <> lower($3)
|
||
RETURNING c.id, c.host, c.archived_at"#,
|
||
)
|
||
.bind(normalized_host)
|
||
.bind(owner_pubkey)
|
||
.bind(protected_deployment_host)
|
||
.fetch_optional(&self.pool)
|
||
.await?;
|
||
row.map(|row| {
|
||
Ok(ArchivedCommunityRecord {
|
||
id: CommunityId::from_uuid(row.try_get("id")?),
|
||
host: row.try_get("host")?,
|
||
archived_at: row.try_get("archived_at")?,
|
||
})
|
||
})
|
||
.transpose()
|
||
}
|
||
|
||
/// Idempotently restores a community when the asserted pubkey is its current owner.
|
||
pub async fn unarchive_community_owned_by(
|
||
&self,
|
||
normalized_host: &str,
|
||
owner_pubkey: &str,
|
||
) -> Result<Option<UnarchivedCommunityRecord>> {
|
||
let row = sqlx::query(
|
||
r#"UPDATE communities c
|
||
SET archived_at = NULL
|
||
FROM relay_members rm
|
||
WHERE lower(c.host) = lower($1)
|
||
AND rm.community_id = c.id
|
||
AND lower(rm.pubkey) = lower($2)
|
||
AND rm.role = 'owner'
|
||
RETURNING c.id, c.host"#,
|
||
)
|
||
.bind(normalized_host)
|
||
.bind(owner_pubkey)
|
||
.fetch_optional(&self.pool)
|
||
.await?;
|
||
row.map(|row| {
|
||
Ok(UnarchivedCommunityRecord {
|
||
id: CommunityId::from_uuid(row.try_get("id")?),
|
||
host: row.try_get("host")?,
|
||
})
|
||
})
|
||
.transpose()
|
||
}
|
||
|
||
/// Returns the community that owns a channel, if the channel exists.
|
||
///
|
||
/// Internal relay producers use this to derive tenant context from the row
|
||
/// they are acting on, rather than falling back to an implicit default.
|
||
pub async fn community_of_channel(&self, channel_id: Uuid) -> Result<Option<CommunityId>> {
|
||
let row = sqlx::query(
|
||
r#"
|
||
SELECT community_id
|
||
FROM channels
|
||
WHERE id = $1
|
||
AND deleted_at IS NULL
|
||
"#,
|
||
)
|
||
.bind(channel_id)
|
||
.fetch_optional(&self.pool)
|
||
.await?;
|
||
|
||
row.map(|row| {
|
||
let id: Uuid = row.try_get("community_id")?;
|
||
Ok(CommunityId::from_uuid(id))
|
||
})
|
||
.transpose()
|
||
}
|
||
|
||
/// Batched version of [`Self::community_of_channel`]: given a list of
|
||
/// channel UUIDs, returns a map from channel id → owning community
|
||
/// for every channel that exists (soft-deletes excluded).
|
||
///
|
||
/// Used by the runtime conformance read-seam emitters in `buzz-relay`:
|
||
/// after a `query_events`/`get_events_by_ids` returns N rows, the
|
||
/// emitter collects distinct `channel_id`s, calls this once, then
|
||
/// projects each row's true community label independently of the
|
||
/// fetch query's WHERE clause. That independence is what makes the
|
||
/// `Inv_NonInterference` / `Inv_ReadConfinement` gate non-vacuous —
|
||
/// a mutation that dropped `community_id = $X` from the fetch query
|
||
/// would still let this helper return the row's true label, and the
|
||
/// checker would see the mismatch.
|
||
///
|
||
/// Channels missing from the result map (deleted or never existed)
|
||
/// are intentionally not present rather than mapped to a default —
|
||
/// callers MUST treat "channel-id not in map" as a coverage breach,
|
||
/// never as "use the resolved community".
|
||
pub async fn communities_of_channels(
|
||
&self,
|
||
channel_ids: &[Uuid],
|
||
) -> Result<std::collections::HashMap<Uuid, CommunityId>> {
|
||
if channel_ids.is_empty() {
|
||
return Ok(std::collections::HashMap::new());
|
||
}
|
||
let rows = sqlx::query(
|
||
r#"
|
||
SELECT id, community_id
|
||
FROM channels
|
||
WHERE id = ANY($1)
|
||
AND deleted_at IS NULL
|
||
"#,
|
||
)
|
||
.bind(channel_ids)
|
||
.fetch_all(&self.pool)
|
||
.await?;
|
||
|
||
let mut out = std::collections::HashMap::with_capacity(rows.len());
|
||
for row in rows {
|
||
let ch: Uuid = row.try_get("id")?;
|
||
let cm: Uuid = row.try_get("community_id")?;
|
||
out.insert(ch, CommunityId::from_uuid(cm));
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// Inserts an event. Returns `(StoredEvent, was_inserted)` — `false` on duplicate.
|
||
pub async fn insert_event(
|
||
&self,
|
||
community_id: CommunityId,
|
||
event: &nostr::Event,
|
||
channel_id: Option<Uuid>,
|
||
) -> 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)
|
||
}
|
||
|
||
/// 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.
|
||
pub async fn query_events(&self, q: &EventQuery) -> Result<Vec<StoredEvent>> {
|
||
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.
|
||
pub async fn query_events_routed(
|
||
&self,
|
||
path: &'static str,
|
||
q: &EventQuery,
|
||
) -> Result<Vec<StoredEvent>> {
|
||
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`].
|
||
pub async fn query_events_routed_bounded(
|
||
&self,
|
||
path: &'static str,
|
||
q: &EventQuery,
|
||
) -> Result<Vec<StoredEvent>> {
|
||
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.
|
||
pub async fn count_events(&self, q: &EventQuery) -> Result<i64> {
|
||
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`.
|
||
pub async fn count_events_routed(&self, path: &'static str, q: &EventQuery) -> Result<i64> {
|
||
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.
|
||
pub async fn huddle_started_link_exists(
|
||
&self,
|
||
community_id: CommunityId,
|
||
parent_channel_id: Uuid,
|
||
ephemeral_channel_id: Uuid,
|
||
creator_pubkey: &[u8],
|
||
) -> Result<bool> {
|
||
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.
|
||
pub async fn get_latest_global_replaceable(
|
||
&self,
|
||
community_id: CommunityId,
|
||
kind: i32,
|
||
pubkey_bytes: &[u8],
|
||
) -> Result<Option<StoredEvent>> {
|
||
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.
|
||
pub async fn get_event_by_id(
|
||
&self,
|
||
community_id: CommunityId,
|
||
id_bytes: &[u8],
|
||
) -> Result<Option<StoredEvent>> {
|
||
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**.
|
||
pub async fn get_event_by_id_including_deleted(
|
||
&self,
|
||
community_id: CommunityId,
|
||
id_bytes: &[u8],
|
||
) -> Result<Option<StoredEvent>> {
|
||
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.
|
||
pub async fn soft_delete_event(
|
||
&self,
|
||
community_id: CommunityId,
|
||
event_id: &[u8],
|
||
) -> Result<bool> {
|
||
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`.
|
||
pub async fn soft_delete_by_coordinate(
|
||
&self,
|
||
community_id: CommunityId,
|
||
kind: i32,
|
||
pubkey: &[u8],
|
||
d_tag: &str,
|
||
deletion_created_at_secs: i64,
|
||
) -> Result<bool> {
|
||
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.
|
||
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<bool> {
|
||
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.
|
||
pub async fn get_last_message_at(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_id: Uuid,
|
||
) -> Result<Option<DateTime<Utc>>> {
|
||
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.
|
||
pub async fn get_last_message_at_bulk(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_ids: &[Uuid],
|
||
) -> Result<std::collections::HashMap<Uuid, DateTime<Utc>>> {
|
||
event::get_last_message_at_bulk(&self.pool, community_id, channel_ids).await
|
||
}
|
||
|
||
/// Batch-fetch non-deleted events by their raw IDs.
|
||
pub async fn get_events_by_ids(
|
||
&self,
|
||
community_id: CommunityId,
|
||
ids: &[&[u8]],
|
||
) -> Result<Vec<StoredEvent>> {
|
||
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.
|
||
pub async fn get_events_by_ids_routed(
|
||
&self,
|
||
path: &'static str,
|
||
community_id: CommunityId,
|
||
ids: &[&[u8]],
|
||
) -> Result<Vec<StoredEvent>> {
|
||
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.
|
||
pub async fn claim_due_push_match_batch(
|
||
&self,
|
||
limit: i64,
|
||
lease_until: DateTime<Utc>,
|
||
) -> Result<Option<push::ClaimedMatchBatch>> {
|
||
push::claim_due_match_batch(&self.pool, limit, lease_until).await
|
||
}
|
||
|
||
/// Load active endpoint-enabled leases eligible for push matching.
|
||
pub async fn active_push_match_leases(
|
||
&self,
|
||
community: CommunityId,
|
||
) -> Result<Vec<push::MatchLease>> {
|
||
push::active_match_leases(&self.pool, community).await
|
||
}
|
||
|
||
/// Complete matcher jobs from one claimed batch while the fence holds.
|
||
pub async fn complete_push_match_batch(
|
||
&self,
|
||
community: CommunityId,
|
||
claim_id: uuid::Uuid,
|
||
event_ids: &[Vec<u8>],
|
||
) -> Result<u64> {
|
||
push::complete_match_batch(&self.pool, community, claim_id, event_ids).await
|
||
}
|
||
|
||
/// Release fenced matcher claims from one batch for retry.
|
||
pub async fn retry_push_match_batch(
|
||
&self,
|
||
community: CommunityId,
|
||
claim_id: uuid::Uuid,
|
||
event_ids: &[Vec<u8>],
|
||
next: DateTime<Utc>,
|
||
) -> Result<u64> {
|
||
push::retry_match_batch(&self.pool, community, claim_id, event_ids, next).await
|
||
}
|
||
|
||
/// Delete exhausted matcher jobs (periodic sweep, off the claim path).
|
||
pub async fn reap_exhausted_push_matches(&self) -> Result<u64> {
|
||
push::reap_exhausted_matches(&self.pool).await
|
||
}
|
||
|
||
/// Idempotently enqueue a wake for a matched lease and event.
|
||
pub async fn enqueue_push_wake(
|
||
&self,
|
||
community: CommunityId,
|
||
author: &[u8],
|
||
installation_id: &str,
|
||
wake: push::NewWake<'_>,
|
||
) -> Result<push::EnqueueWakeOutcome> {
|
||
push::enqueue_wake(&self.pool, community, author, installation_id, wake).await
|
||
}
|
||
|
||
/// Set-wise [`Self::enqueue_push_wake`]: one transaction per batch.
|
||
pub async fn enqueue_push_wakes(
|
||
&self,
|
||
community: CommunityId,
|
||
requests: &[push::WakeRequest],
|
||
) -> Result<Vec<push::EnqueueWakeOutcome>> {
|
||
push::enqueue_wakes(&self.pool, community, requests).await
|
||
}
|
||
|
||
/// Exclusively claim due wake jobs for one community.
|
||
pub async fn claim_due_push_wakes(
|
||
&self,
|
||
community: CommunityId,
|
||
limit: i64,
|
||
lease_until: DateTime<Utc>,
|
||
) -> Result<Vec<push::ClaimedWake>> {
|
||
push::claim_due_wakes(&self.pool, community, limit, lease_until).await
|
||
}
|
||
|
||
/// Revalidate a wake's claim, source event, and current lease before send.
|
||
pub async fn revalidate_push_wake(
|
||
&self,
|
||
community: CommunityId,
|
||
id: Uuid,
|
||
claim_id: Uuid,
|
||
) -> Result<push::RevalidateWakeOutcome> {
|
||
push::revalidate_wake_for_send(&self.pool, community, id, claim_id).await
|
||
}
|
||
|
||
/// Mark a fenced wake claim delivered.
|
||
pub async fn complete_push_wake(
|
||
&self,
|
||
community: CommunityId,
|
||
id: Uuid,
|
||
claim_id: Uuid,
|
||
) -> Result<bool> {
|
||
push::complete_wake(&self.pool, community, id, claim_id).await
|
||
}
|
||
|
||
/// Release a fenced wake claim for retry at the supplied time.
|
||
pub async fn retry_push_wake(
|
||
&self,
|
||
community: CommunityId,
|
||
id: Uuid,
|
||
claim_id: Uuid,
|
||
next: DateTime<Utc>,
|
||
) -> Result<bool> {
|
||
push::retry_wake(&self.pool, community, id, claim_id, next).await
|
||
}
|
||
|
||
/// Mark a fenced wake claim terminally failed.
|
||
pub async fn fail_push_wake(
|
||
&self,
|
||
community: CommunityId,
|
||
id: Uuid,
|
||
claim_id: Uuid,
|
||
) -> Result<bool> {
|
||
push::fail_wake(&self.pool, community, id, claim_id).await
|
||
}
|
||
|
||
/// Disable an endpoint only if the specified lease generation is current.
|
||
pub async fn disable_push_endpoint(
|
||
&self,
|
||
community: CommunityId,
|
||
author: &[u8],
|
||
installation_id: &str,
|
||
generation: i64,
|
||
) -> Result<bool> {
|
||
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)]
|
||
pub async fn accept_push_lease_event(
|
||
&self,
|
||
community: CommunityId,
|
||
event: &nostr::Event,
|
||
installation_id: &str,
|
||
version: push::LeaseVersion<'_>,
|
||
active: Option<push::ActiveLease<'_>>,
|
||
max_active_leases: i64,
|
||
) -> Result<push::AcceptLeaseOutcome> {
|
||
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.
|
||
pub async fn insert_event_with_thread_metadata(
|
||
&self,
|
||
community_id: CommunityId,
|
||
event: &nostr::Event,
|
||
channel_id: Option<Uuid>,
|
||
thread_meta: Option<event::ThreadMetadataParams<'_>>,
|
||
) -> 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)]
|
||
pub async fn insert_reaction_event_with_thread_metadata(
|
||
&self,
|
||
community_id: CommunityId,
|
||
event: &nostr::Event,
|
||
channel_id: Option<Uuid>,
|
||
thread_meta: Option<event::ThreadMetadataParams<'_>>,
|
||
target_event_id: &[u8],
|
||
actor_pubkey: &[u8],
|
||
emoji: &str,
|
||
) -> Result<event::ReactionEventInsertOutcome> {
|
||
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)
|
||
}
|
||
|
||
/// Creates a new channel, bootstraps the creator as owner, and returns the record.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub async fn create_channel(
|
||
&self,
|
||
community_id: CommunityId,
|
||
name: &str,
|
||
channel_type: channel::ChannelType,
|
||
visibility: channel::ChannelVisibility,
|
||
description: Option<&str>,
|
||
created_by: &[u8],
|
||
ttl_seconds: Option<i32>,
|
||
) -> Result<channel::ChannelRecord> {
|
||
channel::create_channel(
|
||
&self.pool,
|
||
community_id,
|
||
name,
|
||
channel_type,
|
||
visibility,
|
||
description,
|
||
created_by,
|
||
ttl_seconds,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Creates a channel with a client-supplied UUID.
|
||
///
|
||
/// Returns `(record, true)` if newly created, `(record, false)` if already exists.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub async fn create_channel_with_id(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_id: Uuid,
|
||
name: &str,
|
||
channel_type: channel::ChannelType,
|
||
visibility: channel::ChannelVisibility,
|
||
description: Option<&str>,
|
||
created_by: &[u8],
|
||
ttl_seconds: Option<i32>,
|
||
) -> Result<(channel::ChannelRecord, bool)> {
|
||
channel::create_channel_with_id(
|
||
&self.pool,
|
||
community_id,
|
||
channel_id,
|
||
name,
|
||
channel_type,
|
||
visibility,
|
||
description,
|
||
created_by,
|
||
ttl_seconds,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Fetches a channel record by ID.
|
||
pub async fn get_channel(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_id: Uuid,
|
||
) -> Result<channel::ChannelRecord> {
|
||
channel::get_channel(&self.pool, community_id, channel_id).await
|
||
}
|
||
|
||
/// Returns the canvas content for a channel, if any.
|
||
pub async fn get_canvas(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_id: Uuid,
|
||
) -> Result<Option<String>> {
|
||
channel::get_canvas(&self.pool, community_id, channel_id).await
|
||
}
|
||
|
||
/// Sets or clears the canvas content for a channel.
|
||
pub async fn set_canvas(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_id: Uuid,
|
||
canvas: Option<&str>,
|
||
) -> Result<()> {
|
||
channel::set_canvas(&self.pool, community_id, channel_id, canvas).await
|
||
}
|
||
|
||
/// Adds a member to a channel.
|
||
pub async fn add_member(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_id: Uuid,
|
||
pubkey: &[u8],
|
||
role: channel::MemberRole,
|
||
invited_by: Option<&[u8]>,
|
||
) -> Result<channel::MemberRecord> {
|
||
channel::add_member(
|
||
&self.pool,
|
||
community_id,
|
||
channel_id,
|
||
pubkey,
|
||
role,
|
||
invited_by,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Removes a member from a channel.
|
||
pub async fn remove_member(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_id: Uuid,
|
||
pubkey: &[u8],
|
||
actor_pubkey: &[u8],
|
||
) -> Result<()> {
|
||
channel::remove_member(&self.pool, community_id, channel_id, pubkey, actor_pubkey).await
|
||
}
|
||
|
||
/// Returns `true` if the pubkey is an active member.
|
||
pub async fn is_member(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_id: Uuid,
|
||
pubkey: &[u8],
|
||
) -> Result<bool> {
|
||
channel::is_member(&self.pool, community_id, channel_id, pubkey).await
|
||
}
|
||
|
||
/// Return the active (channel, pubkey) membership pairs among the given
|
||
/// sets, in one statement.
|
||
pub async fn membership_pairs(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_ids: &[Uuid],
|
||
pubkeys: &[Vec<u8>],
|
||
) -> Result<Vec<(Uuid, Vec<u8>)>> {
|
||
channel::membership_pairs(&self.pool, community_id, channel_ids, pubkeys).await
|
||
}
|
||
|
||
/// Returns all active members of a channel.
|
||
pub async fn get_members(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_id: Uuid,
|
||
) -> Result<Vec<channel::MemberRecord>> {
|
||
channel::get_members(&self.pool, community_id, channel_id).await
|
||
}
|
||
|
||
/// Returns active members for multiple channels in a single query.
|
||
pub async fn get_members_bulk(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_ids: &[Uuid],
|
||
) -> Result<Vec<channel::MemberRecord>> {
|
||
channel::get_members_bulk(&self.pool, community_id, channel_ids).await
|
||
}
|
||
|
||
/// Get all channel IDs accessible to a pubkey.
|
||
pub async fn get_accessible_channel_ids(
|
||
&self,
|
||
community_id: CommunityId,
|
||
pubkey: &[u8],
|
||
) -> Result<Vec<Uuid>> {
|
||
channel::get_accessible_channel_ids(&self.pool, community_id, pubkey).await
|
||
}
|
||
|
||
/// Lists channels, optionally filtered by visibility.
|
||
pub async fn list_channels(
|
||
&self,
|
||
community_id: CommunityId,
|
||
visibility: Option<&str>,
|
||
) -> Result<Vec<channel::ChannelRecord>> {
|
||
channel::list_channels(&self.pool, community_id, visibility).await
|
||
}
|
||
|
||
/// Returns full channel records for all channels a user can access.
|
||
pub async fn get_accessible_channels(
|
||
&self,
|
||
community_id: CommunityId,
|
||
pubkey: &[u8],
|
||
visibility_filter: Option<&str>,
|
||
member_only: Option<bool>,
|
||
) -> Result<Vec<channel::AccessibleChannel>> {
|
||
channel::get_accessible_channels(
|
||
&self.pool,
|
||
community_id,
|
||
pubkey,
|
||
visibility_filter,
|
||
member_only,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Returns all bot-role members with their aggregated channel names in one community.
|
||
pub async fn get_bot_members(
|
||
&self,
|
||
community_id: CommunityId,
|
||
) -> Result<Vec<channel::BotMemberRecord>> {
|
||
channel::get_bot_members(&self.pool, community_id).await
|
||
}
|
||
|
||
/// Bulk-fetch user records by pubkey.
|
||
pub async fn get_users_bulk(
|
||
&self,
|
||
community_id: CommunityId,
|
||
pubkeys: &[Vec<u8>],
|
||
) -> Result<Vec<channel::UserRecord>> {
|
||
channel::get_users_bulk(&self.pool, community_id, pubkeys).await
|
||
}
|
||
|
||
/// Updates a channel's name and/or description.
|
||
pub async fn update_channel(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_id: Uuid,
|
||
updates: channel::ChannelUpdate,
|
||
) -> Result<channel::ChannelRecord> {
|
||
channel::update_channel(&self.pool, community_id, channel_id, updates).await
|
||
}
|
||
|
||
/// Sets the topic for a channel.
|
||
pub async fn set_topic(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_id: Uuid,
|
||
topic: &str,
|
||
set_by: &[u8],
|
||
) -> Result<()> {
|
||
channel::set_topic(&self.pool, community_id, channel_id, topic, set_by).await
|
||
}
|
||
|
||
/// Sets the purpose for a channel.
|
||
pub async fn set_purpose(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_id: Uuid,
|
||
purpose: &str,
|
||
set_by: &[u8],
|
||
) -> Result<()> {
|
||
channel::set_purpose(&self.pool, community_id, channel_id, purpose, set_by).await
|
||
}
|
||
|
||
/// Archives a channel.
|
||
pub async fn archive_channel(&self, community_id: CommunityId, channel_id: Uuid) -> Result<()> {
|
||
channel::archive_channel(&self.pool, community_id, channel_id).await
|
||
}
|
||
|
||
/// Unarchives a channel.
|
||
pub async fn unarchive_channel(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_id: Uuid,
|
||
) -> Result<()> {
|
||
channel::unarchive_channel(&self.pool, community_id, channel_id).await
|
||
}
|
||
|
||
/// Soft-delete a channel.
|
||
pub async fn soft_delete_channel(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_id: Uuid,
|
||
) -> Result<bool> {
|
||
channel::soft_delete_channel(&self.pool, community_id, channel_id).await
|
||
}
|
||
|
||
/// Returns the count of active members in a channel.
|
||
pub async fn get_member_count(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_id: Uuid,
|
||
) -> Result<i64> {
|
||
channel::get_member_count(&self.pool, community_id, channel_id).await
|
||
}
|
||
|
||
/// Bulk-fetch member counts for a set of channel IDs.
|
||
pub async fn get_member_counts_bulk(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_ids: &[Uuid],
|
||
) -> Result<std::collections::HashMap<Uuid, i64>> {
|
||
channel::get_member_counts_bulk(&self.pool, community_id, channel_ids).await
|
||
}
|
||
|
||
/// Get the active role of a pubkey in a channel.
|
||
pub async fn get_member_role(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_id: Uuid,
|
||
pubkey: &[u8],
|
||
) -> Result<Option<String>> {
|
||
channel::get_member_role(&self.pool, community_id, channel_id, pubkey).await
|
||
}
|
||
|
||
/// Archive ephemeral channels whose TTL deadline has passed.
|
||
pub async fn reap_expired_ephemeral_channels(
|
||
&self,
|
||
) -> Result<Vec<channel::ReapedEphemeralChannel>> {
|
||
channel::reap_expired_ephemeral_channels(&self.pool).await
|
||
}
|
||
|
||
/// Query due reminders ready for delivery.
|
||
pub async fn query_due_reminders(
|
||
&self,
|
||
now_secs: i64,
|
||
batch_limit: i64,
|
||
) -> Result<Vec<event::DueReminder>> {
|
||
event::query_due_reminders(&self.pool, now_secs, batch_limit).await
|
||
}
|
||
|
||
/// Atomically claim a due reminder for delivery (cross-pod dedup).
|
||
pub async fn claim_due_reminder(
|
||
&self,
|
||
community_id: CommunityId,
|
||
event_id: &[u8],
|
||
event_created_at: chrono::DateTime<chrono::Utc>,
|
||
) -> Result<bool> {
|
||
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.
|
||
pub async fn claim_due_reminder_with_stamp(
|
||
&self,
|
||
community_id: CommunityId,
|
||
event_id: &[u8],
|
||
event_created_at: chrono::DateTime<chrono::Utc>,
|
||
delivery_stamp: i64,
|
||
) -> Result<bool> {
|
||
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.
|
||
pub async fn release_due_reminder(
|
||
&self,
|
||
community_id: CommunityId,
|
||
event_id: &[u8],
|
||
event_created_at: chrono::DateTime<chrono::Utc>,
|
||
delivery_stamp: i64,
|
||
) -> Result<bool> {
|
||
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`.
|
||
pub async fn ensure_user(&self, community_id: CommunityId, pubkey: &[u8]) -> Result<bool> {
|
||
user::ensure_user(&self.pool, community_id, pubkey).await
|
||
}
|
||
|
||
/// Get a single user record by pubkey.
|
||
pub async fn get_user(
|
||
&self,
|
||
community_id: CommunityId,
|
||
pubkey: &[u8],
|
||
) -> Result<Option<user::UserProfile>> {
|
||
user::get_user(&self.pool, community_id, pubkey).await
|
||
}
|
||
|
||
/// Update a user's profile fields.
|
||
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.
|
||
pub async fn get_user_by_nip05(
|
||
&self,
|
||
community_id: CommunityId,
|
||
local_part: &str,
|
||
domain: &str,
|
||
) -> Result<Option<user::UserProfile>> {
|
||
user::get_user_by_nip05(&self.pool, community_id, local_part, domain).await
|
||
}
|
||
|
||
/// Search users by display name, NIP-05 handle, or pubkey prefix.
|
||
pub async fn search_users(
|
||
&self,
|
||
community_id: CommunityId,
|
||
query: &str,
|
||
limit: u32,
|
||
) -> Result<Vec<user::UserSearchProfile>> {
|
||
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.
|
||
pub async fn set_agent_owner(
|
||
&self,
|
||
community_id: CommunityId,
|
||
agent_pubkey: &[u8],
|
||
owner_pubkey: &[u8],
|
||
) -> Result<bool> {
|
||
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.
|
||
pub async fn get_agent_channel_policy(
|
||
&self,
|
||
community_id: CommunityId,
|
||
pubkey: &[u8],
|
||
) -> Result<Option<(String, Option<Vec<u8>>)>> {
|
||
user::get_agent_channel_policy(&self.pool, community_id, pubkey).await
|
||
}
|
||
|
||
/// Check whether `actor_pubkey` is the agent owner of `target_pubkey`.
|
||
pub async fn is_agent_owner(
|
||
&self,
|
||
community_id: CommunityId,
|
||
target_pubkey: &[u8],
|
||
actor_pubkey: &[u8],
|
||
) -> Result<bool> {
|
||
user::is_agent_owner(&self.pool, community_id, target_pubkey, actor_pubkey).await
|
||
}
|
||
|
||
/// Set the channel_add_policy for a user.
|
||
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.
|
||
pub async fn find_dm_by_participants(
|
||
&self,
|
||
community_id: CommunityId,
|
||
participant_hash: &[u8],
|
||
) -> Result<Option<channel::ChannelRecord>> {
|
||
dm::find_dm_by_participants(&self.pool, community_id, participant_hash).await
|
||
}
|
||
|
||
/// Create or return an existing DM channel.
|
||
pub async fn create_dm(
|
||
&self,
|
||
community_id: CommunityId,
|
||
participants: &[&[u8]],
|
||
created_by: &[u8],
|
||
) -> Result<channel::ChannelRecord> {
|
||
dm::create_dm(&self.pool, community_id, participants, created_by).await
|
||
}
|
||
|
||
/// List all DMs for a user.
|
||
pub async fn list_dms_for_user(
|
||
&self,
|
||
community_id: CommunityId,
|
||
pubkey: &[u8],
|
||
limit: u32,
|
||
cursor: Option<Uuid>,
|
||
) -> Result<Vec<dm::DmRecord>> {
|
||
dm::list_dms_for_user(&self.pool, community_id, pubkey, limit, cursor).await
|
||
}
|
||
|
||
/// Open or retrieve a DM for the given participants.
|
||
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.
|
||
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.
|
||
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.
|
||
pub async fn list_hidden_dms(
|
||
&self,
|
||
community_id: CommunityId,
|
||
pubkey: &[u8],
|
||
) -> Result<Vec<Uuid>> {
|
||
dm::list_hidden_dms(&self.pool, community_id, pubkey).await
|
||
}
|
||
|
||
/// Insert thread metadata.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub async fn insert_thread_metadata(
|
||
&self,
|
||
community_id: CommunityId,
|
||
event_id: &[u8],
|
||
event_created_at: DateTime<Utc>,
|
||
channel_id: Uuid,
|
||
parent_event_id: Option<&[u8]>,
|
||
parent_event_created_at: Option<DateTime<Utc>>,
|
||
root_event_id: Option<&[u8]>,
|
||
root_event_created_at: Option<DateTime<Utc>>,
|
||
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.
|
||
pub async fn get_thread_replies(
|
||
&self,
|
||
community_id: CommunityId,
|
||
root_event_id: &[u8],
|
||
depth_limit: Option<u32>,
|
||
limit: u32,
|
||
cursor: Option<&[u8]>,
|
||
) -> Result<Vec<thread::ThreadReply>> {
|
||
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.
|
||
pub async fn get_thread_summary(
|
||
&self,
|
||
community_id: CommunityId,
|
||
event_id: &[u8],
|
||
) -> Result<Option<thread::ThreadSummary>> {
|
||
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<Utc>, Vec<u8>)>,
|
||
kind_filter: Option<&[u32]>,
|
||
) -> Result<thread::ChannelWindow> {
|
||
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`.
|
||
pub async fn get_channel_window_with_session(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_id: Uuid,
|
||
limit: u32,
|
||
cursor: Option<(DateTime<Utc>, Vec<u8>)>,
|
||
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<Duration>| -> 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<Utc>| -> 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.
|
||
pub async fn get_thread_metadata_by_event(
|
||
&self,
|
||
community_id: CommunityId,
|
||
event_id: &[u8],
|
||
) -> Result<Option<thread::ThreadMetadataRecord>> {
|
||
thread::get_thread_metadata_by_event(&self.pool, community_id, event_id).await
|
||
}
|
||
|
||
/// Decrement reply counts.
|
||
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.
|
||
pub async fn add_reaction(
|
||
&self,
|
||
community: CommunityId,
|
||
event_id: &[u8],
|
||
event_created_at: DateTime<Utc>,
|
||
pubkey: &[u8],
|
||
emoji: &str,
|
||
reaction_event_id: Option<&[u8]>,
|
||
) -> Result<bool> {
|
||
reaction::add_reaction(
|
||
&self.pool,
|
||
community,
|
||
event_id,
|
||
event_created_at,
|
||
pubkey,
|
||
emoji,
|
||
reaction_event_id,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Soft-delete a reaction.
|
||
pub async fn remove_reaction(
|
||
&self,
|
||
community: CommunityId,
|
||
event_id: &[u8],
|
||
event_created_at: DateTime<Utc>,
|
||
pubkey: &[u8],
|
||
emoji: &str,
|
||
) -> Result<bool> {
|
||
reaction::remove_reaction(
|
||
&self.pool,
|
||
community,
|
||
event_id,
|
||
event_created_at,
|
||
pubkey,
|
||
emoji,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Soft-delete a reaction by its source event ID.
|
||
pub async fn remove_reaction_by_source_event_id(
|
||
&self,
|
||
community: CommunityId,
|
||
reaction_event_id: &[u8],
|
||
) -> Result<bool> {
|
||
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.
|
||
pub async fn get_active_reaction_record(
|
||
&self,
|
||
community: CommunityId,
|
||
event_id: &[u8],
|
||
event_created_at: DateTime<Utc>,
|
||
pubkey: &[u8],
|
||
emoji: &str,
|
||
) -> Result<Option<reaction::ActiveReactionRecord>> {
|
||
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.
|
||
pub async fn set_reaction_event_id(
|
||
&self,
|
||
community: CommunityId,
|
||
event_id: &[u8],
|
||
event_created_at: DateTime<Utc>,
|
||
pubkey: &[u8],
|
||
emoji: &str,
|
||
reaction_event_id: &[u8],
|
||
) -> Result<bool> {
|
||
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.
|
||
pub async fn get_reactions(
|
||
&self,
|
||
community: CommunityId,
|
||
event_id: &[u8],
|
||
event_created_at: DateTime<Utc>,
|
||
limit: u32,
|
||
cursor: Option<&str>,
|
||
) -> Result<Vec<reaction::ReactionGroup>> {
|
||
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.
|
||
pub async fn get_reactions_bulk(
|
||
&self,
|
||
community: CommunityId,
|
||
event_ids: &[(&[u8], DateTime<Utc>)],
|
||
) -> Result<Vec<reaction::BulkReactionEntry>> {
|
||
reaction::get_reactions_bulk(&self.pool, community, event_ids).await
|
||
}
|
||
|
||
/// Find events that @mention the given pubkey.
|
||
pub async fn query_feed_mentions(
|
||
&self,
|
||
community: CommunityId,
|
||
pubkey_bytes: &[u8],
|
||
accessible_channel_ids: &[Uuid],
|
||
since: Option<DateTime<Utc>>,
|
||
limit: i64,
|
||
) -> Result<Vec<StoredEvent>> {
|
||
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.
|
||
pub async fn query_feed_mentions_routed(
|
||
&self,
|
||
path: &'static str,
|
||
community: CommunityId,
|
||
pubkey_bytes: &[u8],
|
||
accessible_channel_ids: &[Uuid],
|
||
since: Option<DateTime<Utc>>,
|
||
limit: i64,
|
||
) -> Result<Vec<StoredEvent>> {
|
||
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.
|
||
pub async fn query_feed_needs_action(
|
||
&self,
|
||
community: CommunityId,
|
||
pubkey_bytes: &[u8],
|
||
accessible_channel_ids: &[Uuid],
|
||
since: Option<DateTime<Utc>>,
|
||
limit: i64,
|
||
) -> Result<Vec<StoredEvent>> {
|
||
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.
|
||
pub async fn query_feed_needs_action_routed(
|
||
&self,
|
||
path: &'static str,
|
||
community: CommunityId,
|
||
pubkey_bytes: &[u8],
|
||
accessible_channel_ids: &[Uuid],
|
||
since: Option<DateTime<Utc>>,
|
||
limit: i64,
|
||
) -> Result<Vec<StoredEvent>> {
|
||
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.
|
||
pub async fn query_feed_activity(
|
||
&self,
|
||
community: CommunityId,
|
||
accessible_channel_ids: &[Uuid],
|
||
since: Option<DateTime<Utc>>,
|
||
limit: i64,
|
||
) -> Result<Vec<StoredEvent>> {
|
||
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.
|
||
pub async fn query_feed_activity_routed(
|
||
&self,
|
||
path: &'static str,
|
||
community: CommunityId,
|
||
accessible_channel_ids: &[Uuid],
|
||
since: Option<DateTime<Utc>>,
|
||
limit: i64,
|
||
) -> Result<Vec<StoredEvent>> {
|
||
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)]
|
||
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<DateTime<Utc>>,
|
||
) -> Result<Uuid> {
|
||
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)]
|
||
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<DateTime<Utc>>,
|
||
) -> Result<Option<Uuid>> {
|
||
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.
|
||
pub async fn get_api_token_by_hash(
|
||
&self,
|
||
community_id: CommunityId,
|
||
hash: &[u8],
|
||
) -> Result<Option<ApiTokenRecord>> {
|
||
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.
|
||
pub async fn get_api_token_by_hash_including_revoked(
|
||
&self,
|
||
community_id: CommunityId,
|
||
hash: &[u8],
|
||
) -> Result<Option<ApiTokenRecord>> {
|
||
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.
|
||
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.
|
||
pub async fn list_active_tokens(&self, community_id: CommunityId) -> Result<Vec<TokenSummary>> {
|
||
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<String> = 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).
|
||
pub async fn list_tokens_by_owner(
|
||
&self,
|
||
community_id: CommunityId,
|
||
pubkey: &[u8],
|
||
) -> Result<Vec<ApiTokenRecord>> {
|
||
api_token::list_tokens_by_owner(&self.pool, *community_id.as_uuid(), pubkey).await
|
||
}
|
||
|
||
/// Revoke a single token by ID, scoped to (community, owner).
|
||
pub async fn revoke_token(
|
||
&self,
|
||
community_id: CommunityId,
|
||
id: Uuid,
|
||
owner_pubkey: &[u8],
|
||
revoked_by: &[u8],
|
||
) -> Result<bool> {
|
||
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.
|
||
pub async fn revoke_all_tokens(
|
||
&self,
|
||
community_id: CommunityId,
|
||
owner_pubkey: &[u8],
|
||
revoked_by: &[u8],
|
||
) -> Result<u64> {
|
||
api_token::revoke_all_tokens(
|
||
&self.pool,
|
||
*community_id.as_uuid(),
|
||
owner_pubkey,
|
||
revoked_by,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Create a new workflow.
|
||
pub async fn create_workflow(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_id: Option<Uuid>,
|
||
owner_pubkey: &[u8],
|
||
name: &str,
|
||
definition_json: &str,
|
||
definition_hash: &[u8],
|
||
) -> Result<Uuid> {
|
||
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)]
|
||
pub async fn upsert_workflow(
|
||
&self,
|
||
community_id: CommunityId,
|
||
id: Uuid,
|
||
channel_id: Option<Uuid>,
|
||
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.
|
||
pub async fn get_workflow(
|
||
&self,
|
||
community_id: CommunityId,
|
||
id: Uuid,
|
||
) -> Result<workflow::WorkflowRecord> {
|
||
workflow::get_workflow(&self.pool, community_id, id).await
|
||
}
|
||
|
||
/// List workflows for a channel.
|
||
pub async fn list_channel_workflows(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_id: Uuid,
|
||
limit: Option<i64>,
|
||
offset: Option<i64>,
|
||
) -> Result<Vec<workflow::WorkflowRecord>> {
|
||
workflow::list_channel_workflows(&self.pool, community_id, channel_id, limit, offset).await
|
||
}
|
||
|
||
/// List active, enabled workflows for a channel.
|
||
pub async fn list_enabled_channel_workflows(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_id: Uuid,
|
||
) -> Result<Vec<workflow::WorkflowRecord>> {
|
||
workflow::list_enabled_channel_workflows(&self.pool, community_id, channel_id).await
|
||
}
|
||
|
||
/// List all active, enabled schedule-triggered workflows.
|
||
pub async fn list_all_enabled_workflows(&self) -> Result<Vec<workflow::WorkflowRecord>> {
|
||
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.
|
||
pub async fn claim_scheduled_workflow_fire(
|
||
&self,
|
||
community_id: CommunityId,
|
||
workflow_id: Uuid,
|
||
scheduled_for: chrono::DateTime<chrono::Utc>,
|
||
) -> Result<Option<workflow::ScheduledWorkflowFireClaim>> {
|
||
workflow::claim_scheduled_workflow_fire(
|
||
&self.pool,
|
||
community_id,
|
||
workflow_id,
|
||
scheduled_for,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Fetch the latest claimed schedule instant for interval trigger anchoring.
|
||
pub async fn latest_scheduled_workflow_fire(
|
||
&self,
|
||
community_id: CommunityId,
|
||
workflow_id: Uuid,
|
||
) -> Result<Option<chrono::DateTime<chrono::Utc>>> {
|
||
workflow::latest_scheduled_workflow_fire(&self.pool, community_id, workflow_id).await
|
||
}
|
||
|
||
/// Attach the workflow run id created from a won scheduled-fire claim.
|
||
pub async fn attach_scheduled_workflow_run(
|
||
&self,
|
||
community_id: CommunityId,
|
||
workflow_id: Uuid,
|
||
scheduled_for: chrono::DateTime<chrono::Utc>,
|
||
workflow_run_id: Uuid,
|
||
) -> Result<bool> {
|
||
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.
|
||
pub async fn prune_scheduled_workflow_fires_before(
|
||
&self,
|
||
older_than: chrono::DateTime<chrono::Utc>,
|
||
) -> Result<u64> {
|
||
workflow::prune_scheduled_workflow_fires_before(&self.pool, older_than).await
|
||
}
|
||
|
||
/// Update a workflow's name, definition, and hash.
|
||
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.
|
||
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.
|
||
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.
|
||
pub async fn disable_workflows_for_owner_in_channel(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_id: Uuid,
|
||
owner_pubkey: &[u8],
|
||
) -> Result<u64> {
|
||
workflow::disable_workflows_for_owner_in_channel(
|
||
&self.pool,
|
||
community_id,
|
||
channel_id,
|
||
owner_pubkey,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Delete a workflow and all its runs/approvals.
|
||
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`.
|
||
pub async fn delete_workflow_for_owner(
|
||
&self,
|
||
community_id: CommunityId,
|
||
id: Uuid,
|
||
owner_pubkey: &[u8],
|
||
) -> Result<Option<Uuid>> {
|
||
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).
|
||
pub async fn find_workflow_by_owner_and_name(
|
||
&self,
|
||
community_id: CommunityId,
|
||
owner_pubkey: &[u8],
|
||
name: &str,
|
||
) -> Result<Option<workflow::WorkflowRecord>> {
|
||
workflow::find_by_owner_and_name(&self.pool, community_id, owner_pubkey, name).await
|
||
}
|
||
|
||
/// Create a new workflow run.
|
||
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<Uuid> {
|
||
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.
|
||
pub async fn get_workflow_run(
|
||
&self,
|
||
community_id: CommunityId,
|
||
id: Uuid,
|
||
) -> Result<workflow::WorkflowRunRecord> {
|
||
workflow::get_workflow_run(&self.pool, community_id, id).await
|
||
}
|
||
|
||
/// List runs for a workflow.
|
||
pub async fn list_workflow_runs(
|
||
&self,
|
||
community_id: CommunityId,
|
||
workflow_id: Uuid,
|
||
limit: i64,
|
||
) -> Result<Vec<workflow::WorkflowRunRecord>> {
|
||
workflow::list_workflow_runs(&self.pool, community_id, workflow_id, limit).await
|
||
}
|
||
|
||
/// Update a workflow run's status.
|
||
pub async fn update_workflow_run(
|
||
&self,
|
||
community_id: CommunityId,
|
||
id: Uuid,
|
||
status: workflow::RunStatus,
|
||
current_step: i32,
|
||
trace: &serde_json::Value,
|
||
error: Option<&str>,
|
||
) -> Result<()> {
|
||
workflow::update_workflow_run(
|
||
&self.pool,
|
||
community_id,
|
||
id,
|
||
status,
|
||
current_step,
|
||
trace,
|
||
error,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Create an approval request.
|
||
pub async fn create_approval(&self, params: workflow::CreateApprovalParams<'_>) -> Result<()> {
|
||
workflow::create_approval(&self.pool, params).await
|
||
}
|
||
|
||
/// Fetch an approval by raw token.
|
||
pub async fn get_approval(
|
||
&self,
|
||
community_id: CommunityId,
|
||
token: &str,
|
||
) -> Result<workflow::ApprovalRecord> {
|
||
workflow::get_approval(&self.pool, community_id, token).await
|
||
}
|
||
|
||
/// Fetch an approval by its already-hashed token (no re-hashing).
|
||
pub async fn get_approval_by_stored_hash(
|
||
&self,
|
||
community_id: CommunityId,
|
||
token_hash: &[u8],
|
||
) -> Result<workflow::ApprovalRecord> {
|
||
workflow::get_approval_by_stored_hash(&self.pool, community_id, token_hash).await
|
||
}
|
||
|
||
/// Fetch all approvals for a workflow run.
|
||
pub async fn get_run_approvals(
|
||
&self,
|
||
community_id: CommunityId,
|
||
workflow_id: uuid::Uuid,
|
||
run_id: uuid::Uuid,
|
||
) -> Result<Vec<workflow::ApprovalRecord>> {
|
||
workflow::get_run_approvals(&self.pool, community_id, workflow_id, run_id).await
|
||
}
|
||
|
||
/// Update an approval's status.
|
||
pub async fn update_approval(
|
||
&self,
|
||
community_id: CommunityId,
|
||
token: &str,
|
||
status: workflow::ApprovalStatus,
|
||
approver_pubkey: Option<&[u8]>,
|
||
note: Option<&str>,
|
||
) -> Result<bool> {
|
||
workflow::update_approval(
|
||
&self.pool,
|
||
community_id,
|
||
token,
|
||
status,
|
||
approver_pubkey,
|
||
note,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Update an approval by its already-hashed token (no re-hashing).
|
||
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<bool> {
|
||
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.
|
||
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.
|
||
pub async fn backfill_d_tags(&self) -> Result<u64> {
|
||
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",
|
||
)
|
||
.execute(&self.pool)
|
||
.await?;
|
||
Ok(result.rows_affected())
|
||
}
|
||
|
||
/// Check if a pubkey is in the allowlist for `community`.
|
||
pub async fn is_pubkey_allowed(&self, community: CommunityId, pubkey: &[u8]) -> Result<bool> {
|
||
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).
|
||
pub async fn has_allowlist_entries(&self, community: CommunityId) -> Result<bool> {
|
||
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.
|
||
pub async fn add_to_allowlist(
|
||
&self,
|
||
community: CommunityId,
|
||
pubkey: &[u8],
|
||
added_by: &[u8],
|
||
note: Option<&str>,
|
||
) -> Result<bool> {
|
||
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.
|
||
pub async fn remove_from_allowlist(
|
||
&self,
|
||
community: CommunityId,
|
||
pubkey: &[u8],
|
||
) -> Result<bool> {
|
||
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.
|
||
pub async fn list_allowlist(&self, community: CommunityId) -> Result<Vec<AllowlistEntry>> {
|
||
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.
|
||
pub async fn is_relay_member(&self, community: CommunityId, pubkey: &str) -> Result<bool> {
|
||
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.
|
||
pub async fn get_relay_member(
|
||
&self,
|
||
community: CommunityId,
|
||
pubkey: &str,
|
||
) -> Result<Option<relay_members::RelayMember>> {
|
||
relay_members::get_relay_member(&self.pool, community, pubkey).await
|
||
}
|
||
|
||
/// Returns all relay members of `community` ordered by `created_at` ascending.
|
||
pub async fn list_relay_members(
|
||
&self,
|
||
community: CommunityId,
|
||
) -> Result<Vec<relay_members::RelayMember>> {
|
||
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`).
|
||
pub async fn add_relay_member(
|
||
&self,
|
||
community: CommunityId,
|
||
pubkey: &str,
|
||
role: &str,
|
||
added_by: Option<&str>,
|
||
) -> Result<bool> {
|
||
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.
|
||
pub async fn claim_relay_membership(
|
||
&self,
|
||
community: CommunityId,
|
||
pubkey: &str,
|
||
role: &str,
|
||
policy_version: Option<&str>,
|
||
) -> Result<bool> {
|
||
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.
|
||
pub async fn has_join_policy_acceptance(
|
||
&self,
|
||
community: CommunityId,
|
||
pubkey: &str,
|
||
policy_version: &str,
|
||
) -> Result<bool> {
|
||
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.
|
||
pub async fn remove_relay_member(
|
||
&self,
|
||
community: CommunityId,
|
||
pubkey: &str,
|
||
) -> Result<relay_members::RemoveResult> {
|
||
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`].
|
||
pub async fn remove_relay_member_if_role(
|
||
&self,
|
||
community: CommunityId,
|
||
pubkey: &str,
|
||
expected_role: &str,
|
||
) -> Result<relay_members::RemoveResult> {
|
||
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.
|
||
pub async fn update_relay_member_role(
|
||
&self,
|
||
community: CommunityId,
|
||
pubkey: &str,
|
||
new_role: &str,
|
||
) -> Result<bool> {
|
||
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.
|
||
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<bool> {
|
||
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.
|
||
pub async fn transfer_ownership(
|
||
&self,
|
||
community: CommunityId,
|
||
new_owner_pubkey: &str,
|
||
expected_owner_pubkey: &str,
|
||
) -> Result<relay_members::TransferResult> {
|
||
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.
|
||
pub async fn backfill_from_allowlist(&self, community: CommunityId) -> Result<u64> {
|
||
relay_members::backfill_from_allowlist(&self.pool, community).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.
|
||
pub async fn mint_relay_invite(
|
||
&self,
|
||
community: CommunityId,
|
||
created_by: &str,
|
||
ttl_secs: u64,
|
||
max_uses: Option<i32>,
|
||
) -> Result<relay_invite::MintedInvite> {
|
||
relay_invite::mint_relay_invite(&self.pool, community, created_by, ttl_secs, max_uses).await
|
||
}
|
||
|
||
/// Delete one bounded batch of invites expired before `cutoff`.
|
||
pub async fn reap_expired_relay_invites(
|
||
&self,
|
||
cutoff: chrono::DateTime<chrono::Utc>,
|
||
) -> Result<u64> {
|
||
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).
|
||
pub async fn claim_relay_invite(
|
||
&self,
|
||
community: CommunityId,
|
||
token_hash: &[u8; 32],
|
||
claimer_pubkey: &str,
|
||
policy_version: Option<&str>,
|
||
) -> Result<relay_invite::ClaimOutcome> {
|
||
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.
|
||
pub async fn insert_product_feedback(
|
||
&self,
|
||
community: CommunityId,
|
||
feedback: product_feedback::NewProductFeedback<'_>,
|
||
) -> Result<Uuid> {
|
||
product_feedback::insert(&self.pool, community, feedback).await
|
||
}
|
||
|
||
/// List product feedback across the deployment, newest first.
|
||
pub async fn list_product_feedback(
|
||
&self,
|
||
limit: i64,
|
||
) -> Result<Vec<product_feedback::ProductFeedbackRecord>> {
|
||
product_feedback::list(&self.pool, limit).await
|
||
}
|
||
|
||
/// Insert a tenant-scoped NIP-56 report row, idempotent by report event id.
|
||
pub async fn insert_moderation_report(
|
||
&self,
|
||
community: CommunityId,
|
||
report: moderation::NewReport<'_>,
|
||
) -> Result<Uuid> {
|
||
moderation::insert_report(&self.pool, community, report).await
|
||
}
|
||
|
||
/// List moderation reports for a community, newest first.
|
||
pub async fn list_moderation_reports(
|
||
&self,
|
||
community: CommunityId,
|
||
status: Option<&str>,
|
||
limit: i64,
|
||
) -> Result<Vec<moderation::ReportRecord>> {
|
||
moderation::list_reports(&self.pool, community, status, limit).await
|
||
}
|
||
|
||
/// Fetch one moderation report by row id.
|
||
pub async fn get_moderation_report(
|
||
&self,
|
||
community: CommunityId,
|
||
report_id: Uuid,
|
||
) -> Result<Option<moderation::ReportRecord>> {
|
||
moderation::get_report(&self.pool, community, report_id).await
|
||
}
|
||
|
||
/// Fetch one moderation report by signed NIP-56 report event id.
|
||
pub async fn get_moderation_report_by_event(
|
||
&self,
|
||
community: CommunityId,
|
||
report_event_id: &[u8],
|
||
) -> Result<Option<moderation::ReportRecord>> {
|
||
moderation::get_report_by_event(&self.pool, community, report_event_id).await
|
||
}
|
||
|
||
/// Resolve, dismiss, or escalate an open moderation report.
|
||
pub async fn resolve_moderation_report(
|
||
&self,
|
||
community: CommunityId,
|
||
report_id: Uuid,
|
||
status: &str,
|
||
resolved_by: &[u8],
|
||
action_id: Option<Uuid>,
|
||
) -> Result<bool> {
|
||
moderation::resolve_report(
|
||
&self.pool,
|
||
community,
|
||
report_id,
|
||
status,
|
||
resolved_by,
|
||
action_id,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Upsert a community ban for a member pubkey.
|
||
pub async fn ban_community_member(
|
||
&self,
|
||
community: CommunityId,
|
||
pubkey: &[u8],
|
||
actor: &[u8],
|
||
reason: Option<&str>,
|
||
expires_at: Option<DateTime<Utc>>,
|
||
) -> Result<()> {
|
||
moderation::ban_member(&self.pool, community, pubkey, actor, reason, expires_at).await
|
||
}
|
||
|
||
/// Lift a community ban for a member pubkey.
|
||
pub async fn unban_community_member(
|
||
&self,
|
||
community: CommunityId,
|
||
pubkey: &[u8],
|
||
actor: &[u8],
|
||
) -> Result<bool> {
|
||
moderation::unban_member(&self.pool, community, pubkey, actor).await
|
||
}
|
||
|
||
/// Upsert a community timeout/write-block for a member pubkey.
|
||
pub async fn timeout_community_member(
|
||
&self,
|
||
community: CommunityId,
|
||
pubkey: &[u8],
|
||
actor: &[u8],
|
||
muted_until: DateTime<Utc>,
|
||
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.
|
||
pub async fn untimeout_community_member(
|
||
&self,
|
||
community: CommunityId,
|
||
pubkey: &[u8],
|
||
actor: &[u8],
|
||
) -> Result<bool> {
|
||
moderation::untimeout_member(&self.pool, community, pubkey, actor).await
|
||
}
|
||
|
||
/// Fetch the active ban/timeout restriction state for enforcement hot paths.
|
||
pub async fn moderation_restriction_state(
|
||
&self,
|
||
community: CommunityId,
|
||
pubkey: &[u8],
|
||
) -> Result<moderation::RestrictionState> {
|
||
moderation::restriction_state(&self.pool, community, pubkey).await
|
||
}
|
||
|
||
/// Fetch the full ban/timeout row for a member pubkey.
|
||
pub async fn get_community_ban(
|
||
&self,
|
||
community: CommunityId,
|
||
pubkey: &[u8],
|
||
) -> Result<Option<moderation::BanRecord>> {
|
||
moderation::get_ban(&self.pool, community, pubkey).await
|
||
}
|
||
|
||
/// List currently restricted members in a community.
|
||
pub async fn list_community_restrictions(
|
||
&self,
|
||
community: CommunityId,
|
||
) -> Result<Vec<moderation::BanRecord>> {
|
||
moderation::list_restricted(&self.pool, community).await
|
||
}
|
||
|
||
/// Insert a moderation audit action row.
|
||
pub async fn insert_moderation_action(
|
||
&self,
|
||
community: CommunityId,
|
||
action: moderation::NewAction<'_>,
|
||
) -> Result<Uuid> {
|
||
moderation::insert_action(&self.pool, community, action).await
|
||
}
|
||
|
||
/// List moderation audit action rows, newest first.
|
||
pub async fn list_moderation_actions(
|
||
&self,
|
||
community: CommunityId,
|
||
limit: i64,
|
||
) -> Result<Vec<moderation::ActionRecord>> {
|
||
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`].
|
||
pub async fn repo_name_owner(
|
||
&self,
|
||
community: CommunityId,
|
||
repo_id: &str,
|
||
) -> Result<Option<String>> {
|
||
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`.
|
||
pub async fn reserve_repo_name(
|
||
&self,
|
||
community: CommunityId,
|
||
repo_id: &str,
|
||
owner_pubkey: &str,
|
||
) -> Result<git_repo::ReserveOutcome> {
|
||
git_repo::reserve_repo_name(&self.pool, community, repo_id, owner_pubkey).await
|
||
}
|
||
|
||
/// Count git repos reserved by `owner_pubkey` in `community` (quota check).
|
||
pub async fn count_repos_for_owner(
|
||
&self,
|
||
community: CommunityId,
|
||
owner_pubkey: &str,
|
||
) -> Result<i64> {
|
||
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`].
|
||
pub async fn release_repo_name(
|
||
&self,
|
||
community: CommunityId,
|
||
repo_id: &str,
|
||
owner_pubkey: &str,
|
||
) -> Result<u64> {
|
||
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`.
|
||
pub async fn is_archived(&self, community_id: CommunityId, pubkey: &str) -> Result<bool> {
|
||
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)]
|
||
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<bool> {
|
||
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.
|
||
pub async fn unarchive(&self, community_id: CommunityId, pubkey: &str) -> Result<bool> {
|
||
archived_identities::unarchive(&self.pool, community_id, pubkey).await
|
||
}
|
||
|
||
/// Returns all identities archived in `community_id`, ordered by archive time ascending.
|
||
pub async fn list_archived(
|
||
&self,
|
||
community_id: CommunityId,
|
||
) -> Result<Vec<archived_identities::ArchivedIdentity>> {
|
||
archived_identities::list_archived(&self.pool, community_id).await
|
||
}
|
||
|
||
/// Soft-delete NIP-29 discovery events for a channel created by a specific relay pubkey.
|
||
pub async fn soft_delete_discovery_events(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_id: Uuid,
|
||
relay_pubkey: &[u8],
|
||
) -> Result<u64> {
|
||
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())
|
||
}
|
||
|
||
/// Atomically replace a replaceable event: NIP-16 kinds (0, 3, 41, 10000–19999)
|
||
/// and NIP-29 discovery state (39000–39002, called from side_effects.rs).
|
||
///
|
||
/// Keeps only the event with the highest `created_at` per (kind, pubkey, channel_id).
|
||
/// Same-second ties are broken by lowest event `id` (NIP-16 deterministic ordering).
|
||
/// Returns `(event, false)` for stale writes and duplicate IDs — callers should
|
||
/// skip fan-out/dispatch when `was_inserted` is false.
|
||
pub async fn replace_addressable_event(
|
||
&self,
|
||
community_id: CommunityId,
|
||
event: &nostr::Event,
|
||
channel_id: Option<Uuid>,
|
||
) -> Result<(StoredEvent, bool)> {
|
||
let kind_i32 = buzz_core::kind::event_kind_i32(event);
|
||
let pubkey_bytes = event.pubkey.to_bytes();
|
||
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))?;
|
||
|
||
// Collisions only cause extra serialization; they cannot change behavior.
|
||
let lock_key = event_replacement_lock_key(
|
||
community_id,
|
||
kind_i32,
|
||
pubkey_bytes.as_slice(),
|
||
channel_id.as_ref().map(|id| id.as_bytes().as_slice()),
|
||
);
|
||
|
||
let mut tx = self.pool.begin().await?;
|
||
|
||
// Serialize all writers for the same (kind, pubkey, channel_id) tuple.
|
||
// Advisory lock is transaction-scoped — released on commit/rollback.
|
||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||
.bind(lock_key)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
// Check for the newest existing event. ORDER BY + LIMIT 1 is defensive against
|
||
// historical data where prior bugs may have left multiple live rows.
|
||
let existing: Option<(chrono::DateTime<chrono::Utc>, Vec<u8>)> = sqlx::query_as(
|
||
"SELECT created_at, id FROM events \
|
||
WHERE community_id = $1 AND kind = $2 AND pubkey = $3 \
|
||
AND channel_id IS NOT DISTINCT FROM $4 \
|
||
AND deleted_at IS NULL \
|
||
ORDER BY created_at DESC, id ASC LIMIT 1",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(kind_i32)
|
||
.bind(pubkey_bytes.as_slice())
|
||
.bind(channel_id)
|
||
.fetch_optional(&mut *tx)
|
||
.await?;
|
||
|
||
// Stale-write protection: reject if incoming is not newer.
|
||
// NIP-16: created_at is second-resolution. On same-second tie, lowest
|
||
// event id (lexicographic) wins — deterministic across relays.
|
||
let incoming_id = event.id.as_bytes().as_slice();
|
||
if let Some((existing_ts, existing_id)) = existing {
|
||
let dominated = created_at < existing_ts
|
||
|| (created_at == existing_ts && incoming_id >= existing_id.as_slice());
|
||
if dominated {
|
||
tx.rollback().await?;
|
||
let received_at = chrono::Utc::now();
|
||
return Ok((
|
||
StoredEvent::with_received_at(event.clone(), received_at, channel_id, false),
|
||
false,
|
||
));
|
||
}
|
||
}
|
||
|
||
// Soft-delete the old event (if any). IS NOT DISTINCT FROM for NULL safety.
|
||
sqlx::query(
|
||
"UPDATE events SET deleted_at = NOW() \
|
||
WHERE community_id = $1 AND kind = $2 AND pubkey = $3 \
|
||
AND channel_id IS NOT DISTINCT FROM $4 \
|
||
AND deleted_at IS NULL",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(kind_i32)
|
||
.bind(pubkey_bytes.as_slice())
|
||
.bind(channel_id)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
// Insert the new event inside the same transaction.
|
||
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);
|
||
|
||
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(channel_id)
|
||
.bind(d_tag.as_deref())
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
let was_inserted = insert_result.rows_affected() > 0;
|
||
if !was_inserted {
|
||
// ON CONFLICT fired — the event ID already exists. Rollback the
|
||
// soft-delete so we don't lose the previous replaceable event.
|
||
tx.rollback().await?;
|
||
return Ok((
|
||
StoredEvent::with_received_at(event.clone(), received_at, channel_id, false),
|
||
false,
|
||
));
|
||
}
|
||
|
||
tx.commit().await?;
|
||
|
||
// Mentions are a denormalized index — safe outside the transaction.
|
||
// insert_event() normally handles this, but we inlined the INSERT above.
|
||
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((
|
||
StoredEvent::with_received_at(event.clone(), received_at, channel_id, true),
|
||
true,
|
||
))
|
||
}
|
||
|
||
/// 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.
|
||
pub async fn nip43_membership_snapshot_needs_reconciliation(
|
||
&self,
|
||
community_id: CommunityId,
|
||
relay_pubkey: &nostr::PublicKey,
|
||
) -> Result<bool> {
|
||
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::<Vec<_>>();
|
||
let mut canonical_members = members
|
||
.into_iter()
|
||
.map(|member| (member.pubkey.to_ascii_lowercase(), member.role))
|
||
.collect::<Vec<_>>();
|
||
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.
|
||
///
|
||
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 =
|
||
event_replacement_lock_key(community_id, kind_i32, pubkey_bytes.as_slice(), None);
|
||
|
||
let mut tx = self.pool.begin().await?;
|
||
|
||
// 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.
|
||
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<Tag> = 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::<Option<Uuid>>(None)
|
||
.bind(d_tag.as_deref())
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
let was_inserted = insert_result.rows_affected() > 0;
|
||
if !was_inserted {
|
||
tx.rollback().await?;
|
||
return Ok((
|
||
StoredEvent::with_received_at(event, received_at, None, false),
|
||
false,
|
||
member_count,
|
||
));
|
||
}
|
||
|
||
tx.commit().await?;
|
||
|
||
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, true),
|
||
true,
|
||
member_count,
|
||
))
|
||
}
|
||
|
||
/// Atomically replace a NIP-33 parameterized replaceable event (kind 30000–39999).
|
||
///
|
||
/// Keeps only the event with the highest `created_at` per `(kind, pubkey, d_tag)`.
|
||
/// Same-second ties are broken by lowest event `id` (deterministic ordering).
|
||
/// The entire check → retire old payload → insert runs in a single transaction
|
||
/// with an advisory lock to prevent concurrent-insert races. NIP-RS read-state
|
||
/// coordinates hard-delete the superseded payload and preserve a compact
|
||
/// ordering watermark. Buzz mesh status coordinates also hard-delete their
|
||
/// superseded heartbeat payload because only the live head has product
|
||
/// value; other NIP-33 kinds retain soft-deleted history.
|
||
///
|
||
/// **Channel policy:** NIP-33 replacement keys on `(kind, pubkey, d_tag)` globally —
|
||
/// `channel_id` is NOT part of the replacement key. This matches the Nostr spec:
|
||
/// an author's parameterized replaceable event is a single global resource identified
|
||
/// by its d-tag, regardless of which channel it was submitted to. The `channel_id`
|
||
/// parameter is stored on the new row for query scoping but does not affect replacement.
|
||
///
|
||
/// Note: `replace_addressable_event()` keys on `channel_id` because it serves
|
||
/// relay-signed NIP-29 group metadata (kind 39000–39002) where the relay is the
|
||
/// author and channel_id distinguishes groups. User-submitted NIP-33 events use
|
||
/// this function instead, where the author's pubkey + d-tag is the natural key.
|
||
pub async fn replace_parameterized_event(
|
||
&self,
|
||
community_id: CommunityId,
|
||
event: &nostr::Event,
|
||
d_tag: &str,
|
||
channel_id: Option<Uuid>,
|
||
) -> Result<(StoredEvent, bool)> {
|
||
let kind_i32 = buzz_core::kind::event_kind_i32(event);
|
||
let pubkey_bytes = event.pubkey.to_bytes();
|
||
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 lock_key = event_replacement_lock_key(
|
||
community_id,
|
||
kind_i32,
|
||
pubkey_bytes.as_slice(),
|
||
Some(d_tag.as_bytes()),
|
||
);
|
||
|
||
let mut tx = self.pool.begin().await?;
|
||
|
||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||
.bind(lock_key)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
let d_tag_count = event
|
||
.tags
|
||
.iter()
|
||
.filter(|tag| tag.as_slice().first().is_some_and(|part| part == "d"))
|
||
.count();
|
||
let has_exact_d_tag = event.tags.iter().any(|tag| {
|
||
let parts = tag.as_slice();
|
||
parts.len() >= 2 && parts[0] == "d" && parts[1] == d_tag
|
||
});
|
||
let read_state_t_tag_count = event
|
||
.tags
|
||
.iter()
|
||
.filter(|tag| {
|
||
let parts = tag.as_slice();
|
||
parts.len() == 2 && parts[0] == "t" && parts[1] == "read-state"
|
||
})
|
||
.count();
|
||
let is_nip_rs = kind_i32 == buzz_core::kind::KIND_READ_STATE as i32
|
||
&& d_tag_count == 1
|
||
&& has_exact_d_tag
|
||
&& d_tag.strip_prefix("read-state:").is_some_and(|slot| {
|
||
slot.len() == 32
|
||
&& slot
|
||
.bytes()
|
||
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
|
||
})
|
||
&& read_state_t_tag_count == 1;
|
||
let is_buzz_mesh_status = kind_i32 == buzz_core::kind::KIND_BOOKMARK_SET as i32
|
||
&& d_tag.starts_with("buzz-mesh-member-status:")
|
||
&& event.tags.iter().any(|tag| {
|
||
let parts = tag.as_slice();
|
||
parts.len() == 2 && parts[0] == "k" && parts[1] == "buzz-mesh-status"
|
||
});
|
||
let hard_delete_superseded = is_nip_rs || is_buzz_mesh_status;
|
||
|
||
// Check the live head and, for NIP-RS, the compact historical ordering
|
||
// watermark. The watermark remains after a NIP-09 coordinate deletion,
|
||
// preventing a previously accepted signed blob from being resurrected.
|
||
let existing: Option<(chrono::DateTime<chrono::Utc>, Vec<u8>)> = sqlx::query_as(
|
||
"SELECT created_at, id FROM events \
|
||
WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL \
|
||
ORDER BY created_at DESC, id ASC LIMIT 1",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(kind_i32)
|
||
.bind(pubkey_bytes.as_slice())
|
||
.bind(d_tag)
|
||
.fetch_optional(&mut *tx)
|
||
.await?;
|
||
let watermark: Option<(chrono::DateTime<chrono::Utc>, Vec<u8>)> = if is_nip_rs {
|
||
sqlx::query_as(
|
||
"SELECT created_at, event_id FROM parameterized_event_watermarks \
|
||
WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(kind_i32)
|
||
.bind(pubkey_bytes.as_slice())
|
||
.bind(d_tag)
|
||
.fetch_optional(&mut *tx)
|
||
.await?
|
||
} else {
|
||
None
|
||
};
|
||
|
||
// Stale-write protection: reject if either durable ordering source
|
||
// dominates the incoming tuple. Equal timestamps use lowest event id.
|
||
let incoming_id = event.id.as_bytes().as_slice();
|
||
let dominated =
|
||
existing
|
||
.iter()
|
||
.chain(watermark.iter())
|
||
.any(|(accepted_ts, accepted_id)| {
|
||
created_at < *accepted_ts
|
||
|| (created_at == *accepted_ts && incoming_id >= accepted_id.as_slice())
|
||
});
|
||
if dominated {
|
||
tx.rollback().await?;
|
||
let received_at = chrono::Utc::now();
|
||
return Ok((
|
||
StoredEvent::with_received_at(event.clone(), received_at, channel_id, false),
|
||
false,
|
||
));
|
||
}
|
||
|
||
if existing.is_some() {
|
||
if is_nip_rs {
|
||
// Migration 0011 rejects regex-coordinate hard deletes from
|
||
// pre-fix writers. Authorize only this corrected NIP-RS delete,
|
||
// transaction-locally so pooled connections cannot leak it.
|
||
sqlx::query("SELECT set_config('buzz.nip_rs_hard_delete', 'on', true)")
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
}
|
||
let statement = if hard_delete_superseded {
|
||
"DELETE FROM events \
|
||
WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL"
|
||
} else {
|
||
"UPDATE events SET deleted_at = NOW() \
|
||
WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL"
|
||
};
|
||
sqlx::query(statement)
|
||
.bind(community_id.as_uuid())
|
||
.bind(kind_i32)
|
||
.bind(pubkey_bytes.as_slice())
|
||
.bind(d_tag)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
if hard_delete_superseded {
|
||
if let Some((_, existing_id)) = &existing {
|
||
// Event first, mentions second: migration 0009's live-event
|
||
// fence uses this global lock order to avoid deadlocks.
|
||
sqlx::query(
|
||
"DELETE FROM event_mentions WHERE community_id = $1 AND event_id = $2",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(existing_id)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Insert the new event inside the transaction.
|
||
let sig_bytes = event.sig.serialize();
|
||
let tags_json = serde_json::to_value(&event.tags)?;
|
||
let received_at = chrono::Utc::now();
|
||
|
||
let insert_result = sqlx::query(
|
||
"INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag, not_before) \
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) \
|
||
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(channel_id)
|
||
.bind(d_tag)
|
||
.bind(event::extract_not_before(event))
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
let was_inserted = insert_result.rows_affected() > 0;
|
||
if !was_inserted {
|
||
tx.rollback().await?;
|
||
return Ok((
|
||
StoredEvent::with_received_at(event.clone(), received_at, channel_id, false),
|
||
false,
|
||
));
|
||
}
|
||
|
||
if is_nip_rs {
|
||
sqlx::query(
|
||
"INSERT INTO parameterized_event_watermarks \
|
||
(community_id, kind, pubkey, d_tag, created_at, event_id) \
|
||
VALUES ($1, $2, $3, $4, $5, $6) \
|
||
ON CONFLICT (community_id, kind, pubkey, d_tag) DO UPDATE SET \
|
||
created_at = EXCLUDED.created_at, event_id = EXCLUDED.event_id",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(kind_i32)
|
||
.bind(pubkey_bytes.as_slice())
|
||
.bind(d_tag)
|
||
.bind(created_at)
|
||
.bind(incoming_id)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
}
|
||
|
||
tx.commit().await?;
|
||
|
||
// Mentions are a denormalized index — safe outside the transaction.
|
||
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((
|
||
StoredEvent::with_received_at(event.clone(), received_at, channel_id, true),
|
||
true,
|
||
))
|
||
}
|
||
}
|
||
|
||
/// 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<u8>,
|
||
/// Compressed public key bytes of the token owner.
|
||
pub owner_pubkey: Vec<u8>,
|
||
/// Human-readable token name.
|
||
pub name: String,
|
||
/// Permission scopes granted to this token.
|
||
pub scopes: Vec<String>,
|
||
/// Optional channel ID restrictions.
|
||
pub channel_ids: Option<Vec<Uuid>>,
|
||
/// When the token was created.
|
||
pub created_at: DateTime<Utc>,
|
||
/// Optional expiry timestamp.
|
||
pub expires_at: Option<DateTime<Utc>>,
|
||
/// When the token was last used.
|
||
pub last_used_at: Option<DateTime<Utc>>,
|
||
/// When the token was revoked.
|
||
pub revoked_at: Option<DateTime<Utc>>,
|
||
}
|
||
|
||
/// An entry in the pubkey allowlist.
|
||
#[derive(Debug, Clone)]
|
||
pub struct AllowlistEntry {
|
||
/// The allowed pubkey.
|
||
pub pubkey: Vec<u8>,
|
||
/// Who added this entry.
|
||
pub added_by: Vec<u8>,
|
||
/// When the entry was added.
|
||
pub added_at: DateTime<Utc>,
|
||
/// Optional note.
|
||
pub note: Option<String>,
|
||
}
|
||
|
||
fn parse_api_token_row(row: sqlx::postgres::PgRow) -> Result<ApiTokenRecord> {
|
||
let id: Uuid = row.try_get("id")?;
|
||
|
||
let scopes_json: serde_json::Value = row.try_get("scopes")?;
|
||
let scopes: Vec<String> = serde_json::from_value(scopes_json)
|
||
.map_err(|e| DbError::InvalidData(format!("scopes JSON: {e}")))?;
|
||
|
||
let channel_ids: Option<Vec<Uuid>> = {
|
||
let raw: Option<serde_json::Value> = row.try_get("channel_ids")?;
|
||
match raw {
|
||
None => None,
|
||
Some(v) => {
|
||
let strings: Vec<String> = serde_json::from_value(v)
|
||
.map_err(|e| DbError::InvalidData(format!("channel_ids JSON: {e}")))?;
|
||
let uuids: std::result::Result<Vec<Uuid>, _> =
|
||
strings.iter().map(|s| s.parse::<Uuid>()).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 {
|
||
//! Pin the load-bearing contract for `Db::communities_of_channels`:
|
||
//! a channel id that does NOT exist MUST be absent from the result
|
||
//! map, never mapped to a default. The relay-side read-row emitter
|
||
//! relies on this — a missing entry triggers `MissingLookup →
|
||
//! ImplBug{row_community_lookup_missing} → CoverageBreach`. If this
|
||
//! helper ever started returning a default/zero entry for unknown
|
||
//! channels, that fail-closed chain would go blind.
|
||
use super::*;
|
||
use buzz_core::CommunityId;
|
||
use sqlx::postgres::PgPoolOptions;
|
||
use sqlx::{Acquire, 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 nip_rs_replacement_hard_deletes_payload_and_watermark_rejects_replay() {
|
||
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:{}", "a".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 old = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "old")
|
||
.tags(tags.clone())
|
||
.custom_created_at(Timestamp::from(base))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign old");
|
||
let new = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "new")
|
||
.tags(tags)
|
||
.custom_created_at(Timestamp::from(base + 1))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign new");
|
||
|
||
assert!(
|
||
db.replace_parameterized_event(community, &old, &d_tag, None)
|
||
.await
|
||
.expect("insert old")
|
||
.1
|
||
);
|
||
assert!(
|
||
db.replace_parameterized_event(community, &new, &d_tag, None)
|
||
.await
|
||
.expect("replace with new")
|
||
.1
|
||
);
|
||
|
||
let rows: 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 NIP-RS rows");
|
||
assert_eq!(rows, 1, "superseded payload must be physically deleted");
|
||
|
||
sqlx::query(
|
||
"UPDATE events SET deleted_at=NOW() 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)
|
||
.execute(&db.pool)
|
||
.await
|
||
.expect("simulate NIP-09 coordinate deletion");
|
||
|
||
assert!(
|
||
!db.replace_parameterized_event(community, &old, &d_tag, None)
|
||
.await
|
||
.expect("replay old")
|
||
.1
|
||
);
|
||
let live: i64 = sqlx::query_scalar(
|
||
"SELECT count(*) FROM events 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)
|
||
.fetch_one(&db.pool)
|
||
.await
|
||
.expect("count live NIP-RS rows");
|
||
assert_eq!(live, 0, "watermark must block stale resurrection");
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn mesh_status_replacement_keeps_one_physical_row() {
|
||
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 = "buzz-mesh-member-status:owner-test";
|
||
let tags = vec![
|
||
Tag::parse(["d", d_tag]).expect("d tag"),
|
||
Tag::parse(["k", "buzz-mesh-status"]).expect("k tag"),
|
||
];
|
||
let base = Timestamp::now().as_secs();
|
||
for (offset, content) in [(0, "running"), (1, "running-again"), (2, "stopped")] {
|
||
let event = EventBuilder::new(
|
||
Kind::Custom(buzz_core::kind::KIND_BOOKMARK_SET as u16),
|
||
content,
|
||
)
|
||
.tags(tags.clone())
|
||
.custom_created_at(Timestamp::from(base + offset))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign mesh status");
|
||
assert!(
|
||
db.replace_parameterized_event(community, &event, d_tag, None)
|
||
.await
|
||
.expect("replace mesh status")
|
||
.1
|
||
);
|
||
}
|
||
|
||
let (rows, live): (i64, i64) = sqlx::query_as(
|
||
"SELECT count(*), count(*) FILTER (WHERE deleted_at IS NULL) FROM events \
|
||
WHERE community_id=$1 AND kind=30003 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 mesh status rows");
|
||
assert_eq!((rows, live), (1, 1));
|
||
|
||
sqlx::query(
|
||
"UPDATE events SET deleted_at=NOW() \
|
||
WHERE community_id=$1 AND kind=30003 AND pubkey=$2 AND d_tag=$3",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(d_tag)
|
||
.execute(&db.pool)
|
||
.await
|
||
.expect("simulate old relay soft delete");
|
||
let rows_after_legacy_delete: i64 = sqlx::query_scalar(
|
||
"SELECT count(*) FROM events \
|
||
WHERE community_id=$1 AND kind=30003 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 rows after old relay soft delete");
|
||
assert_eq!(
|
||
rows_after_legacy_delete, 0,
|
||
"migration trigger must purge soft-deleted mesh status"
|
||
);
|
||
}
|
||
|
||
#[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<String> = 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 duplicate_nip_rs_discriminator_tags_keep_legacy_retention() {
|
||
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 base = Timestamp::now().as_secs();
|
||
|
||
for (case, tags) in [
|
||
(
|
||
"duplicate-d",
|
||
vec![
|
||
Tag::parse(["d", &format!("read-state:{}", "c".repeat(32))])
|
||
.expect("first d tag"),
|
||
Tag::parse(["d", &format!("read-state:{}", "d".repeat(32))])
|
||
.expect("second d tag"),
|
||
Tag::parse(["t", "read-state"]).expect("t tag"),
|
||
],
|
||
),
|
||
(
|
||
"duplicate-t",
|
||
vec![
|
||
Tag::parse(["d", &format!("read-state:{}", "e".repeat(32))]).expect("d tag"),
|
||
Tag::parse(["t", "read-state"]).expect("first t tag"),
|
||
Tag::parse(["t", "read-state"]).expect("second t tag"),
|
||
],
|
||
),
|
||
] {
|
||
let d_tag = tags
|
||
.iter()
|
||
.find_map(|tag| {
|
||
let parts = tag.as_slice();
|
||
(parts.first().is_some_and(|part| part == "d") && parts.len() >= 2)
|
||
.then(|| parts[1].clone())
|
||
})
|
||
.expect("first d-tag value");
|
||
let old = EventBuilder::new(
|
||
Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16),
|
||
format!("{case}-old"),
|
||
)
|
||
.tags(tags.clone())
|
||
.custom_created_at(Timestamp::from(base))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign old event");
|
||
let new = EventBuilder::new(
|
||
Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16),
|
||
format!("{case}-new"),
|
||
)
|
||
.tags(tags)
|
||
.custom_created_at(Timestamp::from(base + 1))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign new event");
|
||
|
||
assert!(
|
||
db.replace_parameterized_event(community, &old, &d_tag, None)
|
||
.await
|
||
.expect("insert old event")
|
||
.1
|
||
);
|
||
assert!(
|
||
db.replace_parameterized_event(community, &new, &d_tag, None)
|
||
.await
|
||
.expect("replace with new event")
|
||
.1
|
||
);
|
||
|
||
let (rows, live): (i64, i64) = sqlx::query_as(
|
||
"SELECT count(*), count(*) FILTER (WHERE deleted_at IS NULL) 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 rows");
|
||
assert_eq!((rows, live), (2, 1), "{case} must retain legacy history");
|
||
|
||
let watermarks: i64 = sqlx::query_scalar(
|
||
"SELECT count(*) 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("count watermarks");
|
||
assert_eq!(watermarks, 0, "{case} must not create a watermark");
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn nip_rs_hard_delete_fence_fails_closed_and_scopes_opt_in_to_transaction() {
|
||
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 base = Timestamp::now().as_secs();
|
||
let conforming_d = format!("read-state:{}", "6".repeat(32));
|
||
let conforming = EventBuilder::new(
|
||
Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16),
|
||
"fenced-conforming",
|
||
)
|
||
.tags(vec![
|
||
Tag::parse(["d", conforming_d.as_str()]).expect("d tag"),
|
||
Tag::parse(["t", "read-state"]).expect("t tag"),
|
||
])
|
||
.custom_created_at(Timestamp::from(base))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign conforming event");
|
||
assert!(
|
||
db.replace_parameterized_event(community, &conforming, &conforming_d, None)
|
||
.await
|
||
.expect("insert conforming event")
|
||
.1
|
||
);
|
||
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("6".repeat(64))
|
||
.bind(conforming.id.as_bytes().as_slice())
|
||
.bind(conforming.created_at.as_secs() as f64)
|
||
.execute(&db.pool)
|
||
.await
|
||
.expect("insert mention");
|
||
|
||
// Model ce10's first destructive statement. RAISE aborts the transaction,
|
||
// so its later mention delete and incoming insert can never commit.
|
||
let mut old_writer = db.pool.begin().await.expect("begin old-writer tx");
|
||
let rejected = sqlx::query(
|
||
"DELETE FROM events 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(&conforming_d)
|
||
.execute(&mut *old_writer)
|
||
.await;
|
||
assert!(rejected.is_err(), "old-writer hard delete must be rejected");
|
||
old_writer.rollback().await.expect("rollback rejected tx");
|
||
let preserved: (i64, i64) = sqlx::query_as(
|
||
"SELECT (SELECT count(*) FROM events WHERE community_id=$1 AND id=$2), \
|
||
(SELECT count(*) FROM event_mentions WHERE community_id=$1 AND event_id=$2)",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(conforming.id.as_bytes().as_slice())
|
||
.fetch_one(&db.pool)
|
||
.await
|
||
.expect("count preserved payload and mention");
|
||
assert_eq!(preserved, (1, 1));
|
||
|
||
let nonconforming_d = format!("read-state:{}", "7".repeat(32));
|
||
let nonconforming = EventBuilder::new(
|
||
Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16),
|
||
"fenced-nonconforming",
|
||
)
|
||
.tags(vec![
|
||
Tag::parse(["d", nonconforming_d.as_str()]).expect("first d tag"),
|
||
Tag::parse(["d", "other"]).expect("second d tag"),
|
||
Tag::parse(["t", "read-state"]).expect("t tag"),
|
||
])
|
||
.custom_created_at(Timestamp::from(base + 1))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign nonconforming event");
|
||
assert!(
|
||
db.replace_parameterized_event(community, &nonconforming, &nonconforming_d, None,)
|
||
.await
|
||
.expect("insert nonconforming event")
|
||
.1
|
||
);
|
||
let rejected_nonconforming = sqlx::query(
|
||
"DELETE FROM events WHERE community_id=$1 AND id=$2 AND created_at=to_timestamp($3)",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(nonconforming.id.as_bytes().as_slice())
|
||
.bind(nonconforming.created_at.as_secs() as f64)
|
||
.execute(&db.pool)
|
||
.await;
|
||
assert!(
|
||
rejected_nonconforming.is_err(),
|
||
"fence must cover a nonconforming OLD row at a regex coordinate"
|
||
);
|
||
|
||
let unrelated_d = format!("read-state:{}", "8".repeat(32));
|
||
let unrelated = EventBuilder::new(Kind::Custom(30023), "unrelated")
|
||
.tags(vec![Tag::parse(["d", unrelated_d.as_str()]).expect("d tag")])
|
||
.custom_created_at(Timestamp::from(base + 2))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign unrelated event");
|
||
assert!(
|
||
db.replace_parameterized_event(community, &unrelated, &unrelated_d, None)
|
||
.await
|
||
.expect("insert unrelated event")
|
||
.1
|
||
);
|
||
let unrelated_delete = sqlx::query(
|
||
"DELETE FROM events WHERE community_id=$1 AND id=$2 AND created_at=to_timestamp($3)",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(unrelated.id.as_bytes().as_slice())
|
||
.bind(unrelated.created_at.as_secs() as f64)
|
||
.execute(&db.pool)
|
||
.await
|
||
.expect("delete unrelated event");
|
||
assert_eq!(unrelated_delete.rows_affected(), 1);
|
||
|
||
// Check both transaction exits on one physical session; pool selection
|
||
// cannot accidentally hide a leaked session-local authorization value.
|
||
let mut conn = db.pool.acquire().await.expect("acquire dedicated session");
|
||
for commit in [true, false] {
|
||
let mut tx = conn.begin().await.expect("begin GUC transaction");
|
||
let value: String =
|
||
sqlx::query_scalar("SELECT set_config('buzz.nip_rs_hard_delete', 'on', true)")
|
||
.fetch_one(&mut *tx)
|
||
.await
|
||
.expect("set transaction-local GUC");
|
||
assert_eq!(value, "on");
|
||
if commit {
|
||
tx.commit().await.expect("commit GUC transaction");
|
||
} else {
|
||
tx.rollback().await.expect("rollback GUC transaction");
|
||
}
|
||
let leaked: Option<String> = sqlx::query_scalar(
|
||
"SELECT NULLIF(current_setting('buzz.nip_rs_hard_delete', true), '')",
|
||
)
|
||
.fetch_one(&mut *conn)
|
||
.await
|
||
.expect("read GUC after transaction");
|
||
assert_ne!(leaked.as_deref(), Some("on"));
|
||
}
|
||
}
|
||
|
||
#[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::postgres::PgQueryResult, sqlx::Error> {
|
||
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<chrono::Utc>, Vec<u8>) = 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 lookup_community_by_host_matches_case_insensitive_host_index() {
|
||
let db = setup_db().await;
|
||
let id = Uuid::new_v4();
|
||
let lower_host = format!("lookup-community-{}.example", id.simple());
|
||
let stored_host = lower_host.to_uppercase();
|
||
|
||
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
|
||
.bind(id)
|
||
.bind(&stored_host)
|
||
.execute(&db.pool)
|
||
.await
|
||
.expect("insert mixed-case community host");
|
||
|
||
let found = db
|
||
.lookup_community_by_host(&lower_host)
|
||
.await
|
||
.expect("lookup lower-case host")
|
||
.expect("community found by lower-case host");
|
||
assert_eq!(found.id, CommunityId::from_uuid(id));
|
||
assert_eq!(found.host, stored_host);
|
||
|
||
let found = db
|
||
.lookup_community_by_host(&stored_host)
|
||
.await
|
||
.expect("lookup stored-case host")
|
||
.expect("community found by stored-case host");
|
||
assert_eq!(found.id, CommunityId::from_uuid(id));
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn create_community_with_owner_is_atomic_and_create_only() {
|
||
let db = setup_db().await;
|
||
let host = format!("create-only-{}.example", Uuid::new_v4().simple());
|
||
let owner = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||
let other = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||
|
||
let created = db
|
||
.create_community_with_owner(&host, owner)
|
||
.await
|
||
.expect("create community");
|
||
let CreateCommunityWithOwnerResult::Created(created) = created else {
|
||
panic!("expected new community");
|
||
};
|
||
assert_eq!(created.host, host);
|
||
let owner_role: Option<String> = sqlx::query_scalar(
|
||
"SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2",
|
||
)
|
||
.bind(created.id.as_uuid())
|
||
.bind(owner)
|
||
.fetch_optional(&db.pool)
|
||
.await
|
||
.expect("owner role");
|
||
assert_eq!(owner_role.as_deref(), Some("owner"));
|
||
|
||
let retry = db
|
||
.create_community_with_owner(&host.to_ascii_uppercase(), owner)
|
||
.await
|
||
.expect("same-owner retry");
|
||
assert_eq!(
|
||
retry,
|
||
CreateCommunityWithOwnerResult::Created(created.clone()),
|
||
"retry returns the original row"
|
||
);
|
||
|
||
let collision = db
|
||
.create_community_with_owner(&host, other)
|
||
.await
|
||
.expect("collision result");
|
||
assert_eq!(collision, CreateCommunityWithOwnerResult::HostExists);
|
||
let roles: Vec<(String, String)> = sqlx::query_as(
|
||
"SELECT pubkey, role FROM relay_members WHERE community_id = $1 ORDER BY pubkey",
|
||
)
|
||
.bind(created.id.as_uuid())
|
||
.fetch_all(&db.pool)
|
||
.await
|
||
.expect("community roles");
|
||
assert_eq!(roles, vec![(owner.to_string(), "owner".to_string())]);
|
||
|
||
db.bootstrap_owner(created.id, other)
|
||
.await
|
||
.expect("rotate owner");
|
||
let post_rotation_retry = db
|
||
.create_community_with_owner(&host, owner)
|
||
.await
|
||
.expect("post-rotation retry");
|
||
assert_eq!(
|
||
post_rotation_retry,
|
||
CreateCommunityWithOwnerResult::HostExists
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn unarchive_community_owned_by_restores_admission_idempotently() {
|
||
let db = setup_db().await;
|
||
let host = format!("unarchive-{}.example", Uuid::new_v4().simple());
|
||
let owner = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
|
||
let outsider = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
|
||
let created = db
|
||
.create_community_with_owner(&host, &owner)
|
||
.await
|
||
.expect("create community");
|
||
let CreateCommunityWithOwnerResult::Created(created) = created else {
|
||
panic!("expected new community");
|
||
};
|
||
|
||
let archived = db
|
||
.archive_community_owned_by(&host, &owner, "protected.example")
|
||
.await
|
||
.expect("archive community")
|
||
.expect("owned community");
|
||
assert_eq!(archived.id, created.id);
|
||
assert!(
|
||
db.lookup_community_by_host(&host)
|
||
.await
|
||
.expect("active lookup")
|
||
.is_none(),
|
||
"archived communities must fail admission"
|
||
);
|
||
assert!(db
|
||
.unarchive_community_owned_by(&host, &outsider)
|
||
.await
|
||
.expect("wrong-owner unarchive")
|
||
.is_none());
|
||
assert!(db
|
||
.unarchive_community_owned_by("missing.example", &owner)
|
||
.await
|
||
.expect("unknown-host unarchive")
|
||
.is_none());
|
||
|
||
let restored = db
|
||
.unarchive_community_owned_by(&host.to_ascii_uppercase(), &owner)
|
||
.await
|
||
.expect("unarchive community")
|
||
.expect("owned community");
|
||
assert_eq!(restored.id, created.id);
|
||
assert_eq!(restored.host, host);
|
||
assert_eq!(
|
||
db.lookup_community_by_host(&host)
|
||
.await
|
||
.expect("restored lookup")
|
||
.expect("active community")
|
||
.id,
|
||
created.id
|
||
);
|
||
assert_eq!(
|
||
db.get_relay_member(created.id, &owner)
|
||
.await
|
||
.expect("owner lookup")
|
||
.expect("owner remains")
|
||
.role,
|
||
"owner"
|
||
);
|
||
|
||
let retry = db
|
||
.unarchive_community_owned_by(&host, &owner)
|
||
.await
|
||
.expect("idempotent retry")
|
||
.expect("owned community");
|
||
assert_eq!(retry, restored);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn create_community_with_owner_enforces_per_owner_limit() {
|
||
let db = setup_db().await;
|
||
let owner = format!("{:064x}", Uuid::new_v4().as_u128());
|
||
|
||
// Create 3 communities for this owner (the max).
|
||
for i in 0..3 {
|
||
let host = format!("limit-test-{}-{}.example", i, Uuid::new_v4().simple());
|
||
assert!(matches!(
|
||
db.create_community_with_owner(&host, &owner)
|
||
.await
|
||
.expect("create community"),
|
||
CreateCommunityWithOwnerResult::Created(_)
|
||
));
|
||
}
|
||
|
||
let host = format!("limit-test-3-{}.example", Uuid::new_v4().simple());
|
||
assert_eq!(
|
||
db.create_community_with_owner(&host, &owner)
|
||
.await
|
||
.expect("create community call"),
|
||
CreateCommunityWithOwnerResult::LimitReached
|
||
);
|
||
assert!(
|
||
db.lookup_community_by_host(&host)
|
||
.await
|
||
.expect("look up rolled-back fresh host")
|
||
.is_none(),
|
||
"limit rejection must roll back the fresh community row"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn concurrent_same_owner_create_returns_the_winning_row_to_both_callers() {
|
||
let db = setup_db().await;
|
||
let host = format!("concurrent-create-{}.example", Uuid::new_v4().simple());
|
||
let owner = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||
|
||
let (first, second) = tokio::join!(
|
||
db.create_community_with_owner(&host, owner),
|
||
db.create_community_with_owner(&host, owner),
|
||
);
|
||
let first = first.expect("first concurrent create");
|
||
let second = second.expect("second concurrent create");
|
||
|
||
assert!(matches!(first, CreateCommunityWithOwnerResult::Created(_)));
|
||
assert_eq!(first, second, "conflict loser re-reads the winning row");
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn ensure_configured_community_reports_insert_winner() {
|
||
let db = setup_db().await;
|
||
let host = format!("ensure-community-{}.example", Uuid::new_v4().simple());
|
||
|
||
let first = db
|
||
.ensure_configured_community(&host)
|
||
.await
|
||
.expect("first ensure");
|
||
assert!(first.created, "first ensure should report created");
|
||
assert_eq!(first.host, host);
|
||
|
||
let second = db
|
||
.ensure_configured_community(&host)
|
||
.await
|
||
.expect("second ensure");
|
||
assert!(!second.created, "second ensure should report existed");
|
||
assert_eq!(second.id, first.id);
|
||
assert_eq!(second.host, host);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn list_communities_owned_by_returns_only_owner_rows() {
|
||
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 community_c = CommunityId::from_uuid(make_community(&db.pool).await);
|
||
// Unique per run: `list_communities_owned_by` is keyed only by pubkey,
|
||
// so a shared fixed pubkey picks up communities leaked by sibling
|
||
// ignored tests running against the same database.
|
||
let owner = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
|
||
let owner = owner.as_str();
|
||
let other = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
|
||
let other = other.as_str();
|
||
|
||
db.bootstrap_owner(community_a, owner)
|
||
.await
|
||
.expect("owner A");
|
||
db.bootstrap_owner(community_b, other)
|
||
.await
|
||
.expect("other owner B");
|
||
db.add_relay_member(community_c, owner, "admin", None)
|
||
.await
|
||
.expect("admin C");
|
||
|
||
let owned = db
|
||
.list_communities_owned_by(owner)
|
||
.await
|
||
.expect("list owned communities");
|
||
|
||
assert_eq!(owned.len(), 1);
|
||
assert_eq!(owned[0].id, community_a);
|
||
}
|
||
|
||
async fn insert_channel(pool: &PgPool, community_id: Uuid, channel_id: Uuid) {
|
||
let creator: Vec<u8> = vec![0u8; 32];
|
||
sqlx::query(
|
||
r#"
|
||
INSERT INTO channels
|
||
(id, community_id, name, channel_type, visibility, created_by)
|
||
VALUES
|
||
($1, $2, $3, 'stream'::channel_type, 'open'::channel_visibility, $4)
|
||
"#,
|
||
)
|
||
.bind(channel_id)
|
||
.bind(community_id)
|
||
.bind(format!("ch-{}", channel_id.simple()))
|
||
.bind(&creator)
|
||
.execute(pool)
|
||
.await
|
||
.expect("insert channel");
|
||
}
|
||
|
||
#[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"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn communities_of_channels_present_for_existing_absent_for_missing() {
|
||
let db = setup_db().await;
|
||
let community = make_community(&db.pool).await;
|
||
let existing = Uuid::new_v4();
|
||
insert_channel(&db.pool, community, existing).await;
|
||
|
||
// Channel that is NOT inserted — the load-bearing case.
|
||
let missing = Uuid::new_v4();
|
||
|
||
let result = db
|
||
.communities_of_channels(&[existing, missing])
|
||
.await
|
||
.expect("communities_of_channels");
|
||
|
||
// (1) Existing channel → present with its true community.
|
||
assert_eq!(
|
||
result.get(&existing).copied(),
|
||
Some(CommunityId::from_uuid(community)),
|
||
"existing channel must map to its true community",
|
||
);
|
||
|
||
// (2) Missing channel → ABSENT from the map (never defaulted).
|
||
// This is the contract the relay-side `MissingLookup → ImplBug`
|
||
// fail-closed guard-rail depends on. If this assertion ever
|
||
// weakens to `result.get(&missing) != Some(community)`, the
|
||
// mutate-bite below stops biting.
|
||
assert!(
|
||
!result.contains_key(&missing),
|
||
"missing channel must be absent from the result map, got {:?}",
|
||
result.get(&missing),
|
||
);
|
||
|
||
// (3) Map size matches: exactly one entry, the existing one.
|
||
assert_eq!(
|
||
result.len(),
|
||
1,
|
||
"result map must contain only existing channels"
|
||
);
|
||
}
|
||
|
||
/// 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 run migrations.
|
||
/// Returns (pool, db_name); callers should `drop_scratch_db` when done.
|
||
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 = 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");
|
||
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;
|
||
}
|
||
|
||
/// 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<u8> {
|
||
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<String> = 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<String> = 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<nostr::Event> = (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<String> {
|
||
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<String> {
|
||
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<String> {
|
||
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<nostr::Event> = (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<String> {
|
||
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<nostr::Event> = (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<Uuid>| {
|
||
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;
|
||
}
|
||
|
||
/// 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 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<u8>, age: Option<u64>| {
|
||
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;
|
||
}
|
||
}
|