mirror of
https://github.com/block/buzz.git
synced 2026-11-08 10:21:46 +00:00
## Summary `buzz-db` tests that create their own databases or roles dropped them as the last line of the test body. A failed assertion or a timeout skipped that line and left the database or role on the shared Postgres server. Seven files carried their own copy of `create_scratch_db_through` and its drop helper. - Adds `ScratchDb` and `ScratchRole` guards to `crates/buzz-db/src/test_support.rs`. Their `Drop` runs `DROP DATABASE IF EXISTS ... WITH (FORCE)` or `DROP ROLE IF EXISTS` on a fresh admin connection, on a separate thread with its own runtime. `ScratchSchema` already works this way, so the guards work under both runtime flavours and while a panicking test unwinds. `WITH (FORCE)` ends any sessions still open on the database, so pools need not be closed first. - A failed drop prints a warning instead of panicking, because a panic during unwinding would abort the test binary. - The seven per-file helpers and every end-of-test `DROP DATABASE` in `buzz-db` now use the guard. The replica-fence masked-activity test's least-privilege role uses `ScratchRole`. Admin pools and `close()` calls that only served cleanup are removed. Test behaviour is otherwise unchanged. - `crates/buzz-db/TESTING.md` now points new tests at the guards. - The second commit fixes a comment in `armed_pool_rejects_old_channel_inserts_through_public_api` that still described the old end-of-test drop. **Existing leftovers need dropping by hand.** This change stops new leftovers but does not remove the ones earlier failed runs already left in dev `buzz-postgres` containers. There were 24 on the author's machine, named `stall_r_*`, `buzz_admin_*`, `floor_probe_*`, `fence_*` and `cxl_*`. List them, check the list, then drop each one: ```sql SELECT datname FROM pg_database WHERE datname ~ '^(stall_r|buzz_admin|floor_probe|fence|cxl)_'; DROP DATABASE "<name>" WITH (FORCE); ``` The guards are private to `buzz-db`. `buzz-push-gateway`, `buzz-admin` and `buzz-relay` tests still create and drop scratch databases by hand, which could get a shared copy of the guards in a follow-up. ### Related issue None found. This continues the test-cleanup work in #8727. ### Testing - Ran the 70 affected `buzz-db` tests against the local `buzz-postgres` container: 68 passed. Neither failure comes from this change. `cluster_global_probe_commits_tokens_and_sessions_prove_coverage` hit the known `MaskedActivity { masked: 1 }` flake under load and passed when run alone. Because it panicked while holding a `ScratchDb`, it also showed a panicking test leaves nothing behind. `serving_lease_statement_timeout_cancels_slow_statement` failed with `VersionMismatch(56)`, because the shared dev database already has another branch's migration 56 applied. - After the run, the server's database and role lists matched the snapshot taken before it. - A temporary test panicked while holding an open transaction, an exclusive table lock and a live session as the scratch role. It left no database or role behind under either runtime flavour and printed no drop warnings. That test is not part of this PR. - `cargo fmt --check` and `cargo clippy -p buzz-db --all-targets -- -D warnings` pass. The tests ran before the commits were cherry-picked onto current main. Only clippy was re-run afterwards. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
5527 lines
214 KiB
Rust
5527 lines
214 KiB
Rust
//! Event storage and retrieval.
|
||
//!
|
||
//! AUTH events (kind 22242) are never stored — they carry bearer tokens.
|
||
//! Ephemeral events (kinds 20000–29999) are never stored — Redis pub/sub only.
|
||
//! Deduplication is application-layer: ON CONFLICT DO NOTHING.
|
||
|
||
use chrono::{DateTime, Utc};
|
||
use nostr::Event;
|
||
use sqlx::{PgPool, QueryBuilder, Row};
|
||
use uuid::Uuid;
|
||
|
||
use buzz_core::kind::{
|
||
event_kind_i32, is_ephemeral, is_parameterized_replaceable, KIND_AUTH, KIND_CANVAS,
|
||
KIND_EVENT_REMINDER, KIND_HUDDLE_STARTED, SHARED_GATED_KINDS,
|
||
};
|
||
use buzz_core::{CommunityId, StoredEvent};
|
||
use buzz_datastore_tracing::datastore_span;
|
||
|
||
use crate::error::{DbError, Result};
|
||
use crate::{AdmittedTx, Db};
|
||
|
||
// Compatibility exports preserve the pre-extraction public event-store paths.
|
||
pub use crate::reminder::{
|
||
claim_due_reminder, claim_due_reminder_with_stamp, query_due_reminders, release_due_reminder,
|
||
DueReminder,
|
||
};
|
||
|
||
/// Largest page [`query_events`] will return when [`EventQuery::max_limit`] is
|
||
/// unset — the effective ceiling on any client-requested `limit`.
|
||
///
|
||
/// This is the value the relay advertises as NIP-11 `limitation.max_limit`, so
|
||
/// the advertised ceiling and the enforced one cannot drift.
|
||
pub const DEFAULT_MAX_PAGE_LIMIT: i64 = 1_000;
|
||
|
||
/// NIP-AR revision and removal kinds, whose stable identity is a `d` tag.
|
||
pub const ARTIFACT_KINDS: [i32; 2] = [
|
||
buzz_core::kind::KIND_ARTIFACT as i32,
|
||
buzz_core::kind::KIND_ARTIFACT_REMOVAL as i32,
|
||
];
|
||
|
||
/// Optional filters for [`query_events`].
|
||
#[derive(Debug, Clone)]
|
||
pub struct EventQuery {
|
||
/// Server-resolved community scope.
|
||
pub community_id: CommunityId,
|
||
/// Restrict results to this channel.
|
||
pub channel_id: Option<Uuid>,
|
||
/// Restrict results to these kind values (stored as `i32` in Postgres).
|
||
pub kinds: Option<Vec<i32>>,
|
||
/// Restrict results to events from this pubkey.
|
||
pub pubkey: Option<Vec<u8>>,
|
||
/// Return events created at or after this time.
|
||
pub since: Option<DateTime<Utc>>,
|
||
/// Return events created at or before this time.
|
||
pub until: Option<DateTime<Utc>>,
|
||
/// Maximum number of events to return.
|
||
pub limit: Option<i64>,
|
||
/// Number of events to skip (for pagination).
|
||
pub offset: Option<i64>,
|
||
/// Restrict to events with a `p` tag mentioning this hex pubkey.
|
||
/// Joins against `event_mentions` table (indexed).
|
||
pub p_tag_hex: Option<String>,
|
||
/// Restrict to events with this exact `d_tag` value (NIP-33).
|
||
/// Pushed into SQL via the `idx_events_parameterized` index.
|
||
pub d_tag: Option<String>,
|
||
/// Restrict to events with any of these `d_tag` values (multi-value NIP-33 pushdown).
|
||
/// Used when a filter has multiple `#d` values and targets only NIP-33 kinds.
|
||
pub d_tags: Option<Vec<String>>,
|
||
/// Composite keyset cursor: exclude events at or "after" this (created_at, id) pair.
|
||
/// Used with `until` for stable pagination: events where
|
||
/// `created_at < until OR (created_at = until AND id > before_id)`.
|
||
/// When set, `until` must also be set.
|
||
pub before_id: Option<Vec<u8>>,
|
||
/// When true, restricts results to global events (`channel_id IS NULL`).
|
||
/// Use for endpoints that serve non-channel data (e.g. kind:1 notes) to
|
||
/// defensively prevent leaking channel-scoped events if the ingest
|
||
/// invariant (`is_global_only_kind`) ever changes.
|
||
/// Mutually exclusive with `channel_id`.
|
||
pub global_only: bool,
|
||
/// Restrict results to events from any of these pubkeys (multi-author `IN` pushdown).
|
||
pub authors: Option<Vec<Vec<u8>>>,
|
||
/// Restrict results to events with any of these IDs (multi-id `IN` pushdown).
|
||
pub ids: Option<Vec<Vec<u8>>>,
|
||
/// Restrict results to events with an `e` tag referencing any of these event IDs (hex).
|
||
/// Matched positionally, with JSONB containment as the index prefilter.
|
||
pub e_tags: Option<Vec<String>>,
|
||
/// Restrict artifact rows ([`ARTIFACT_KINDS`]) to those with a `d` tag
|
||
/// matching any of these values, matched positionally. Their `d_tag`
|
||
/// column is NULL (not NIP-33), so this lets identity lookups match before
|
||
/// SQL `LIMIT`. Rows of other kinds are left to the caller's post-filter.
|
||
pub d_tag_values: Option<Vec<String>>,
|
||
/// Restrict results to events with an exact custom tag pair: a tag whose
|
||
/// first element is the name and second the value, checked positionally
|
||
/// (JSONB containment is only the index prefilter) before SQL `LIMIT`.
|
||
pub custom_tag: Option<(String, String)>,
|
||
/// Restrict results to events that, for every `(name, values)` entry,
|
||
/// have a tag whose name is `name` and whose first value is one of
|
||
/// `values` (NIP-01 tag matching), before SQL `LIMIT`. Entries with no
|
||
/// values are ignored.
|
||
pub tag_values: Vec<(String, Vec<String>)>,
|
||
/// Restrict results to events in any of these channels. By default,
|
||
/// channel-less global events are retained so this can enforce a viewer's
|
||
/// accessible-channel scope without hiding global events. Set
|
||
/// [`EventQuery::channel_ids_include_global`] to `false` for an explicit
|
||
/// multi-channel `#h` filter, which must match only requested channels.
|
||
/// Applied before SQL `LIMIT` so access- and filter-scoped historical pages
|
||
/// have exact exhaustion semantics.
|
||
pub channel_ids: Option<Vec<uuid::Uuid>>,
|
||
/// Whether [`EventQuery::channel_ids`] also retains channel-less global
|
||
/// events. Defaults to `true` for access-scope queries.
|
||
pub channel_ids_include_global: bool,
|
||
/// Override the default page clamp ([`DEFAULT_MAX_PAGE_LIMIT`]). Used by
|
||
/// the COUNT fallback path, which needs to fetch all matching events for
|
||
/// post-filter counting. When None, the default clamp applies.
|
||
pub max_limit: Option<i64>,
|
||
/// Shared-gated visibility reader: when set, append an SQL visibility
|
||
/// clause for every kind in [`SHARED_GATED_KINDS`] before ORDER/LIMIT so
|
||
/// private events are excluded from the candidate page rather than
|
||
/// discarded after it.
|
||
///
|
||
/// The clause is: `AND (kind NOT IN (...) OR pubkey = $reader OR <shared>)`,
|
||
/// where the `IN` list is [`SHARED_GATED_KINDS`] and `<shared>` requires a
|
||
/// tag whose element 0 is `shared` and element 1 is `true`. Containment on
|
||
/// the GIN index (migration 0004, jsonb_path_ops) is the fast first pass;
|
||
/// containment alone would also admit decoys like `["x","shared","true"]`.
|
||
/// `event_visible_to_reader` remains the post-filter.
|
||
pub shared_gated_reader: Option<Vec<u8>>,
|
||
/// The channel types whose membership notices (44100/44101) the query may
|
||
/// return: the filter's `#t` values
|
||
/// ([`buzz_core::filter::notice_channel_types`]). `None` means the legacy
|
||
/// types only, so a read path that does not set it never returns a notice
|
||
/// about a type added later, such as a system channel (NIP-CL).
|
||
pub notice_channel_types: Option<Vec<String>>,
|
||
}
|
||
|
||
impl EventQuery {
|
||
/// Construct an unconstrained query inside a server-resolved community.
|
||
///
|
||
/// `community_id` has no safe default. This keeps call sites concise while
|
||
/// making tenant provenance explicit at construction.
|
||
#[must_use]
|
||
pub const fn for_community(community_id: CommunityId) -> Self {
|
||
Self {
|
||
community_id,
|
||
channel_id: None,
|
||
kinds: None,
|
||
pubkey: None,
|
||
since: None,
|
||
until: None,
|
||
limit: None,
|
||
offset: None,
|
||
p_tag_hex: None,
|
||
d_tag: None,
|
||
d_tags: None,
|
||
before_id: None,
|
||
global_only: false,
|
||
authors: None,
|
||
ids: None,
|
||
e_tags: None,
|
||
d_tag_values: None,
|
||
custom_tag: None,
|
||
tag_values: Vec::new(),
|
||
channel_ids: None,
|
||
channel_ids_include_global: true,
|
||
max_limit: None,
|
||
shared_gated_reader: None,
|
||
notice_channel_types: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
pub use crate::reaction::{insert_reaction_event_with_thread_metadata, ReactionEventInsertOutcome};
|
||
|
||
/// Maximum length for a `d_tag` value (bytes). NIP-33 d-tags are short identifiers;
|
||
/// anything beyond this is either a bug or abuse.
|
||
pub const D_TAG_MAX_LEN: usize = 1024;
|
||
|
||
/// Maximum huddle-start content bytes considered by the parent-link lookup.
|
||
///
|
||
/// The canonical content is a small JSON object containing one UUID. Rejecting
|
||
/// oversized candidates keeps a malformed lifecycle event from making audio
|
||
/// admission pull large text rows into memory.
|
||
const HUDDLE_LINK_CONTENT_MAX_BYTES: i64 = 512;
|
||
/// Maximum candidate rows inspected after SQL prefiltering by parent, creator,
|
||
/// kind, and UUID substring.
|
||
const HUDDLE_LINK_CANDIDATE_LIMIT: i64 = 32;
|
||
|
||
/// Extract the `d_tag` value for storage.
|
||
///
|
||
/// For NIP-33 parameterized replaceable events (kind 30000–39999): returns the first
|
||
/// `d` tag's value, or `""` if no `d` tag is present (per NIP-33 spec).
|
||
/// For all other events: returns `None` (column stays NULL).
|
||
pub fn extract_d_tag(event: &Event) -> Option<String> {
|
||
let kind_u32 = event.kind.as_u16() as u32;
|
||
if !is_parameterized_replaceable(kind_u32) {
|
||
return None;
|
||
}
|
||
let val = event
|
||
.tags
|
||
.iter()
|
||
.find_map(|tag| {
|
||
let parts = tag.as_slice();
|
||
if parts.len() >= 2 && parts[0] == "d" {
|
||
Some(parts[1].to_string())
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.unwrap_or_default(); // Missing d tag → empty string per NIP-33
|
||
Some(val)
|
||
}
|
||
|
||
/// Extract the `not_before` timestamp for materialization in the `events` table.
|
||
///
|
||
/// Only applies to `kind:30300` (NIP-ER event reminders). Returns the first
|
||
/// valid `not_before` tag value as an `i64` Unix timestamp, or `None` if the
|
||
/// event is not a reminder or has no `not_before` tag.
|
||
pub fn extract_not_before(event: &Event) -> Option<i64> {
|
||
let kind_u32 = event.kind.as_u16() as u32;
|
||
if kind_u32 != KIND_EVENT_REMINDER {
|
||
return None;
|
||
}
|
||
event.tags.iter().find_map(|tag| {
|
||
let parts = tag.as_slice();
|
||
if parts.len() >= 2 && parts[0] == "not_before" {
|
||
parts[1].parse::<i64>().ok()
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
}
|
||
|
||
fn huddle_started_content_links(content: &str, ephemeral_channel_id: Uuid) -> bool {
|
||
serde_json::from_str::<serde_json::Value>(content)
|
||
.ok()
|
||
.and_then(|value| {
|
||
value
|
||
.get("ephemeral_channel_id")
|
||
.and_then(serde_json::Value::as_str)
|
||
.and_then(|id| Uuid::parse_str(id).ok())
|
||
})
|
||
.is_some_and(|id| id == ephemeral_channel_id)
|
||
}
|
||
|
||
/// Resolve creator-authenticated parent links for a bounded set of huddle sessions.
|
||
///
|
||
/// The creator constraint matters: a member of some unrelated channel can post
|
||
/// their own kind:48100 event there, but they cannot sign as the creator of the
|
||
/// target ephemeral channel. One set-based query replaces the liveness
|
||
/// endpoint's former session × parent lookup loop. Malformed historical start
|
||
/// content is ignored rather than aborting the complete liveness snapshot.
|
||
pub async fn huddle_started_links(
|
||
pool: &PgPool,
|
||
community_id: CommunityId,
|
||
parent_channel_ids: &[Uuid],
|
||
ephemeral_channel_ids: &[Uuid],
|
||
) -> Result<Vec<(Uuid, Uuid, Vec<u8>)>> {
|
||
if parent_channel_ids.is_empty() || ephemeral_channel_ids.is_empty() {
|
||
return Ok(Vec::new());
|
||
}
|
||
let mut connection = crate::observability::acquire_writer(
|
||
pool,
|
||
crate::observability::WriterOperation::SubscriptionHistory,
|
||
)
|
||
.await?;
|
||
let rows = sqlx::query(
|
||
r#"
|
||
SELECT DISTINCT ON (backing.id)
|
||
backing.id AS session_id,
|
||
start.channel_id AS parent_channel_id,
|
||
backing.created_by
|
||
FROM events start
|
||
JOIN channels backing
|
||
ON backing.community_id = start.community_id
|
||
AND backing.id::text = CASE
|
||
WHEN start.content IS JSON OBJECT
|
||
THEN (start.content::json ->> 'ephemeral_channel_id')
|
||
ELSE NULL
|
||
END
|
||
AND backing.deleted_at IS NULL
|
||
WHERE start.deleted_at IS NULL
|
||
AND start.community_id = $1
|
||
AND start.channel_id = ANY($2)
|
||
AND start.kind = $3
|
||
AND octet_length(start.content) <= $5
|
||
AND backing.id = ANY($4)
|
||
AND start.pubkey = backing.created_by
|
||
ORDER BY backing.id, start.created_at DESC, start.id ASC
|
||
"#,
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(parent_channel_ids)
|
||
.bind(KIND_HUDDLE_STARTED as i32)
|
||
.bind(ephemeral_channel_ids)
|
||
.bind(HUDDLE_LINK_CONTENT_MAX_BYTES)
|
||
.fetch_all(&mut *connection)
|
||
.await?;
|
||
|
||
rows.into_iter()
|
||
.map(|row| {
|
||
Ok((
|
||
row.try_get("session_id")?,
|
||
row.try_get("parent_channel_id")?,
|
||
row.try_get("created_by")?,
|
||
))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Return whether a creator-signed huddle-start event links a parent channel
|
||
/// to the requested ephemeral huddle channel.
|
||
pub async fn huddle_started_link_exists(
|
||
pool: &PgPool,
|
||
community_id: CommunityId,
|
||
parent_channel_id: Uuid,
|
||
ephemeral_channel_id: Uuid,
|
||
creator_pubkey: &[u8],
|
||
) -> Result<bool> {
|
||
huddle_started_link_exists_with_operation(
|
||
pool,
|
||
community_id,
|
||
parent_channel_id,
|
||
ephemeral_channel_id,
|
||
creator_pubkey,
|
||
crate::observability::WriterOperation::Authorization,
|
||
)
|
||
.await
|
||
}
|
||
|
||
async fn huddle_started_link_exists_with_operation(
|
||
pool: &PgPool,
|
||
community_id: CommunityId,
|
||
parent_channel_id: Uuid,
|
||
ephemeral_channel_id: Uuid,
|
||
creator_pubkey: &[u8],
|
||
operation: crate::observability::WriterOperation,
|
||
) -> Result<bool> {
|
||
let mut connection = crate::observability::acquire_writer(pool, operation).await?;
|
||
let uuid_needle = format!("%{}%", ephemeral_channel_id);
|
||
let candidates: Vec<String> = sqlx::query_scalar(
|
||
r#"
|
||
SELECT content
|
||
FROM events
|
||
WHERE deleted_at IS NULL
|
||
AND community_id = $1
|
||
AND channel_id = $2
|
||
AND kind = $3
|
||
AND pubkey = $4
|
||
AND octet_length(content) <= $5
|
||
AND content ILIKE $6
|
||
ORDER BY created_at DESC, id ASC
|
||
LIMIT $7
|
||
"#,
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(parent_channel_id)
|
||
.bind(KIND_HUDDLE_STARTED as i32)
|
||
.bind(creator_pubkey)
|
||
.bind(HUDDLE_LINK_CONTENT_MAX_BYTES)
|
||
.bind(uuid_needle)
|
||
.bind(HUDDLE_LINK_CANDIDATE_LIMIT)
|
||
.fetch_all(&mut *connection)
|
||
.await?;
|
||
|
||
Ok(candidates
|
||
.iter()
|
||
.any(|content| huddle_started_content_links(content, ephemeral_channel_id)))
|
||
}
|
||
|
||
/// Return whether a creator-signed huddle-start event links a parent channel
|
||
/// to the requested ephemeral huddle channel — checked inside an open
|
||
/// transaction with a shared row lock on matching rows.
|
||
///
|
||
/// Uses `SELECT ... FOR SHARE` so any concurrent `soft_delete_event_and_update_thread()` that
|
||
/// attempts `UPDATE events SET deleted_at = NOW() WHERE ...` on the same row
|
||
/// must wait until this transaction commits or rolls back. This makes the
|
||
/// re-read authoritative against concurrent deletion — "visibility" alone
|
||
/// (i.e. a plain SELECT) is insufficient under READ COMMITTED because deletion
|
||
/// can commit between the SELECT and the join commit in the same transaction.
|
||
///
|
||
/// Uses `tx.as_mut()` so the lock participates in the caller's transaction.
|
||
/// A `false` return means the link was deleted or was never inserted, and the
|
||
/// caller should abort the surrounding transaction.
|
||
pub async fn huddle_started_link_exists_in_transaction(
|
||
tx: &mut AdmittedTx,
|
||
parent_channel_id: Uuid,
|
||
ephemeral_channel_id: Uuid,
|
||
creator_pubkey: &[u8],
|
||
) -> Result<bool> {
|
||
let community_id = tx.community();
|
||
let uuid_needle = format!("%{}%", ephemeral_channel_id);
|
||
let candidates: Vec<String> = sqlx::query_scalar(
|
||
r#"
|
||
SELECT content
|
||
FROM events
|
||
WHERE deleted_at IS NULL
|
||
AND community_id = $1
|
||
AND channel_id = $2
|
||
AND kind = $3
|
||
AND pubkey = $4
|
||
AND octet_length(content) <= $5
|
||
AND content ILIKE $6
|
||
ORDER BY created_at DESC, id ASC
|
||
LIMIT $7
|
||
FOR SHARE
|
||
"#,
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(parent_channel_id)
|
||
.bind(KIND_HUDDLE_STARTED as i32)
|
||
.bind(creator_pubkey)
|
||
.bind(HUDDLE_LINK_CONTENT_MAX_BYTES)
|
||
.bind(uuid_needle)
|
||
.bind(HUDDLE_LINK_CANDIDATE_LIMIT)
|
||
.fetch_all(tx.conn())
|
||
.await?;
|
||
|
||
Ok(candidates
|
||
.iter()
|
||
.any(|content| huddle_started_content_links(content, ephemeral_channel_id)))
|
||
}
|
||
|
||
/// Insert a Nostr event. Rejects AUTH and ephemeral kinds.
|
||
///
|
||
/// This raw-pool helper does not start application push production. Serving
|
||
/// callers should use [`Db::insert_event`] with a configured [`Db`].
|
||
///
|
||
/// Returns `(StoredEvent, was_inserted)` — `was_inserted` is `false` on duplicate.
|
||
pub async fn insert_event(
|
||
pool: &PgPool,
|
||
community_id: CommunityId,
|
||
event: &Event,
|
||
channel_id: Option<Uuid>,
|
||
) -> Result<(StoredEvent, bool)> {
|
||
let mut tx = crate::begin_community_event_write_transaction(
|
||
pool,
|
||
community_id,
|
||
crate::observability::WriterOperation::EventWrite,
|
||
)
|
||
.await?;
|
||
let result = insert_event_in_transaction(&mut tx, event, channel_id).await?;
|
||
tx.commit().await?;
|
||
Ok(result)
|
||
}
|
||
|
||
/// Insert a Nostr event in a caller-owned admitted transaction, scoped to the
|
||
/// transaction's community.
|
||
///
|
||
/// This is the transaction-composition seam for callers that must keep the
|
||
/// event insert open while performing related work. The caller owns commit or
|
||
/// rollback.
|
||
pub async fn insert_event_in_transaction(
|
||
tx: &mut AdmittedTx,
|
||
event: &Event,
|
||
channel_id: Option<Uuid>,
|
||
) -> Result<(StoredEvent, bool)> {
|
||
let result = insert_event_on(tx, event, channel_id).await?;
|
||
if result.1 {
|
||
crate::operator_listener::enqueue_mentions_in_transaction(tx, event).await?;
|
||
}
|
||
Ok(result)
|
||
}
|
||
|
||
async fn insert_event_on(
|
||
tx: &mut AdmittedTx,
|
||
event: &Event,
|
||
channel_id: Option<Uuid>,
|
||
) -> Result<(StoredEvent, bool)> {
|
||
let community_id = tx.community();
|
||
let kind_u16 = event.kind.as_u16();
|
||
let kind_u32 = u32::from(kind_u16);
|
||
|
||
if kind_u32 == KIND_AUTH {
|
||
return Err(DbError::AuthEventRejected);
|
||
}
|
||
if is_ephemeral(kind_u32) {
|
||
return Err(DbError::EphemeralEventRejected(kind_u16));
|
||
}
|
||
|
||
let id_bytes = event.id.as_bytes();
|
||
let pubkey_bytes = event.pubkey.to_bytes();
|
||
let sig_bytes = event.sig.serialize();
|
||
let tags_json = serde_json::to_value(&event.tags)?;
|
||
// Cast chain: nostr Kind (u16) → i32 (Postgres INT column). Safe: all Buzz kinds fit in i32.
|
||
let kind_i32 = event_kind_i32(event);
|
||
let created_at_secs = event.created_at.as_secs() as i64;
|
||
let created_at = DateTime::from_timestamp(created_at_secs, 0)
|
||
.ok_or(DbError::InvalidTimestamp(created_at_secs))?;
|
||
let received_at = Utc::now();
|
||
let d_tag = extract_d_tag(event);
|
||
let not_before = extract_not_before(event);
|
||
crate::channel_members::guard_channel_roster_event(tx, kind_i32, channel_id, &tags_json)
|
||
.await?;
|
||
let result = sqlx::query(
|
||
r#"
|
||
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(id_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())
|
||
.bind(not_before)
|
||
.execute(tx.conn())
|
||
.await?;
|
||
|
||
let was_inserted = result.rows_affected() > 0;
|
||
if was_inserted {
|
||
crate::store::event_follow_up::after_admitted_insert(
|
||
tx,
|
||
id_bytes.as_slice(),
|
||
kind_i32,
|
||
channel_id,
|
||
)
|
||
.await?;
|
||
}
|
||
|
||
Ok((
|
||
StoredEvent::with_received_at(event.clone(), received_at, channel_id, true),
|
||
was_inserted,
|
||
))
|
||
}
|
||
|
||
/// Query events with optional filters. Results ordered by `created_at DESC`.
|
||
///
|
||
/// Uses `QueryBuilder` for dynamic filter composition — avoids string concatenation
|
||
/// while keeping all user values in bind parameters.
|
||
pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result<Vec<StoredEvent>> {
|
||
query_events_with_operation(
|
||
pool,
|
||
q,
|
||
crate::observability::WriterOperation::SubscriptionHistory,
|
||
)
|
||
.await
|
||
}
|
||
|
||
pub(crate) async fn query_events_with_operation(
|
||
pool: &PgPool,
|
||
q: &EventQuery,
|
||
operation: crate::observability::WriterOperation,
|
||
) -> Result<Vec<StoredEvent>> {
|
||
if let Some(answer) = answer_without_db(q) {
|
||
return answer;
|
||
}
|
||
let mut conn = crate::observability::acquire_writer(pool, operation).await?;
|
||
query_events_on(&mut conn, q).await
|
||
}
|
||
|
||
/// [`query_events`] on a specific session — the replica-routing path runs
|
||
/// follow-up (aux) queries on the exact reader connection whose heartbeat
|
||
/// observation proved coverage for the page they annotate.
|
||
pub(crate) async fn query_events_on(
|
||
conn: &mut sqlx::PgConnection,
|
||
q: &EventQuery,
|
||
) -> Result<Vec<StoredEvent>> {
|
||
if let Some(answer) = answer_without_db(q) {
|
||
return answer;
|
||
}
|
||
|
||
let mut qb = build_query_events_sql(q);
|
||
let rows = fetch_with_read_deadline(conn, &mut qb, !has_tag_values(q)).await?;
|
||
|
||
let mut out = Vec::with_capacity(rows.len());
|
||
for row in rows {
|
||
if let Some(ev) = row_to_stored_event(row)? {
|
||
out.push(ev);
|
||
}
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// The result of `q` when its shape alone decides it: an invalid query, or one
|
||
/// that matches nothing. Every entry point checks this before it takes a
|
||
/// connection, so a match-nothing read (a NIP-01 `limit: 0` live REQ, say)
|
||
/// costs no pool acquire.
|
||
pub(crate) fn answer_without_db(q: &EventQuery) -> Option<Result<Vec<StoredEvent>>> {
|
||
// Composite cursor requires both halves.
|
||
if q.before_id.is_some() && q.until.is_none() {
|
||
return Some(Err(DbError::InvalidData(
|
||
"before_id requires until to be set".to_string(),
|
||
)));
|
||
}
|
||
|
||
// global_only and channel_id are mutually exclusive.
|
||
if q.global_only && q.channel_id.is_some() {
|
||
return Some(Err(DbError::InvalidData(
|
||
"global_only and channel_id are mutually exclusive".to_string(),
|
||
)));
|
||
}
|
||
|
||
// A zero page, or an empty list, means "match nothing".
|
||
let matches_nothing = page_limit(q) == 0
|
||
|| q.kinds.as_deref().is_some_and(|k| k.is_empty())
|
||
|| q.authors.as_deref().is_some_and(|a| a.is_empty())
|
||
|| q.ids.as_deref().is_some_and(|i| i.is_empty())
|
||
|| q.e_tags.as_deref().is_some_and(|e| e.is_empty());
|
||
matches_nothing.then(|| Ok(vec![]))
|
||
}
|
||
|
||
/// The `LIMIT` [`query_events_on`] applies: the requested page, clamped.
|
||
fn page_limit(q: &EventQuery) -> i64 {
|
||
q.limit
|
||
.unwrap_or(100)
|
||
.min(q.max_limit.unwrap_or(DEFAULT_MAX_PAGE_LIMIT))
|
||
}
|
||
|
||
/// SQL for [`query_events_on`], after [`answer_without_db`].
|
||
fn build_query_events_sql(q: &EventQuery) -> QueryBuilder<sqlx::Postgres> {
|
||
let limit_val = page_limit(q);
|
||
let offset_val = q.offset.unwrap_or(0);
|
||
|
||
// e-tag reads filter inside a MATERIALIZED CTE and sort/limit outside it.
|
||
// With `ORDER BY created_at LIMIT n` in the same scope, the planner
|
||
// overestimates matches and walks each partition's pkey backward, testing
|
||
// every community row against the id set; the fence keeps it on the GIN
|
||
// tags index.
|
||
let e_tag_fence = q.e_tags.is_some();
|
||
let mut qb: QueryBuilder<sqlx::Postgres> = if let Some(ref p_hex) = q.p_tag_hex {
|
||
// Join against event_mentions for #p-filtered queries (indexed).
|
||
let mut b = QueryBuilder::new(if e_tag_fence {
|
||
"WITH m AS MATERIALIZED ("
|
||
} else {
|
||
""
|
||
});
|
||
b.push(
|
||
"SELECT e.id, e.pubkey, e.created_at, e.kind, e.tags, e.content, \
|
||
e.sig, e.received_at, e.channel_id \
|
||
FROM events e \
|
||
INNER JOIN event_mentions m ON ",
|
||
);
|
||
push_mention_join(&mut b, q, p_hex);
|
||
b
|
||
} else {
|
||
let mut b = QueryBuilder::new(if e_tag_fence {
|
||
"WITH m AS MATERIALIZED ("
|
||
} else {
|
||
""
|
||
});
|
||
b.push(
|
||
"SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \
|
||
FROM events WHERE community_id = ",
|
||
);
|
||
b.push_bind(q.community_id.as_uuid());
|
||
b.push(" AND deleted_at IS NULL");
|
||
b
|
||
};
|
||
|
||
// Use unqualified column names when no join, qualified when joined.
|
||
let col_prefix = if q.p_tag_hex.is_some() { "e." } else { "" };
|
||
// Generic reads return only current artifact revisions, including delete
|
||
// tombstones; explicit revision IDs also read earlier revisions.
|
||
if q.ids.is_none() {
|
||
let table = if q.p_tag_hex.is_some() { "e" } else { "events" };
|
||
qb.push(format!(" AND NOT ({table}.kind = 45010 AND NOT EXISTS (SELECT 1 FROM artifact_heads ah WHERE ah.community_id={table}.community_id AND ah.event_id={table}.id))"));
|
||
}
|
||
push_notice_visibility(&mut qb, col_prefix, q);
|
||
|
||
if let Some(ch) = q.channel_id {
|
||
qb.push(format!(" AND {col_prefix}channel_id = "))
|
||
.push_bind(ch);
|
||
} else if q.global_only {
|
||
qb.push(format!(" AND {col_prefix}channel_id IS NULL"));
|
||
}
|
||
|
||
// Multi-channel IN pushdown. Access-scope queries retain global events;
|
||
// explicit multi-value #h filters do not.
|
||
//
|
||
// SECURITY: Some(empty vec) means "match no channels". Access-scope
|
||
// queries still retain globals; explicit #h queries match nothing.
|
||
if let Some(ref ch_ids) = q.channel_ids {
|
||
if ch_ids.is_empty() {
|
||
if q.channel_ids_include_global {
|
||
qb.push(format!(" AND {col_prefix}channel_id IS NULL"));
|
||
} else {
|
||
qb.push(" AND FALSE");
|
||
}
|
||
} else {
|
||
qb.push(" AND (");
|
||
if q.channel_ids_include_global {
|
||
qb.push(format!("{col_prefix}channel_id IS NULL OR "));
|
||
}
|
||
qb.push(format!("{col_prefix}channel_id IN ("));
|
||
let mut sep = qb.separated(", ");
|
||
for ch in ch_ids {
|
||
sep.push_bind(*ch);
|
||
}
|
||
qb.push("))");
|
||
}
|
||
}
|
||
|
||
if let Some(ks) = q.kinds.as_deref().filter(|k| !k.is_empty()) {
|
||
qb.push(format!(" AND {col_prefix}kind IN ("));
|
||
let mut sep = qb.separated(", ");
|
||
for k in ks {
|
||
sep.push_bind(*k);
|
||
}
|
||
qb.push(")");
|
||
}
|
||
|
||
if let Some(ref pk) = q.pubkey {
|
||
qb.push(format!(" AND {col_prefix}pubkey = "))
|
||
.push_bind(pk.clone());
|
||
}
|
||
|
||
// Multi-author IN pushdown (mutually exclusive with single pubkey in practice).
|
||
if let Some(ref authors) = q.authors {
|
||
if !authors.is_empty() {
|
||
qb.push(format!(" AND {col_prefix}pubkey IN ("));
|
||
let mut sep = qb.separated(", ");
|
||
for a in authors {
|
||
sep.push_bind(a.clone());
|
||
}
|
||
qb.push(")");
|
||
}
|
||
}
|
||
|
||
// Multi-id IN pushdown.
|
||
if let Some(ref ids) = q.ids {
|
||
if !ids.is_empty() {
|
||
qb.push(format!(" AND {col_prefix}id IN ("));
|
||
let mut sep = qb.separated(", ");
|
||
for id in ids {
|
||
sep.push_bind(id.clone());
|
||
}
|
||
qb.push(")");
|
||
}
|
||
}
|
||
|
||
// e-tag pushdown: exact positional match with a JSONB containment
|
||
// prefilter, tags @> ANY('[["e","<hex>"]]', ...). Served by idx_events_tags_gin
|
||
// (GIN, jsonb_path_ops — migrations/0004): the channel-window aux closure
|
||
// fans this out once per retained row, which made unindexed containment
|
||
// the dominant scroll-back cost (~1.7s/page on staging).
|
||
if let Some(ref e_tags) = q.e_tags {
|
||
if !e_tags.is_empty() {
|
||
qb.push(" AND ");
|
||
push_exact_tag_match(&mut qb, col_prefix, "e", e_tags);
|
||
}
|
||
}
|
||
|
||
if let Some(ref values) = q.d_tag_values {
|
||
push_artifact_d_tag_predicate(&mut qb, col_prefix, values);
|
||
}
|
||
|
||
if let Some((ref name, ref value)) = q.custom_tag {
|
||
qb.push(" AND ");
|
||
push_exact_tag_match(&mut qb, col_prefix, name, std::slice::from_ref(value));
|
||
}
|
||
|
||
push_tag_value_filters(&mut qb, col_prefix, &q.tag_values);
|
||
|
||
if let Some(s) = q.since {
|
||
qb.push(format!(" AND {col_prefix}created_at >= "))
|
||
.push_bind(s);
|
||
}
|
||
if let Some(u) = q.until {
|
||
if let Some(ref bid) = q.before_id {
|
||
// Composite keyset cursor for stable pagination.
|
||
// With ORDER BY created_at DESC, id ASC, "next page" means:
|
||
// created_at < cursor_ts OR (created_at = cursor_ts AND id > cursor_id)
|
||
qb.push(format!(" AND ({col_prefix}created_at < "));
|
||
qb.push_bind(u);
|
||
qb.push(format!(" OR ({col_prefix}created_at = "));
|
||
qb.push_bind(u);
|
||
qb.push(format!(" AND {col_prefix}id > "));
|
||
qb.push_bind(bid.clone());
|
||
qb.push("))");
|
||
} else {
|
||
qb.push(format!(" AND {col_prefix}created_at <= "))
|
||
.push_bind(u);
|
||
}
|
||
}
|
||
|
||
if let Some(ref d) = q.d_tag {
|
||
qb.push(format!(" AND {col_prefix}d_tag = "))
|
||
.push_bind(d.clone());
|
||
} else if let Some(ref ds) = q.d_tags {
|
||
if !ds.is_empty() {
|
||
qb.push(format!(" AND {col_prefix}d_tag IN ("));
|
||
let mut sep = qb.separated(", ");
|
||
for d in ds {
|
||
sep.push_bind(d.clone());
|
||
}
|
||
qb.push(")");
|
||
}
|
||
}
|
||
|
||
// Shared-gated visibility pushdown: exclude SHARED_GATED_KINDS events that
|
||
// are neither authored by the reader nor explicitly shared. Applied BEFORE
|
||
// ORDER/LIMIT so that a page of newer private events does not push visible
|
||
// shared ones off the end of the result set (the catalog query pattern).
|
||
//
|
||
// Clause: AND (kind NOT IN (30175, 30178) OR pubkey = $reader
|
||
// OR <exact ["shared","true",...] tag>)
|
||
//
|
||
// The shared match is positional, like the other tag pushdowns: plain
|
||
// containment would admit a private event carrying ["x","shared","true"],
|
||
// which then wastes a page slot before the post-filter drops it.
|
||
if let Some(ref reader_bytes) = q.shared_gated_reader {
|
||
qb.push(format!(" AND ({col_prefix}kind NOT IN ("));
|
||
let mut sep = qb.separated(", ");
|
||
for kind in SHARED_GATED_KINDS {
|
||
sep.push_bind(*kind as i32);
|
||
}
|
||
qb.push(format!(") OR {col_prefix}pubkey = "));
|
||
qb.push_bind(reader_bytes.clone());
|
||
qb.push(" OR ");
|
||
push_exact_tag_match(&mut qb, col_prefix, "shared", &["true".to_owned()]);
|
||
qb.push(")");
|
||
}
|
||
|
||
// Composite ordering for deterministic pagination across ALL callers of
|
||
// query_events (WebSocket REQ, REST endpoints, canvas, notes, etc.).
|
||
// The `id ASC` tiebreaker ensures stable results when events share the
|
||
// same second. No existing index covers this trailing column — Postgres
|
||
// sorts in memory, which is fine at current scale. If query performance
|
||
// degrades, add a composite index like `(pubkey, kind, created_at DESC, id ASC)`.
|
||
if e_tag_fence {
|
||
qb.push(") SELECT * FROM m ORDER BY created_at DESC, id ASC LIMIT ");
|
||
} else {
|
||
qb.push(format!(
|
||
" ORDER BY {col_prefix}created_at DESC, {col_prefix}id ASC LIMIT "
|
||
));
|
||
}
|
||
qb.push_bind(limit_val);
|
||
qb.push(" OFFSET ").push_bind(offset_val);
|
||
qb
|
||
}
|
||
|
||
/// `#p` reads join `event_mentions` by the full event key, including
|
||
/// `created_at`: mention rows copy the event's `created_at` at insert, so the
|
||
/// equality is exact and turns each joined row into one primary-key probe on
|
||
/// the right partition instead of a channel-index range scan.
|
||
///
|
||
/// `since`/`until` are repeated on the mention side so the outer scan of
|
||
/// `idx_event_mentions_pubkey_created` is a range, not every mention of the
|
||
/// pubkey ever. Without them a busy agent reconnecting with an old `since`
|
||
/// produced (all of its mentions) × (channel rows since `since`) work; on
|
||
/// bb-public one such REQ ran for over 45 minutes. `until` is a superset bound
|
||
/// for the keyset cursor, whose exact `(created_at, id)` test stays on `e`.
|
||
fn push_mention_join(b: &mut QueryBuilder<sqlx::Postgres>, q: &EventQuery, p_hex: &str) {
|
||
b.push(
|
||
"e.community_id = m.community_id AND e.id = m.event_id \
|
||
AND e.created_at = m.event_created_at \
|
||
WHERE e.community_id = ",
|
||
);
|
||
b.push_bind(q.community_id.as_uuid());
|
||
b.push(" AND m.community_id = ");
|
||
b.push_bind(q.community_id.as_uuid());
|
||
b.push(" AND e.deleted_at IS NULL AND m.pubkey_hex = ");
|
||
b.push_bind(p_hex.to_ascii_lowercase());
|
||
if let Some(s) = q.since {
|
||
b.push(" AND m.event_created_at >= ").push_bind(s);
|
||
}
|
||
if let Some(u) = q.until {
|
||
b.push(" AND m.event_created_at <= ").push_bind(u);
|
||
}
|
||
// Every mention insert copies the event's kind, so repeating the `kinds`
|
||
// filter on `m` drops no rows and lets the planner range-scan
|
||
// `idx_event_mentions_pubkey_kind_created`.
|
||
if let Some(ks) = q.kinds.as_deref().filter(|k| !k.is_empty()) {
|
||
b.push(" AND m.event_kind IN (");
|
||
let mut sep = b.separated(", ");
|
||
for k in ks {
|
||
sep.push_bind(*k);
|
||
}
|
||
b.push(")");
|
||
}
|
||
}
|
||
|
||
/// Server-side deadline for every generic event read ([`query_events_on`] and
|
||
/// [`count_events_on`]: REQ history, `/query`, `/count`, aux closures), kept
|
||
/// under the desktop's 30 s request timeout so a runaway query is cancelled
|
||
/// once no client can still be waiting for it. It only ever tightens the
|
||
/// effective `statement_timeout`: a shorter operator or caller cap is kept,
|
||
/// and a disabled (`0`) or longer one is lowered to 20 s. The limit is per
|
||
/// statement, not per request.
|
||
pub const READ_STATEMENT_TIMEOUT: &str = "20s";
|
||
|
||
/// Lower `statement_timeout` to `$1` unless it is already tighter; `$2`
|
||
/// selects transaction-local (`true`) or session (`false`) scope.
|
||
pub(crate) const TIGHTEN_STATEMENT_TIMEOUT_SQL: &str = "SELECT set_config('statement_timeout', \
|
||
CASE WHEN current_setting('statement_timeout')::interval = interval '0' \
|
||
OR current_setting('statement_timeout')::interval > $1::text::interval \
|
||
THEN $1::text ELSE current_setting('statement_timeout') END, $2)";
|
||
|
||
/// Run an event read under [`READ_STATEMENT_TIMEOUT`] without changing the
|
||
/// deadline for anything else on `conn`. `begin()` opens a transaction on an
|
||
/// autocommit writer connection or a savepoint inside the replica's read
|
||
/// transaction; rolling it back (the read has no writes to keep) reverts the
|
||
/// transaction-local `set_config` in both cases.
|
||
async fn fetch_with_read_deadline(
|
||
conn: &mut sqlx::PgConnection,
|
||
qb: &mut QueryBuilder<sqlx::Postgres>,
|
||
persistent: bool,
|
||
) -> Result<Vec<sqlx::postgres::PgRow>> {
|
||
let mut tx = sqlx::Connection::begin(&mut *conn).await?;
|
||
sqlx::query(TIGHTEN_STATEMENT_TIMEOUT_SQL)
|
||
.bind(READ_STATEMENT_TIMEOUT)
|
||
.bind(true)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
let rows = qb
|
||
.build()
|
||
.persistent(persistent)
|
||
.fetch_all(&mut *tx)
|
||
.await?;
|
||
tx.rollback().await?;
|
||
Ok(rows)
|
||
}
|
||
|
||
/// Match a tag whose first element is `name` and whose second is any of
|
||
/// `values` (NIP-01), as one parenthesized predicate.
|
||
///
|
||
/// JSONB containment alone ignores element position and multiplicity:
|
||
/// `[["t","t"]]` is contained in `["t","other"]`, and `[["e",id]]` in
|
||
/// `["x","e",id]`. Over-matching before `LIMIT` lets a non-matching newer row
|
||
/// take the slot, so containment stays only as the GIN-indexed prefilter
|
||
/// (one array-bound test, so planner cost does not scale with `values`) and
|
||
/// the positional check makes the predicate exact.
|
||
fn push_exact_tag_match(
|
||
qb: &mut QueryBuilder<sqlx::Postgres>,
|
||
col_prefix: &str,
|
||
name: &str,
|
||
values: &[String],
|
||
) {
|
||
let containments: Vec<serde_json::Value> = values
|
||
.iter()
|
||
.map(|value| serde_json::json!([[name, value]]))
|
||
.collect();
|
||
qb.push(format!("({col_prefix}tags @> ANY("))
|
||
.push_bind(containments)
|
||
.push(format!(
|
||
"::jsonb[]) AND EXISTS (SELECT 1 FROM jsonb_array_elements({col_prefix}tags) AS tag \
|
||
WHERE tag->>0 = "
|
||
))
|
||
.push_bind(name.to_owned())
|
||
.push(" AND tag->>1 = ANY(")
|
||
.push_bind(values.to_vec())
|
||
.push(")))");
|
||
}
|
||
|
||
/// Whether `q` carries a [`EventQuery::tag_values`] match. Such a query runs
|
||
/// as an unnamed statement, so Postgres plans it with the bound values. After
|
||
/// a few runs, a cached generic plan ignores the value and can scan every
|
||
/// group-state row in the community instead of using the `tags` GIN index.
|
||
fn has_tag_values(q: &EventQuery) -> bool {
|
||
q.tag_values.iter().any(|(_, values)| !values.is_empty())
|
||
}
|
||
|
||
/// AND one [`push_exact_tag_match`] per non-empty [`EventQuery::tag_values`]
|
||
/// entry, so entries are ANDed and values within an entry are ORed.
|
||
fn push_tag_value_filters(
|
||
qb: &mut QueryBuilder<sqlx::Postgres>,
|
||
col_prefix: &str,
|
||
tag_values: &[(String, Vec<String>)],
|
||
) {
|
||
for (name, values) in tag_values {
|
||
if values.is_empty() {
|
||
continue;
|
||
}
|
||
qb.push(" AND ");
|
||
push_exact_tag_match(qb, col_prefix, name, values);
|
||
}
|
||
}
|
||
|
||
/// Return only membership notices whose channel type the query may see
|
||
/// (NIP-CL), as [`buzz_core::filter::notice_visible`] does in `filters_match`:
|
||
/// `AND (kind NOT IN (44100, 44101) OR <type tag in visible types>
|
||
/// [OR <no type tag>])`. An untagged notice is a legacy type, so it is kept
|
||
/// only when a legacy type is visible. Pushed before `LIMIT` so hidden rows
|
||
/// cannot take page slots. Skipped when the query's kinds cannot match a
|
||
/// notice.
|
||
fn push_notice_visibility(qb: &mut QueryBuilder<sqlx::Postgres>, col_prefix: &str, q: &EventQuery) {
|
||
use buzz_core::channel::LEGACY_CHANNEL_TYPES;
|
||
use buzz_core::filter::{MEMBERSHIP_NOTICE_KINDS, NOTICE_CHANNEL_TYPE_TAG};
|
||
if let Some(kinds) = q.kinds.as_deref() {
|
||
if !kinds
|
||
.iter()
|
||
.any(|kind| MEMBERSHIP_NOTICE_KINDS.contains(&(*kind as u32)))
|
||
{
|
||
return;
|
||
}
|
||
}
|
||
let visible: Vec<String> = match &q.notice_channel_types {
|
||
Some(types) => types.clone(),
|
||
None => LEGACY_CHANNEL_TYPES
|
||
.iter()
|
||
.map(|t| (*t).to_owned())
|
||
.collect(),
|
||
};
|
||
let untagged_visible = visible
|
||
.iter()
|
||
.any(|t| LEGACY_CHANNEL_TYPES.contains(&t.as_str()));
|
||
let [added, removed] = MEMBERSHIP_NOTICE_KINDS;
|
||
qb.push(format!(
|
||
" AND ({col_prefix}kind NOT IN ({added}, {removed}) OR "
|
||
));
|
||
if visible.is_empty() {
|
||
qb.push("FALSE");
|
||
} else {
|
||
push_exact_tag_match(qb, col_prefix, NOTICE_CHANNEL_TYPE_TAG, &visible);
|
||
}
|
||
if untagged_visible {
|
||
qb.push(format!(
|
||
" OR NOT EXISTS (SELECT 1 FROM jsonb_array_elements({col_prefix}tags) AS tag \
|
||
WHERE tag->>0 = "
|
||
))
|
||
.push_bind(NOTICE_CHANNEL_TYPE_TAG.to_owned())
|
||
.push(")");
|
||
}
|
||
qb.push(")");
|
||
}
|
||
|
||
/// Match `#d` on artifact rows before `LIMIT` while leaving other kinds to the
|
||
/// caller's post-filter: `(kind NOT IN (artifact kinds) OR <exact d match>)`.
|
||
fn push_artifact_d_tag_predicate(
|
||
qb: &mut QueryBuilder<sqlx::Postgres>,
|
||
col_prefix: &str,
|
||
values: &[String],
|
||
) {
|
||
if values.is_empty() {
|
||
return;
|
||
}
|
||
let [revision, removal] = ARTIFACT_KINDS;
|
||
qb.push(format!(
|
||
" AND ({col_prefix}kind NOT IN ({revision}, {removal}) OR "
|
||
));
|
||
push_exact_tag_match(qb, col_prefix, "d", values);
|
||
qb.push(")");
|
||
}
|
||
|
||
pub(crate) fn row_to_stored_event(row: sqlx::postgres::PgRow) -> Result<Option<StoredEvent>> {
|
||
let id_bytes: Vec<u8> = row.try_get("id")?;
|
||
let pubkey_bytes: Vec<u8> = row.try_get("pubkey")?;
|
||
let created_at: DateTime<Utc> = row.try_get("created_at")?;
|
||
let kind_i32: i32 = row.try_get("kind")?;
|
||
let tags_json: serde_json::Value = row.try_get("tags")?;
|
||
let content: String = row.try_get("content")?;
|
||
let sig_bytes: Vec<u8> = row.try_get("sig")?;
|
||
let received_at: DateTime<Utc> = row.try_get("received_at")?;
|
||
|
||
let channel_id: Option<Uuid> = row.try_get("channel_id")?;
|
||
|
||
// kind is stored as i32 (Postgres INT) but Nostr uses u16. Values > 65535 are corrupt.
|
||
let kind_u16 = u16::try_from(kind_i32)
|
||
.map_err(|_| DbError::InvalidData(format!("kind out of u16 range: {kind_i32}")))?;
|
||
|
||
let event_json = serde_json::json!({
|
||
"id": hex::encode(&id_bytes),
|
||
"pubkey": hex::encode(&pubkey_bytes),
|
||
"created_at": created_at.timestamp(),
|
||
"kind": kind_u16,
|
||
"tags": tags_json,
|
||
"content": content,
|
||
"sig": hex::encode(&sig_bytes),
|
||
});
|
||
|
||
// Avoid the Value → String → parse round-trip: deserialize directly from the Value.
|
||
let event: nostr::Event = match serde_json::from_value(event_json) {
|
||
Ok(e) => e,
|
||
Err(e) => {
|
||
tracing::warn!("failed to reconstruct event from DB row: {e}");
|
||
return Ok(None);
|
||
}
|
||
};
|
||
|
||
Ok(Some(StoredEvent::with_received_at(
|
||
event,
|
||
received_at,
|
||
channel_id,
|
||
true,
|
||
)))
|
||
}
|
||
|
||
/// Count events matching the given query parameters (NIP-45 COUNT support).
|
||
///
|
||
/// Uses the same filter logic as `query_events` but returns only the count.
|
||
pub async fn count_events(pool: &PgPool, q: &EventQuery) -> Result<i64> {
|
||
let mut conn = crate::observability::acquire_writer(
|
||
pool,
|
||
crate::observability::WriterOperation::SubscriptionHistory,
|
||
)
|
||
.await?;
|
||
count_events_on(&mut conn, q).await
|
||
}
|
||
|
||
/// [`count_events`] on a specific session — the replica-routing path runs
|
||
/// the count on the exact reader connection whose heartbeat observation
|
||
/// proved its predicate.
|
||
pub(crate) async fn count_events_on(conn: &mut sqlx::PgConnection, q: &EventQuery) -> Result<i64> {
|
||
// Empty list means "match nothing" — return 0 immediately.
|
||
if q.kinds.as_deref().is_some_and(|k| k.is_empty()) {
|
||
return Ok(0);
|
||
}
|
||
if q.authors.as_deref().is_some_and(|a| a.is_empty()) {
|
||
return Ok(0);
|
||
}
|
||
if q.ids.as_deref().is_some_and(|i| i.is_empty()) {
|
||
return Ok(0);
|
||
}
|
||
if q.e_tags.as_deref().is_some_and(|e| e.is_empty()) {
|
||
return Ok(0);
|
||
}
|
||
|
||
let mut qb = count_events_sql(q);
|
||
// Same transaction-local deadline as `query_events_on`, so a COUNT cannot
|
||
// stall indefinitely either.
|
||
let row = fetch_with_read_deadline(conn, &mut qb, !has_tag_values(q))
|
||
.await?
|
||
.into_iter()
|
||
.next()
|
||
.ok_or_else(|| DbError::InvalidData("COUNT returned no rows".to_string()))?;
|
||
let cnt: i64 = row.try_get("cnt")?;
|
||
|
||
Ok(cnt)
|
||
}
|
||
|
||
/// SQL for [`count_events_on`], after its match-nothing early returns.
|
||
fn count_events_sql(q: &EventQuery) -> QueryBuilder<sqlx::Postgres> {
|
||
let mut qb: QueryBuilder<sqlx::Postgres> = if let Some(ref p_hex) = q.p_tag_hex {
|
||
let mut b = QueryBuilder::new(
|
||
"SELECT COUNT(*) as cnt FROM events e \
|
||
INNER JOIN event_mentions m ON ",
|
||
);
|
||
push_mention_join(&mut b, q, p_hex);
|
||
b
|
||
} else {
|
||
let mut b = QueryBuilder::new("SELECT COUNT(*) as cnt FROM events WHERE community_id = ");
|
||
b.push_bind(q.community_id.as_uuid());
|
||
b.push(" AND deleted_at IS NULL");
|
||
b
|
||
};
|
||
|
||
let col_prefix = if q.p_tag_hex.is_some() { "e." } else { "" };
|
||
// Generic reads return only current artifact revisions, including delete
|
||
// tombstones; explicit revision IDs also read earlier revisions.
|
||
if q.ids.is_none() {
|
||
let table = if q.p_tag_hex.is_some() { "e" } else { "events" };
|
||
qb.push(format!(" AND NOT ({table}.kind = 45010 AND NOT EXISTS (SELECT 1 FROM artifact_heads ah WHERE ah.community_id={table}.community_id AND ah.event_id={table}.id))"));
|
||
}
|
||
push_notice_visibility(&mut qb, col_prefix, q);
|
||
|
||
if let Some(ch) = q.channel_id {
|
||
qb.push(format!(" AND {col_prefix}channel_id = "))
|
||
.push_bind(ch);
|
||
} else if q.global_only {
|
||
qb.push(format!(" AND {col_prefix}channel_id IS NULL"));
|
||
}
|
||
|
||
// Multi-channel IN pushdown for COUNT. Access-scope queries retain global
|
||
// events; explicit multi-value #h filters do not.
|
||
if let Some(ref ch_ids) = q.channel_ids {
|
||
if ch_ids.is_empty() {
|
||
if q.channel_ids_include_global {
|
||
qb.push(format!(" AND {col_prefix}channel_id IS NULL"));
|
||
} else {
|
||
qb.push(" AND FALSE");
|
||
}
|
||
} else {
|
||
qb.push(" AND (");
|
||
if q.channel_ids_include_global {
|
||
qb.push(format!("{col_prefix}channel_id IS NULL OR "));
|
||
}
|
||
qb.push(format!("{col_prefix}channel_id IN ("));
|
||
let mut sep = qb.separated(", ");
|
||
for ch in ch_ids {
|
||
sep.push_bind(*ch);
|
||
}
|
||
qb.push("))");
|
||
}
|
||
}
|
||
|
||
if let Some(ks) = q.kinds.as_deref().filter(|k| !k.is_empty()) {
|
||
qb.push(format!(" AND {col_prefix}kind IN ("));
|
||
let mut sep = qb.separated(", ");
|
||
for k in ks {
|
||
sep.push_bind(*k);
|
||
}
|
||
qb.push(")");
|
||
}
|
||
|
||
if let Some(ref pk) = q.pubkey {
|
||
qb.push(format!(" AND {col_prefix}pubkey = "))
|
||
.push_bind(pk.clone());
|
||
}
|
||
|
||
if let Some(ref authors) = q.authors {
|
||
if !authors.is_empty() {
|
||
qb.push(format!(" AND {col_prefix}pubkey IN ("));
|
||
let mut sep = qb.separated(", ");
|
||
for a in authors {
|
||
sep.push_bind(a.clone());
|
||
}
|
||
qb.push(")");
|
||
}
|
||
}
|
||
|
||
if let Some(ref ids) = q.ids {
|
||
if !ids.is_empty() {
|
||
qb.push(format!(" AND {col_prefix}id IN ("));
|
||
let mut sep = qb.separated(", ");
|
||
for id in ids {
|
||
sep.push_bind(id.clone());
|
||
}
|
||
qb.push(")");
|
||
}
|
||
}
|
||
|
||
if let Some(ref e_tags) = q.e_tags {
|
||
if !e_tags.is_empty() {
|
||
qb.push(" AND ");
|
||
push_exact_tag_match(&mut qb, col_prefix, "e", e_tags);
|
||
}
|
||
}
|
||
|
||
if let Some(ref values) = q.d_tag_values {
|
||
push_artifact_d_tag_predicate(&mut qb, col_prefix, values);
|
||
}
|
||
|
||
if let Some((ref name, ref value)) = q.custom_tag {
|
||
qb.push(" AND ");
|
||
push_exact_tag_match(&mut qb, col_prefix, name, std::slice::from_ref(value));
|
||
}
|
||
|
||
push_tag_value_filters(&mut qb, col_prefix, &q.tag_values);
|
||
|
||
if let Some(s) = q.since {
|
||
qb.push(format!(" AND {col_prefix}created_at >= "))
|
||
.push_bind(s);
|
||
}
|
||
if let Some(u) = q.until {
|
||
qb.push(format!(" AND {col_prefix}created_at <= "))
|
||
.push_bind(u);
|
||
}
|
||
|
||
if let Some(ref d) = q.d_tag {
|
||
qb.push(format!(" AND {col_prefix}d_tag = "))
|
||
.push_bind(d.clone());
|
||
} else if let Some(ref ds) = q.d_tags {
|
||
if !ds.is_empty() {
|
||
qb.push(format!(" AND {col_prefix}d_tag IN ("));
|
||
let mut sep = qb.separated(", ");
|
||
for d in ds {
|
||
sep.push_bind(d.clone());
|
||
}
|
||
qb.push(")");
|
||
}
|
||
}
|
||
|
||
qb
|
||
}
|
||
|
||
/// SQL predicate over an `events` row for coordinates that carry no
|
||
/// historical value: NIP-RS read state and Buzz mesh heartbeats. Their
|
||
/// deletion is physical, including the mention index, so soft-deleted payloads
|
||
/// never accumulate. This is exactly the classification used by the migration
|
||
/// 0011 / 0019 purge triggers this path replaces.
|
||
macro_rules! retention_free_event_predicate {
|
||
() => {
|
||
"((kind = 30078 \
|
||
AND d_tag ~ '^read-state:[0-9a-f]{32}$' \
|
||
AND (SELECT count(*) \
|
||
FROM jsonb_array_elements(CASE WHEN jsonb_typeof(tags) = 'array' THEN tags ELSE '[]'::jsonb END) tag \
|
||
WHERE jsonb_typeof(tag) = 'array' AND tag->0 = '\"d\"'::jsonb) = 1 \
|
||
AND EXISTS (SELECT 1 \
|
||
FROM jsonb_array_elements(CASE WHEN jsonb_typeof(tags) = 'array' THEN tags ELSE '[]'::jsonb END) tag \
|
||
WHERE jsonb_typeof(tag) = 'array' AND jsonb_array_length(tag) >= 2 \
|
||
AND jsonb_typeof(tag->1) = 'string' AND tag->>0 = 'd' AND tag->>1 = d_tag) \
|
||
AND (SELECT count(*) \
|
||
FROM jsonb_array_elements(CASE WHEN jsonb_typeof(tags) = 'array' THEN tags ELSE '[]'::jsonb END) tag \
|
||
WHERE tag = '[\"t\", \"read-state\"]'::jsonb) = 1) \
|
||
OR (kind = 30003 \
|
||
AND d_tag LIKE 'buzz-mesh-member-status:%' \
|
||
AND tags @> '[[\"k\", \"buzz-mesh-status\"]]'::jsonb))"
|
||
};
|
||
}
|
||
|
||
/// Kinds that can hold a retention-free coordinate. Cheap Rust gate so
|
||
/// ordinary deletions skip the purge statement entirely.
|
||
fn may_be_retention_free(kind: i32) -> bool {
|
||
kind == 30078 || kind == 30003
|
||
}
|
||
|
||
/// Soft-delete the live row for an addressable coordinate
|
||
/// `(kind, pubkey, d_tag)` — the NIP-33 replacement key — provided it is not
|
||
/// newer than the deletion request.
|
||
///
|
||
/// Used by `handle_a_tag_deletion` to honour NIP-09 a-tag deletions for any
|
||
/// parameterized-replaceable kind. The WHERE clause mirrors
|
||
/// `replace_parameterized_event` so the coordinate semantics stay consistent:
|
||
/// `channel_id` is intentionally NOT in the key (NIP-33 replacement is global
|
||
/// per the spec — `channel_id` is stored for query scoping, not identity).
|
||
///
|
||
/// `deletion_created_at_secs` is the deletion event's own `created_at`. NIP-09
|
||
/// scopes an `a`-tag deletion to versions at or before that instant, so a
|
||
/// delayed or replayed tombstone signed between two versions must not erase the
|
||
/// newer replacement. `events.created_at` is immutable per row, so the predicate
|
||
/// guarantees a tombstone can never erase a version newer than itself — the UPDATE
|
||
/// re-evaluates its WHERE clause after any lock wait, so a replacement that races
|
||
/// the deletion and lands with a later `created_at` is always spared.
|
||
///
|
||
/// This does NOT guarantee deletion completeness when a same-coordinate
|
||
/// replacement races the deletion: the deletion may evaluate its predicate before
|
||
/// the replacement arrives, miss the incoming head, and return `Ok(false)`. That
|
||
/// outcome is state-identical to the deletion having arrived first (old head
|
||
/// gone, new head present), which is a valid Nostr ordering — Nostr never fixes
|
||
/// the order of concurrent writes from different signers, and even same-signer
|
||
/// ordering is advisory. The return value feeds only a debug log, not a
|
||
/// correctness gate.
|
||
///
|
||
/// Returns `Ok(true)` if a row was deleted, `Ok(false)` if no live row matched
|
||
/// (already deleted, never existed, or strictly newer than the deletion).
|
||
pub async fn soft_delete_by_coordinate(
|
||
pool: &PgPool,
|
||
community_id: CommunityId,
|
||
kind: i32,
|
||
pubkey: &[u8],
|
||
d_tag: &str,
|
||
deletion_created_at_secs: i64,
|
||
) -> Result<bool> {
|
||
let deletion_created_at = DateTime::from_timestamp(deletion_created_at_secs, 0)
|
||
.ok_or(DbError::InvalidTimestamp(deletion_created_at_secs))?;
|
||
let mut tx = crate::begin_community_event_write_transaction(
|
||
pool,
|
||
community_id,
|
||
crate::observability::WriterOperation::EventWrite,
|
||
)
|
||
.await?;
|
||
let purged = if may_be_retention_free(kind) {
|
||
purge_retention_free_events(
|
||
&mut tx,
|
||
RetentionFreeTarget::Coordinate {
|
||
kind,
|
||
pubkey,
|
||
d_tag,
|
||
created_at_or_before: deletion_created_at,
|
||
},
|
||
)
|
||
.await?
|
||
} else {
|
||
0
|
||
};
|
||
// `AND NOT COALESCE(<predicate>, false)` holds the invariant in SQL rather than through
|
||
// statement order: under READ COMMITTED this UPDATE takes a fresh snapshot,
|
||
// so a retention-free head committed by a racing replacement after the
|
||
// purge ran is spared instead of soft-deleted (the "deletion arrived
|
||
// first" outcome documented above). COALESCE keeps a NULL predicate
|
||
// (e.g. NULL `d_tag`) on the ordinary soft-delete path, as the triggers do.
|
||
let result = sqlx::query(concat!(
|
||
"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 \
|
||
AND created_at <= $5 AND NOT COALESCE(",
|
||
retention_free_event_predicate!(),
|
||
", false)"
|
||
))
|
||
.bind(community_id.as_uuid())
|
||
.bind(kind)
|
||
.bind(pubkey)
|
||
.bind(d_tag)
|
||
.bind(deletion_created_at)
|
||
.execute(tx.conn())
|
||
.await?;
|
||
|
||
tx.commit().await?;
|
||
|
||
Ok(purged > 0 || result.rows_affected() > 0)
|
||
}
|
||
|
||
/// Purge live retention-free rows matching `$target`, plus their mention rows,
|
||
/// and return the number of events removed.
|
||
macro_rules! purge_retention_free_sql {
|
||
($target:literal) => {
|
||
concat!(
|
||
"WITH purged AS ( \
|
||
DELETE FROM events \
|
||
WHERE community_id = $1 AND ",
|
||
$target,
|
||
" AND deleted_at IS NULL AND ",
|
||
retention_free_event_predicate!(),
|
||
" RETURNING id \
|
||
), purged_mentions AS ( \
|
||
DELETE FROM event_mentions mention USING purged \
|
||
WHERE mention.community_id = $1 AND mention.event_id = purged.id \
|
||
) \
|
||
SELECT count(*) FROM purged"
|
||
)
|
||
};
|
||
}
|
||
|
||
/// Which live rows a deletion targets.
|
||
enum RetentionFreeTarget<'a> {
|
||
Id(&'a [u8]),
|
||
Coordinate {
|
||
kind: i32,
|
||
pubkey: &'a [u8],
|
||
d_tag: &'a str,
|
||
created_at_or_before: DateTime<Utc>,
|
||
},
|
||
}
|
||
|
||
/// Physically delete live retention-free rows matched by `target`, plus their
|
||
/// mention rows, in the caller's transaction. The generic soft delete that
|
||
/// follows excludes the same predicate, so a retention-free row is either
|
||
/// purged here or left live, never soft-deleted. Returns the number of events
|
||
/// removed.
|
||
async fn purge_retention_free_events(
|
||
tx: &mut AdmittedTx,
|
||
target: RetentionFreeTarget<'_>,
|
||
) -> Result<u64> {
|
||
let community_id = tx.community();
|
||
// Migration 0011 fences NIP-RS hard deletes behind a transaction-local
|
||
// opt-in. The fence and this opt-in are removed together once the
|
||
// migration-only triggers are dropped.
|
||
sqlx::query("SELECT set_config('buzz.nip_rs_hard_delete', 'on', true)")
|
||
.execute(tx.conn())
|
||
.await?;
|
||
|
||
let purged: i64 = match target {
|
||
RetentionFreeTarget::Id(event_id) => {
|
||
sqlx::query_scalar(purge_retention_free_sql!("id = $2"))
|
||
.bind(community_id.as_uuid())
|
||
.bind(event_id)
|
||
.fetch_one(tx.conn())
|
||
.await?
|
||
}
|
||
RetentionFreeTarget::Coordinate {
|
||
kind,
|
||
pubkey,
|
||
d_tag,
|
||
created_at_or_before,
|
||
} => {
|
||
sqlx::query_scalar(purge_retention_free_sql!(
|
||
"kind = $2 AND pubkey = $3 AND d_tag = $4 AND created_at <= $5"
|
||
))
|
||
.bind(community_id.as_uuid())
|
||
.bind(kind)
|
||
.bind(pubkey)
|
||
.bind(d_tag)
|
||
.bind(created_at_or_before)
|
||
.fetch_one(tx.conn())
|
||
.await?
|
||
}
|
||
};
|
||
|
||
Ok(purged as u64)
|
||
}
|
||
|
||
/// Atomically soft-delete an event and decrement thread reply counters.
|
||
///
|
||
/// Wraps the delete + counter update in a single transaction so a crash between
|
||
/// them cannot leave counters permanently inflated. Returns `Ok(true)` if the
|
||
/// event was deleted this call.
|
||
///
|
||
/// When the target event is a kind-40100 (canvas) event, this function derives
|
||
/// the target's `kind` and `channel_id` from the database inside the same
|
||
/// transaction and acquires the same `(community, kind, channel)` advisory lock
|
||
/// used by [`insert_canvas_head_checked`] before the UPDATE. This prevents a
|
||
/// concurrent tagged write from observing a head that is simultaneously being
|
||
/// removed. The serialization invariant is owned entirely by this function;
|
||
/// callers do not classify the target kind.
|
||
pub async fn soft_delete_event_and_update_thread(
|
||
pool: &PgPool,
|
||
community_id: CommunityId,
|
||
event_id: &[u8],
|
||
parent_event_id: Option<&[u8]>,
|
||
root_event_id: Option<&[u8]>,
|
||
) -> Result<bool> {
|
||
let mut tx = crate::begin_community_event_write_transaction(
|
||
pool,
|
||
community_id,
|
||
crate::observability::WriterOperation::EventWrite,
|
||
)
|
||
.await?;
|
||
let deleted = soft_delete_event_and_update_thread_in_tx(
|
||
&mut tx,
|
||
event_id,
|
||
parent_event_id,
|
||
root_event_id,
|
||
)
|
||
.await?;
|
||
tx.commit().await?;
|
||
Ok(deleted)
|
||
}
|
||
|
||
/// Transaction-scoped body of [`soft_delete_event_and_update_thread`].
|
||
///
|
||
/// Callers that must fence the delete with their own writes (e.g. the admin
|
||
/// action lease/marker) run this inside their transaction; the caller commits.
|
||
pub(crate) async fn soft_delete_event_and_update_thread_in_tx(
|
||
tx: &mut AdmittedTx,
|
||
event_id: &[u8],
|
||
parent_event_id: Option<&[u8]>,
|
||
root_event_id: Option<&[u8]>,
|
||
) -> Result<bool> {
|
||
let community_id = tx.community();
|
||
use crate::store::replaceable::event_replacement_lock_key;
|
||
|
||
// Derive the target event's kind and channel_id inside the transaction so
|
||
// that the serialization decision cannot be bypassed by any caller.
|
||
let target: Option<(i32, Option<Uuid>)> = sqlx::query_as(
|
||
"SELECT kind, channel_id FROM events \
|
||
WHERE community_id = $1 AND id = $2",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(event_id)
|
||
.fetch_optional(tx.conn())
|
||
.await?;
|
||
|
||
// Relay-signed move removals are the source channel's only replay record.
|
||
if target.is_some_and(|(kind, _)| kind == 45011) {
|
||
return Err(DbError::InvalidData(
|
||
"artifact removal markers cannot be deleted".into(),
|
||
));
|
||
}
|
||
|
||
if let Some((kind, Some(channel_id))) = target {
|
||
if kind == KIND_CANVAS as i32 {
|
||
let lock_key = event_replacement_lock_key(
|
||
community_id,
|
||
kind,
|
||
&[],
|
||
Some(channel_id.as_bytes().as_slice()),
|
||
);
|
||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||
.bind(lock_key)
|
||
.execute(tx.conn())
|
||
.await?;
|
||
}
|
||
}
|
||
|
||
let purged = if target.is_some_and(|(kind, _)| may_be_retention_free(kind)) {
|
||
purge_retention_free_events(tx, RetentionFreeTarget::Id(event_id)).await?
|
||
} else {
|
||
0
|
||
};
|
||
let result = sqlx::query(concat!(
|
||
"UPDATE events SET deleted_at = NOW() \
|
||
WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL AND NOT COALESCE(",
|
||
retention_free_event_predicate!(),
|
||
", false)"
|
||
))
|
||
.bind(community_id.as_uuid())
|
||
.bind(event_id)
|
||
.execute(tx.conn())
|
||
.await?;
|
||
|
||
let deleted = purged > 0 || result.rows_affected() > 0;
|
||
|
||
if deleted {
|
||
sqlx::query("UPDATE artifact_heads SET modified_at=clock_timestamp() WHERE community_id=$1 AND artifact_id=(SELECT artifact_id FROM artifact_revisions WHERE community_id=$1 AND event_id=$2) AND event_id=$2")
|
||
.bind(community_id.as_uuid()).bind(event_id).execute(tx.conn()).await?;
|
||
if let Some(pid) = parent_event_id {
|
||
sqlx::query(
|
||
"UPDATE thread_metadata \
|
||
SET reply_count = GREATEST(reply_count - 1, 0) \
|
||
WHERE community_id = $1 AND event_id = $2",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(pid)
|
||
.execute(tx.conn())
|
||
.await?;
|
||
|
||
if let Some(root_id) = root_event_id {
|
||
sqlx::query(
|
||
"UPDATE thread_metadata \
|
||
SET descendant_count = GREATEST(descendant_count - 1, 0) \
|
||
WHERE community_id = $1 AND event_id = $2",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(root_id)
|
||
.execute(tx.conn())
|
||
.await?;
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(deleted)
|
||
}
|
||
|
||
/// Returns the `created_at` timestamp of the most recent non-deleted event in a channel.
|
||
pub async fn get_last_message_at(
|
||
pool: &PgPool,
|
||
community_id: CommunityId,
|
||
channel_id: uuid::Uuid,
|
||
) -> Result<Option<DateTime<Utc>>> {
|
||
let mut connection = crate::observability::acquire_writer(
|
||
pool,
|
||
crate::observability::WriterOperation::SubscriptionHistory,
|
||
)
|
||
.await?;
|
||
let row = sqlx::query(
|
||
"SELECT created_at FROM events \
|
||
WHERE community_id = $1 AND channel_id = $2 AND deleted_at IS NULL \
|
||
ORDER BY created_at DESC LIMIT 1",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(channel_id)
|
||
.fetch_optional(&mut *connection)
|
||
.await?;
|
||
|
||
match row {
|
||
Some(r) => Ok(Some(r.try_get("created_at")?)),
|
||
None => Ok(None),
|
||
}
|
||
}
|
||
|
||
/// Bulk-fetch the most recent `created_at` for a set of channel IDs.
|
||
///
|
||
/// Returns a map of `channel_id → last_message_at`. Channels with no events are omitted.
|
||
/// Single query regardless of input size.
|
||
pub async fn get_last_message_at_bulk(
|
||
pool: &PgPool,
|
||
community_id: CommunityId,
|
||
channel_ids: &[uuid::Uuid],
|
||
) -> Result<std::collections::HashMap<uuid::Uuid, DateTime<Utc>>> {
|
||
if channel_ids.is_empty() {
|
||
return Ok(std::collections::HashMap::new());
|
||
}
|
||
let mut connection = crate::observability::acquire_writer(
|
||
pool,
|
||
crate::observability::WriterOperation::SubscriptionHistory,
|
||
)
|
||
.await?;
|
||
|
||
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(
|
||
"SELECT channel_id, MAX(created_at) as last_at FROM events \
|
||
WHERE community_id = ",
|
||
);
|
||
qb.push_bind(community_id.as_uuid());
|
||
qb.push(" AND deleted_at IS NULL AND channel_id IN (");
|
||
let mut sep = qb.separated(", ");
|
||
for id in channel_ids {
|
||
sep.push_bind(*id);
|
||
}
|
||
qb.push(") GROUP BY channel_id");
|
||
|
||
let rows = qb.build().fetch_all(&mut *connection).await?;
|
||
|
||
let mut map = std::collections::HashMap::with_capacity(rows.len());
|
||
for row in rows {
|
||
let id: Uuid = row.try_get("channel_id")?;
|
||
let last_at: DateTime<Utc> = row.try_get("last_at")?;
|
||
map.insert(id, last_at);
|
||
}
|
||
Ok(map)
|
||
}
|
||
|
||
/// Fetches a single non-deleted event by its raw 32-byte ID.
|
||
///
|
||
/// Returns `None` if the event does not exist or has been soft-deleted.
|
||
/// Use [`get_event_by_id_including_deleted`] when you need to inspect
|
||
/// tombstoned rows (e.g. audit, undelete).
|
||
pub async fn get_event_by_id(
|
||
pool: &PgPool,
|
||
community_id: CommunityId,
|
||
id_bytes: &[u8],
|
||
) -> Result<Option<StoredEvent>> {
|
||
get_event_by_id_with_operation(
|
||
pool,
|
||
community_id,
|
||
id_bytes,
|
||
crate::observability::WriterOperation::Authorization,
|
||
)
|
||
.await
|
||
}
|
||
|
||
pub(crate) async fn get_event_by_id_with_operation(
|
||
pool: &PgPool,
|
||
community_id: CommunityId,
|
||
id_bytes: &[u8],
|
||
operation: crate::observability::WriterOperation,
|
||
) -> Result<Option<StoredEvent>> {
|
||
let mut connection = crate::observability::acquire_writer(pool, operation).await?;
|
||
let row = sqlx::query(
|
||
"SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \
|
||
FROM events WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(id_bytes)
|
||
.fetch_optional(&mut *connection)
|
||
.await?;
|
||
|
||
match row {
|
||
Some(r) => row_to_stored_event(r),
|
||
None => Ok(None),
|
||
}
|
||
}
|
||
|
||
/// Fetches the latest global (non-channel, `channel_id IS NULL`) replaceable event
|
||
/// for a (kind, pubkey) pair.
|
||
///
|
||
/// Uses canonical NIP-16 ordering: `created_at DESC, id ASC LIMIT 1`.
|
||
/// This matches the write path's tie-breaking logic and handles historical
|
||
/// duplicate survivors where multiple live rows share the same timestamp.
|
||
pub async fn get_latest_global_replaceable(
|
||
pool: &PgPool,
|
||
community_id: CommunityId,
|
||
kind: i32,
|
||
pubkey_bytes: &[u8],
|
||
) -> Result<Option<StoredEvent>> {
|
||
let mut connection = crate::observability::acquire_writer(
|
||
pool,
|
||
crate::observability::WriterOperation::Authorization,
|
||
)
|
||
.await?;
|
||
let row = sqlx::query(
|
||
"SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \
|
||
FROM events \
|
||
WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND channel_id IS NULL AND deleted_at IS NULL \
|
||
ORDER BY created_at DESC, id ASC \
|
||
LIMIT 1",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(kind)
|
||
.bind(pubkey_bytes)
|
||
.fetch_optional(&mut *connection)
|
||
.await?;
|
||
|
||
match row {
|
||
Some(r) => row_to_stored_event(r),
|
||
None => Ok(None),
|
||
}
|
||
}
|
||
|
||
/// Fetches a single event by its raw 32-byte ID, **including soft-deleted rows**.
|
||
///
|
||
/// Most callers should use [`get_event_by_id`] instead. This variant is needed
|
||
/// when the caller must distinguish "never existed" from "was deleted" (e.g.
|
||
/// audit trails, compliance queries).
|
||
pub async fn get_event_by_id_including_deleted(
|
||
pool: &PgPool,
|
||
community_id: CommunityId,
|
||
id_bytes: &[u8],
|
||
) -> Result<Option<StoredEvent>> {
|
||
get_event_by_id_including_deleted_with_operation(
|
||
pool,
|
||
community_id,
|
||
id_bytes,
|
||
crate::observability::WriterOperation::Authorization,
|
||
)
|
||
.await
|
||
}
|
||
|
||
pub(crate) async fn get_event_by_id_including_deleted_with_operation(
|
||
pool: &PgPool,
|
||
community_id: CommunityId,
|
||
id_bytes: &[u8],
|
||
operation: crate::observability::WriterOperation,
|
||
) -> Result<Option<StoredEvent>> {
|
||
let mut connection = crate::observability::acquire_writer(pool, operation).await?;
|
||
let row = sqlx::query(
|
||
"SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \
|
||
FROM events WHERE community_id = $1 AND id = $2 ORDER BY created_at DESC LIMIT 1",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(id_bytes)
|
||
.fetch_optional(&mut *connection)
|
||
.await?;
|
||
|
||
match row {
|
||
Some(r) => row_to_stored_event(r),
|
||
None => Ok(None),
|
||
}
|
||
}
|
||
|
||
/// Batch-fetch non-deleted events by their raw 32-byte IDs.
|
||
///
|
||
/// Returns events in arbitrary order — callers reorder as needed.
|
||
/// Uses a single `WHERE id IN (...)` query regardless of input size.
|
||
pub async fn get_events_by_ids(
|
||
pool: &PgPool,
|
||
community_id: CommunityId,
|
||
ids: &[&[u8]],
|
||
) -> Result<Vec<StoredEvent>> {
|
||
get_events_by_ids_with_operation(
|
||
pool,
|
||
community_id,
|
||
ids,
|
||
crate::observability::WriterOperation::SubscriptionHistory,
|
||
)
|
||
.await
|
||
}
|
||
|
||
pub(crate) async fn get_events_by_ids_with_operation(
|
||
pool: &PgPool,
|
||
community_id: CommunityId,
|
||
ids: &[&[u8]],
|
||
operation: crate::observability::WriterOperation,
|
||
) -> Result<Vec<StoredEvent>> {
|
||
if ids.is_empty() {
|
||
return Ok(vec![]);
|
||
}
|
||
let mut conn = crate::observability::acquire_writer(pool, operation).await?;
|
||
get_events_by_ids_on(&mut conn, community_id, ids).await
|
||
}
|
||
|
||
/// [`get_events_by_ids`] on a specific session — the replica-routing path
|
||
/// runs the query on the exact reader connection whose heartbeat
|
||
/// observation proved its predicate.
|
||
pub(crate) async fn get_events_by_ids_on(
|
||
conn: &mut sqlx::PgConnection,
|
||
community_id: CommunityId,
|
||
ids: &[&[u8]],
|
||
) -> Result<Vec<StoredEvent>> {
|
||
if ids.is_empty() {
|
||
return Ok(vec![]);
|
||
}
|
||
debug_assert!(ids.len() <= 500, "batch fetch should be bounded by caller");
|
||
|
||
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(
|
||
"SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \
|
||
FROM events WHERE community_id = ",
|
||
);
|
||
qb.push_bind(community_id.as_uuid());
|
||
qb.push(" AND deleted_at IS NULL AND id IN (");
|
||
let mut sep = qb.separated(", ");
|
||
for id in ids {
|
||
sep.push_bind(id.to_vec());
|
||
}
|
||
qb.push(")");
|
||
|
||
let rows = qb.build().fetch_all(&mut *conn).await?;
|
||
|
||
let mut out = Vec::with_capacity(rows.len());
|
||
for row in rows {
|
||
if let Some(ev) = row_to_stored_event(row)? {
|
||
out.push(ev);
|
||
}
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// Parameters for [`insert_event_with_thread_metadata`].
|
||
#[derive(Debug)]
|
||
pub struct ThreadMetadataParams<'a> {
|
||
/// The Nostr event ID of this message.
|
||
pub event_id: &'a [u8],
|
||
/// When the event was created.
|
||
pub event_created_at: DateTime<Utc>,
|
||
/// The channel this event belongs to.
|
||
pub channel_id: Uuid,
|
||
/// Event ID of the direct parent, if this is a reply.
|
||
pub parent_event_id: Option<&'a [u8]>,
|
||
/// When the parent event was created.
|
||
pub parent_event_created_at: Option<DateTime<Utc>>,
|
||
/// Event ID of the thread root, if this is a nested reply.
|
||
pub root_event_id: Option<&'a [u8]>,
|
||
/// When the root event was created.
|
||
pub root_event_created_at: Option<DateTime<Utc>>,
|
||
/// Nesting depth (root = 0).
|
||
pub depth: i32,
|
||
/// Whether this reply is broadcast to the channel timeline.
|
||
pub broadcast: bool,
|
||
}
|
||
|
||
pub(crate) async fn insert_event_with_thread_metadata_tx(
|
||
tx: &mut AdmittedTx,
|
||
event: &Event,
|
||
channel_id: Option<Uuid>,
|
||
thread_meta: Option<ThreadMetadataParams<'_>>,
|
||
) -> Result<(StoredEvent, bool)> {
|
||
let community_id = tx.community();
|
||
let kind_u16 = event.kind.as_u16();
|
||
let kind_u32 = u32::from(kind_u16);
|
||
|
||
if kind_u32 == KIND_AUTH {
|
||
return Err(DbError::AuthEventRejected);
|
||
}
|
||
if is_ephemeral(kind_u32) {
|
||
return Err(DbError::EphemeralEventRejected(kind_u16));
|
||
}
|
||
|
||
let id_bytes = event.id.as_bytes();
|
||
let pubkey_bytes = event.pubkey.to_bytes();
|
||
let sig_bytes = event.sig.serialize();
|
||
let tags_json = serde_json::to_value(&event.tags)?;
|
||
let kind_i32 = event_kind_i32(event);
|
||
let created_at_secs = event.created_at.as_secs() as i64;
|
||
let created_at = DateTime::from_timestamp(created_at_secs, 0)
|
||
.ok_or(DbError::InvalidTimestamp(created_at_secs))?;
|
||
let received_at = Utc::now();
|
||
let d_tag = extract_d_tag(event);
|
||
let not_before = extract_not_before(event);
|
||
|
||
crate::channel_members::guard_channel_roster_event(tx, kind_i32, channel_id, &tags_json)
|
||
.await?;
|
||
|
||
let result = sqlx::query(
|
||
r#"
|
||
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(id_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())
|
||
.bind(not_before)
|
||
.execute(tx.conn())
|
||
.await?;
|
||
|
||
let was_inserted = result.rows_affected() > 0;
|
||
|
||
if was_inserted {
|
||
crate::store::event_follow_up::after_admitted_insert(
|
||
tx,
|
||
id_bytes.as_slice(),
|
||
kind_i32,
|
||
channel_id,
|
||
)
|
||
.await?;
|
||
|
||
if let Some(ref meta) = thread_meta {
|
||
let broadcast_val: bool = meta.broadcast;
|
||
|
||
let tm_result = sqlx::query(
|
||
r#"
|
||
INSERT INTO thread_metadata
|
||
(community_id, event_created_at, event_id, channel_id,
|
||
parent_event_id, parent_event_created_at,
|
||
root_event_id, root_event_created_at,
|
||
depth, broadcast)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||
ON CONFLICT DO NOTHING
|
||
"#,
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(meta.event_created_at)
|
||
.bind(meta.event_id)
|
||
.bind(meta.channel_id)
|
||
.bind(meta.parent_event_id)
|
||
.bind(meta.parent_event_created_at)
|
||
.bind(meta.root_event_id)
|
||
.bind(meta.root_event_created_at)
|
||
.bind(meta.depth)
|
||
.bind(broadcast_val)
|
||
.execute(tx.conn())
|
||
.await?;
|
||
|
||
// Only bump reply counts if the metadata row was actually inserted.
|
||
if tm_result.rows_affected() > 0 {
|
||
if let Some(pid) = meta.parent_event_id {
|
||
// Ensure the parent has a thread_metadata row so the UPDATE
|
||
// below has something to hit. Root (depth=0) messages don't
|
||
// get a row on first insert, so we create a stub here.
|
||
let parent_ts = meta
|
||
.parent_event_created_at
|
||
.unwrap_or(meta.event_created_at);
|
||
sqlx::query(
|
||
r#"
|
||
INSERT INTO thread_metadata
|
||
(community_id, event_created_at, event_id, channel_id,
|
||
parent_event_id, parent_event_created_at,
|
||
root_event_id, root_event_created_at,
|
||
depth, broadcast)
|
||
VALUES ($1, $2, $3, $4, NULL, NULL, NULL, NULL, 0, false)
|
||
ON CONFLICT DO NOTHING
|
||
"#,
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(parent_ts)
|
||
.bind(pid)
|
||
.bind(meta.channel_id)
|
||
.execute(tx.conn())
|
||
.await?;
|
||
|
||
// Ensure the root also has a row (may differ from parent for nested replies).
|
||
if let Some(root_id) = meta.root_event_id {
|
||
if root_id != pid {
|
||
let root_ts =
|
||
meta.root_event_created_at.unwrap_or(meta.event_created_at);
|
||
sqlx::query(
|
||
r#"
|
||
INSERT INTO thread_metadata
|
||
(community_id, event_created_at, event_id, channel_id,
|
||
parent_event_id, parent_event_created_at,
|
||
root_event_id, root_event_created_at,
|
||
depth, broadcast)
|
||
VALUES ($1, $2, $3, $4, NULL, NULL, NULL, NULL, 0, false)
|
||
ON CONFLICT DO NOTHING
|
||
"#,
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(root_ts)
|
||
.bind(root_id)
|
||
.bind(meta.channel_id)
|
||
.execute(tx.conn())
|
||
.await?;
|
||
}
|
||
}
|
||
|
||
sqlx::query(
|
||
r#"
|
||
UPDATE thread_metadata
|
||
SET reply_count = reply_count + 1, last_reply_at = NOW()
|
||
WHERE community_id = $1 AND event_id = $2
|
||
"#,
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(pid)
|
||
.execute(tx.conn())
|
||
.await?;
|
||
|
||
if let Some(root_id) = meta.root_event_id {
|
||
sqlx::query(
|
||
r#"
|
||
UPDATE thread_metadata
|
||
SET descendant_count = descendant_count + 1
|
||
WHERE community_id = $1 AND event_id = $2
|
||
"#,
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(root_id)
|
||
.execute(tx.conn())
|
||
.await?;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
crate::operator_listener::enqueue_mentions_in_transaction(tx, event).await?;
|
||
}
|
||
|
||
Ok((
|
||
StoredEvent::with_received_at(event.clone(), received_at, channel_id, true),
|
||
was_inserted,
|
||
))
|
||
}
|
||
|
||
pub(crate) async fn acquire_canvas_event_write_lock_if_needed(
|
||
tx: &mut AdmittedTx,
|
||
event: &Event,
|
||
channel_id: Option<Uuid>,
|
||
) -> Result<()> {
|
||
if event_kind_i32(event) != KIND_CANVAS as i32 {
|
||
return Ok(());
|
||
}
|
||
|
||
let Some(channel_id) = channel_id else {
|
||
return Ok(());
|
||
};
|
||
|
||
acquire_canvas_coordinate_lock(tx, channel_id).await
|
||
}
|
||
|
||
/// Take the per-`(community, canvas kind, channel)` advisory lock that
|
||
/// serializes canvas writes on one channel head, author excluded.
|
||
async fn acquire_canvas_coordinate_lock(tx: &mut AdmittedTx, channel_id: Uuid) -> Result<()> {
|
||
let community_id = tx.community();
|
||
use crate::store::replaceable::event_replacement_lock_key;
|
||
|
||
let lock_key = event_replacement_lock_key(
|
||
community_id,
|
||
KIND_CANVAS as i32,
|
||
&[],
|
||
Some(channel_id.as_bytes().as_slice()),
|
||
);
|
||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||
.bind(lock_key)
|
||
.execute(tx.conn())
|
||
.await?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Atomically insert an event and its optional thread metadata.
|
||
///
|
||
/// `insert_event` and `insert_thread_metadata` calls could leave reply counters
|
||
/// inconsistent if one succeeded and the other failed. Keep this as one
|
||
/// transaction so reply metadata and counters commit together with the event.
|
||
///
|
||
/// For kind-40100 (canvas) events with a `channel_id`, acquires the same
|
||
/// `(community, kind, channel)` advisory lock used by
|
||
/// [`insert_canvas_head_checked`] so that untagged unconditional canvas appends
|
||
/// serialize against concurrent tagged writes on the same coordinate. Untagged
|
||
/// writes remain unconditional — they never conflict — but must not race the
|
||
/// head read inside a concurrent tagged transaction.
|
||
///
|
||
/// Returns `(StoredEvent, was_inserted)`.
|
||
pub async fn insert_event_with_thread_metadata(
|
||
pool: &PgPool,
|
||
community_id: CommunityId,
|
||
event: &Event,
|
||
channel_id: Option<Uuid>,
|
||
thread_meta: Option<ThreadMetadataParams<'_>>,
|
||
) -> Result<(StoredEvent, bool)> {
|
||
let mut tx = crate::begin_community_event_write_transaction(
|
||
pool,
|
||
community_id,
|
||
crate::observability::WriterOperation::EventWrite,
|
||
)
|
||
.await?;
|
||
acquire_canvas_event_write_lock_if_needed(&mut tx, event, channel_id).await?;
|
||
let result =
|
||
insert_event_with_thread_metadata_tx(&mut tx, event, channel_id, thread_meta).await?;
|
||
tx.commit().await?;
|
||
Ok(result)
|
||
}
|
||
|
||
/// Outcome of a channel-head conditional canvas write.
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
pub enum ChannelHeadWriteStatus {
|
||
/// The event was appended as the new channel head.
|
||
Inserted,
|
||
/// The exact event already existed (idempotent replay of the current head).
|
||
Duplicate,
|
||
/// `ExpectedHead` was supplied but no live head exists for the channel.
|
||
RevisionMissing,
|
||
/// The live head id did not match `ExpectedHead`, or `ExpectNoHead` was
|
||
/// required but a head already exists.
|
||
RevisionMismatch,
|
||
/// Precondition matched but the candidate does not sort ahead of the head
|
||
/// under `created_at DESC, id ASC`; accepting it would not change the
|
||
/// visible canvas.
|
||
SupersedeFailed,
|
||
}
|
||
|
||
/// Optimistic-concurrency precondition for [`insert_canvas_head_checked`].
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
pub enum ChannelHeadPrecondition<'a> {
|
||
/// Require that no live head exists yet (first creation of the canvas).
|
||
ExpectNoHead,
|
||
/// Require the live head to match this validated 32-byte event ID.
|
||
ExpectedHead(&'a [u8]),
|
||
}
|
||
|
||
/// Returns `true` iff a candidate canvas event sorts strictly ahead of the
|
||
/// current head under `created_at DESC, id ASC`.
|
||
fn candidate_supersedes_head(
|
||
candidate: &Event,
|
||
candidate_id: &[u8; 32],
|
||
head_created_at: DateTime<Utc>,
|
||
head_id: &[u8],
|
||
) -> bool {
|
||
let candidate_secs = candidate.created_at.as_secs() as i64;
|
||
let head_secs = head_created_at.timestamp();
|
||
match candidate_secs.cmp(&head_secs) {
|
||
std::cmp::Ordering::Greater => true,
|
||
std::cmp::Ordering::Less => false,
|
||
std::cmp::Ordering::Equal => candidate_id.as_slice() < head_id,
|
||
}
|
||
}
|
||
|
||
/// Conditionally append a canvas event (kind 40100) as its channel's head
|
||
/// under an optimistic-concurrency precondition.
|
||
///
|
||
/// Any other kind is rejected with [`DbError::InvalidData`] before a
|
||
/// transaction opens: the head read and the coordinate lock below are only
|
||
/// meaningful for canvas, so the contract is enforced rather than assumed.
|
||
///
|
||
/// Acquires a per-`(community, kind, channel)` advisory lock (author excluded
|
||
/// so cross-author concurrent edits serialize on the same head), reads the head
|
||
/// under `created_at DESC, id ASC`, evaluates the precondition, and on success
|
||
/// inserts the event and its mentions in the same transaction. Failures are
|
||
/// pure reads — nothing is mutated.
|
||
///
|
||
/// A matching `ExpectedHead` precondition also requires the candidate to sort
|
||
/// strictly ahead of the head; if not, returns `SupersedeFailed`. Re-submitting
|
||
/// the byte-identical live head short-circuits to `Duplicate` without evaluating
|
||
/// the precondition (safe transport-retry semantics).
|
||
pub async fn insert_canvas_head_checked(
|
||
pool: &PgPool,
|
||
community_id: CommunityId,
|
||
event: &Event,
|
||
channel_id: Uuid,
|
||
precondition: ChannelHeadPrecondition<'_>,
|
||
) -> Result<(StoredEvent, ChannelHeadWriteStatus)> {
|
||
let kind_i32 = buzz_core::kind::event_kind_i32(event);
|
||
if kind_i32 != KIND_CANVAS as i32 {
|
||
return Err(DbError::InvalidData(format!(
|
||
"insert_canvas_head_checked requires kind {KIND_CANVAS}, got {kind_i32}"
|
||
)));
|
||
}
|
||
let received_at = Utc::now();
|
||
let incoming_id = event.id.as_bytes();
|
||
|
||
let mut tx = crate::begin_community_event_write_transaction(
|
||
pool,
|
||
community_id,
|
||
crate::observability::WriterOperation::EventWrite,
|
||
)
|
||
.await?;
|
||
acquire_canvas_coordinate_lock(&mut tx, channel_id).await?;
|
||
|
||
let head: Option<(Vec<u8>, DateTime<Utc>)> = sqlx::query_as(
|
||
"SELECT id, created_at FROM events \
|
||
WHERE community_id = $1 AND kind = $2 AND channel_id = $3 AND deleted_at IS NULL \
|
||
ORDER BY created_at DESC, id ASC LIMIT 1",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(kind_i32)
|
||
.bind(channel_id)
|
||
.fetch_optional(tx.conn())
|
||
.await?;
|
||
|
||
// Idempotent replay: the incoming event is already the live head.
|
||
if head
|
||
.as_ref()
|
||
.is_some_and(|(id, _)| id.as_slice() == incoming_id.as_slice())
|
||
{
|
||
tx.rollback().await?;
|
||
return Ok((
|
||
StoredEvent::with_received_at(event.clone(), received_at, Some(channel_id), true),
|
||
ChannelHeadWriteStatus::Duplicate,
|
||
));
|
||
}
|
||
|
||
let status = match (precondition, head.as_ref()) {
|
||
(ChannelHeadPrecondition::ExpectNoHead, None) => None,
|
||
(ChannelHeadPrecondition::ExpectNoHead, Some(_)) => {
|
||
Some(ChannelHeadWriteStatus::RevisionMismatch)
|
||
}
|
||
(ChannelHeadPrecondition::ExpectedHead(_), None) => {
|
||
Some(ChannelHeadWriteStatus::RevisionMissing)
|
||
}
|
||
(ChannelHeadPrecondition::ExpectedHead(expected), Some((id, head_created_at))) => {
|
||
if id.as_slice() != expected {
|
||
Some(ChannelHeadWriteStatus::RevisionMismatch)
|
||
} else if !candidate_supersedes_head(event, incoming_id, *head_created_at, id) {
|
||
Some(ChannelHeadWriteStatus::SupersedeFailed)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
};
|
||
if let Some(status) = status {
|
||
tx.rollback().await?;
|
||
return Ok((
|
||
StoredEvent::with_received_at(event.clone(), received_at, Some(channel_id), false),
|
||
status,
|
||
));
|
||
}
|
||
|
||
let (stored, was_inserted) =
|
||
insert_event_with_thread_metadata_tx(&mut tx, event, Some(channel_id), None).await?;
|
||
if !was_inserted {
|
||
// The primary-key row already exists. The idempotent-replay branch above
|
||
// already returned `Duplicate` for the case where the incoming event is
|
||
// the canonical live head. Reaching here means the row exists but is NOT
|
||
// the live head (e.g. soft-deleted). Because every kind-40100 mutator
|
||
// serializes on this advisory key, no concurrent writer can have changed
|
||
// the live head since our read above, so the truthful result is
|
||
// `RevisionMismatch` — a false `Duplicate` would acknowledge a write
|
||
// that did not become live.
|
||
tx.rollback().await?;
|
||
return Ok((stored, ChannelHeadWriteStatus::RevisionMismatch));
|
||
}
|
||
crate::insert_mentions_in_transaction(&mut tx, event, Some(channel_id)).await?;
|
||
tx.commit().await?;
|
||
|
||
Ok((stored, ChannelHeadWriteStatus::Inserted))
|
||
}
|
||
|
||
impl Db {
|
||
/// Inserts an event. Returns `(StoredEvent, was_inserted)` — `false` on duplicate.
|
||
#[datastore_span(name = "insert_event", system = "postgresql")]
|
||
pub async fn insert_event(
|
||
&self,
|
||
community_id: CommunityId,
|
||
event: &nostr::Event,
|
||
channel_id: Option<Uuid>,
|
||
) -> Result<(StoredEvent, bool)> {
|
||
let mut tx = crate::begin_community_event_write_transaction(
|
||
&self.pool,
|
||
community_id,
|
||
crate::observability::WriterOperation::EventWrite,
|
||
)
|
||
.await?;
|
||
tx.set_push_enqueue(self.push_enqueue.clone());
|
||
let result = crate::event::insert_event_in_transaction(&mut tx, event, channel_id).await?;
|
||
if result.1 {
|
||
crate::insert_mentions_in_transaction(&mut tx, event, channel_id).await?;
|
||
}
|
||
tx.commit().await?;
|
||
Ok(result)
|
||
}
|
||
|
||
/// Queries events matching the given filter parameters.
|
||
///
|
||
/// Always reads from the WRITER pool. If the result influences a write
|
||
/// or a permission decision, this is the method to call. Display-path
|
||
/// callers that tolerate bounded staleness should use
|
||
/// [`Db::query_events_routed`] instead — converting a caller is an
|
||
/// explicit, per-callsite decision, never a change to this method.
|
||
#[datastore_span(name = "query_events", system = "postgresql")]
|
||
pub async fn query_events(&self, q: &EventQuery) -> Result<Vec<StoredEvent>> {
|
||
crate::event::query_events_with_operation(
|
||
&self.pool,
|
||
q,
|
||
crate::observability::WriterOperation::Authorization,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Query authoritative event state that directly controls a durable event
|
||
/// mutation or its post-commit side effects.
|
||
#[datastore_span(name = "query_events_for_event_write", system = "postgresql")]
|
||
pub async fn query_events_for_event_write(&self, q: &EventQuery) -> Result<Vec<StoredEvent>> {
|
||
crate::event::query_events_with_operation(
|
||
&self.pool,
|
||
q,
|
||
crate::observability::WriterOperation::EventWrite,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Query authoritative event state for startup reconciliation.
|
||
#[datastore_span(name = "query_events_for_bootstrap", system = "postgresql")]
|
||
pub async fn query_events_for_bootstrap(&self, q: &EventQuery) -> Result<Vec<StoredEvent>> {
|
||
crate::event::query_events_with_operation(
|
||
&self.pool,
|
||
q,
|
||
crate::observability::WriterOperation::Bootstrap,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Query authoritative event state for background reconciliation or repair.
|
||
#[datastore_span(name = "query_events_for_maintenance", system = "postgresql")]
|
||
pub async fn query_events_for_maintenance(&self, q: &EventQuery) -> Result<Vec<StoredEvent>> {
|
||
crate::event::query_events_with_operation(
|
||
&self.pool,
|
||
q,
|
||
crate::observability::WriterOperation::Maintenance,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// [`Db::query_events`] with replica routing — the opt-in fast path for
|
||
/// display reads.
|
||
///
|
||
/// Rule of thumb: **if the result influences a write or a permission,
|
||
/// it reads from the writer** — do not convert such a caller to this
|
||
/// method. Every new caller must be added to the caller-classification
|
||
/// table in `PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md`.
|
||
///
|
||
/// Routing derives the strongest sound predicate from the query shape
|
||
/// ([`crate::RoutePredicate::for_query`]): a channel-pinned query with an
|
||
/// `until` upper bound may be served covered (provably complete below
|
||
/// the fence wall); anything else is bounded-staleness only. The whole
|
||
/// seam is gated on `BUZZ_REPLICA_READ_MAX_AGE_MS` (default off): when
|
||
/// unset, even covered-eligible queries stay on the writer, so merging
|
||
/// this seam is a true no-op until the budget is configured. Every
|
||
/// failure fails closed to the writer, except a statement-timeout cancel
|
||
/// (57014), which is surfaced rather than re-run.
|
||
#[datastore_span(name = "query_events_routed", system = "postgresql")]
|
||
pub async fn query_events_routed(
|
||
&self,
|
||
path: &'static str,
|
||
q: &EventQuery,
|
||
) -> Result<Vec<StoredEvent>> {
|
||
if let Some(answer) = crate::event::answer_without_db(q) {
|
||
return answer;
|
||
}
|
||
let predicate = crate::RoutePredicate::for_query(q, self.replica_read_max_age.is_some());
|
||
match self
|
||
.route_read(
|
||
path,
|
||
predicate,
|
||
crate::observability::ReaderOperation::SubscriptionHistory,
|
||
)
|
||
.await
|
||
{
|
||
crate::RouteDecision::Replica(mut tx, _entry, reason) => {
|
||
match crate::event::query_events_on(&mut tx, q).await {
|
||
Ok(events) => {
|
||
Self::record_route(path, "replica", reason);
|
||
Ok(events)
|
||
}
|
||
// Surface a cancel instead of re-running it on the writer; see
|
||
// `DbError::is_statement_cancelled` for the trade-off.
|
||
Err(e) if e.is_statement_cancelled() => {
|
||
Self::record_route(path, "replica", "statement_cancelled");
|
||
Err(e)
|
||
}
|
||
Err(e) => {
|
||
// Mid-query replica failure: fail closed to the
|
||
// writer rather than surfacing a routed error.
|
||
tracing::warn!(path, "replica read failed; re-running on writer: {e}");
|
||
Self::record_route(path, "writer", "replica_error");
|
||
crate::event::query_events_with_operation(
|
||
&self.pool,
|
||
q,
|
||
crate::observability::WriterOperation::SubscriptionHistory,
|
||
)
|
||
.await
|
||
}
|
||
}
|
||
}
|
||
crate::RouteDecision::Writer => {
|
||
crate::event::query_events_with_operation(
|
||
&self.pool,
|
||
q,
|
||
crate::observability::WriterOperation::SubscriptionHistory,
|
||
)
|
||
.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 [`crate::RoutePredicate::Covered`]). A
|
||
/// display page absorbs that per-row; a number derived from the rows
|
||
/// does not. Same classification-table requirement as
|
||
/// [`Db::query_events_routed`].
|
||
#[datastore_span(name = "query_events_routed_bounded", system = "postgresql")]
|
||
pub async fn query_events_routed_bounded(
|
||
&self,
|
||
path: &'static str,
|
||
q: &EventQuery,
|
||
) -> Result<Vec<StoredEvent>> {
|
||
if let Some(answer) = crate::event::answer_without_db(q) {
|
||
return answer;
|
||
}
|
||
match self
|
||
.route_read(
|
||
path,
|
||
crate::RoutePredicate::Bounded,
|
||
crate::observability::ReaderOperation::SubscriptionHistory,
|
||
)
|
||
.await
|
||
{
|
||
crate::RouteDecision::Replica(mut tx, _entry, reason) => {
|
||
match crate::event::query_events_on(&mut tx, q).await {
|
||
Ok(events) => {
|
||
Self::record_route(path, "replica", reason);
|
||
Ok(events)
|
||
}
|
||
// Surface a cancel instead of re-running it on the writer; see
|
||
// `DbError::is_statement_cancelled` for the trade-off.
|
||
Err(e) if e.is_statement_cancelled() => {
|
||
Self::record_route(path, "replica", "statement_cancelled");
|
||
Err(e)
|
||
}
|
||
Err(e) => {
|
||
tracing::warn!(path, "replica read failed; re-running on writer: {e}");
|
||
Self::record_route(path, "writer", "replica_error");
|
||
crate::event::query_events_with_operation(
|
||
&self.pool,
|
||
q,
|
||
crate::observability::WriterOperation::SubscriptionHistory,
|
||
)
|
||
.await
|
||
}
|
||
}
|
||
}
|
||
crate::RouteDecision::Writer => {
|
||
crate::event::query_events_with_operation(
|
||
&self.pool,
|
||
q,
|
||
crate::observability::WriterOperation::SubscriptionHistory,
|
||
)
|
||
.await
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Count events matching the given query (NIP-45 COUNT support).
|
||
///
|
||
/// Always reads from the WRITER pool — see [`Db::query_events`] for the
|
||
/// writer-vs-routed rule.
|
||
#[datastore_span(name = "count_events", system = "postgresql")]
|
||
pub async fn count_events(&self, q: &EventQuery) -> Result<i64> {
|
||
crate::event::count_events(&self.pool, q).await
|
||
}
|
||
|
||
/// [`Db::count_events`] with replica routing — same contract, rules,
|
||
/// and classification-table requirement as [`Db::query_events_routed`].
|
||
///
|
||
/// Counts route on the BOUNDED arm only, never covered: the covered
|
||
/// arm bounds insert-completeness but not deletion visibility (soft
|
||
/// deletes are UPDATEs outside the floor guard), and a count has no
|
||
/// downstream per-row re-filter to absorb extra rows — a silently
|
||
/// inflated number for up to `FENCE_STALENESS` is a different product
|
||
/// statement than a page briefly showing a deleted row. `Bounded` ties
|
||
/// the error to the accepted budget `B`.
|
||
#[datastore_span(name = "count_events_routed", system = "postgresql")]
|
||
pub async fn count_events_routed(&self, path: &'static str, q: &EventQuery) -> Result<i64> {
|
||
match self
|
||
.route_read(
|
||
path,
|
||
crate::RoutePredicate::Bounded,
|
||
crate::observability::ReaderOperation::SubscriptionHistory,
|
||
)
|
||
.await
|
||
{
|
||
crate::RouteDecision::Replica(mut tx, _entry, reason) => {
|
||
match crate::event::count_events_on(&mut tx, q).await {
|
||
Ok(count) => {
|
||
Self::record_route(path, "replica", reason);
|
||
Ok(count)
|
||
}
|
||
// Surface a cancel instead of re-running it on the writer; see
|
||
// `DbError::is_statement_cancelled` for the trade-off.
|
||
Err(e) if e.is_statement_cancelled() => {
|
||
Self::record_route(path, "replica", "statement_cancelled");
|
||
Err(e)
|
||
}
|
||
Err(e) => {
|
||
tracing::warn!(path, "replica count failed; re-running on writer: {e}");
|
||
Self::record_route(path, "writer", "replica_error");
|
||
crate::event::count_events(&self.pool, q).await
|
||
}
|
||
}
|
||
}
|
||
crate::RouteDecision::Writer => crate::event::count_events(&self.pool, q).await,
|
||
}
|
||
}
|
||
|
||
/// Resolve creator-signed huddle-start links for bounded parent/session sets.
|
||
#[datastore_span(name = "huddle_started_links", system = "postgresql")]
|
||
pub async fn huddle_started_links(
|
||
&self,
|
||
community_id: CommunityId,
|
||
parent_channel_ids: &[Uuid],
|
||
ephemeral_channel_ids: &[Uuid],
|
||
) -> Result<Vec<(Uuid, Uuid, Vec<u8>)>> {
|
||
crate::event::huddle_started_links(
|
||
&self.pool,
|
||
community_id,
|
||
parent_channel_ids,
|
||
ephemeral_channel_ids,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Return whether a creator-signed huddle-start event links a parent
|
||
/// channel to the requested ephemeral huddle channel.
|
||
#[datastore_span(name = "huddle_started_link_exists", system = "postgresql")]
|
||
pub async fn huddle_started_link_exists(
|
||
&self,
|
||
community_id: CommunityId,
|
||
parent_channel_id: Uuid,
|
||
ephemeral_channel_id: Uuid,
|
||
creator_pubkey: &[u8],
|
||
) -> Result<bool> {
|
||
crate::event::huddle_started_link_exists(
|
||
&self.pool,
|
||
community_id,
|
||
parent_channel_id,
|
||
ephemeral_channel_id,
|
||
creator_pubkey,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Validate a huddle link while admitting a huddle event for persistence.
|
||
#[datastore_span(
|
||
name = "huddle_started_link_exists_for_event_write",
|
||
system = "postgresql"
|
||
)]
|
||
pub async fn huddle_started_link_exists_for_event_write(
|
||
&self,
|
||
community_id: CommunityId,
|
||
parent_channel_id: Uuid,
|
||
ephemeral_channel_id: Uuid,
|
||
creator_pubkey: &[u8],
|
||
) -> Result<bool> {
|
||
crate::event::huddle_started_link_exists_with_operation(
|
||
&self.pool,
|
||
community_id,
|
||
parent_channel_id,
|
||
ephemeral_channel_id,
|
||
creator_pubkey,
|
||
crate::observability::WriterOperation::EventWrite,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Fetch the latest replaceable event for a (kind, pubkey) pair.
|
||
///
|
||
/// Uses canonical NIP-16 ordering: `created_at DESC, id ASC`.
|
||
/// This matches the write path in [`replace_addressable_event`] and handles
|
||
/// historical duplicate survivors correctly.
|
||
#[datastore_span(name = "get_latest_global_replaceable", system = "postgresql")]
|
||
pub async fn get_latest_global_replaceable(
|
||
&self,
|
||
community_id: CommunityId,
|
||
kind: i32,
|
||
pubkey_bytes: &[u8],
|
||
) -> Result<Option<StoredEvent>> {
|
||
crate::event::get_latest_global_replaceable(&self.pool, community_id, kind, pubkey_bytes)
|
||
.await
|
||
}
|
||
|
||
/// Fetches a single non-deleted event by its raw ID bytes.
|
||
///
|
||
/// Returns `None` if the event does not exist or has been soft-deleted.
|
||
#[datastore_span(name = "get_event_by_id", system = "postgresql")]
|
||
pub async fn get_event_by_id(
|
||
&self,
|
||
community_id: CommunityId,
|
||
id_bytes: &[u8],
|
||
) -> Result<Option<StoredEvent>> {
|
||
crate::event::get_event_by_id(&self.pool, community_id, id_bytes).await
|
||
}
|
||
|
||
/// Fetch an event as a prerequisite of an event write or durable
|
||
/// post-write side effect.
|
||
#[datastore_span(name = "get_event_by_id_for_event_write", system = "postgresql")]
|
||
pub async fn get_event_by_id_for_event_write(
|
||
&self,
|
||
community_id: CommunityId,
|
||
id_bytes: &[u8],
|
||
) -> Result<Option<StoredEvent>> {
|
||
crate::event::get_event_by_id_with_operation(
|
||
&self.pool,
|
||
community_id,
|
||
id_bytes,
|
||
crate::observability::WriterOperation::EventWrite,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Fetches a single event by its raw ID bytes, **including soft-deleted rows**.
|
||
#[datastore_span(name = "get_event_by_id_including_deleted", system = "postgresql")]
|
||
pub async fn get_event_by_id_including_deleted(
|
||
&self,
|
||
community_id: CommunityId,
|
||
id_bytes: &[u8],
|
||
) -> Result<Option<StoredEvent>> {
|
||
crate::event::get_event_by_id_including_deleted(&self.pool, community_id, id_bytes).await
|
||
}
|
||
|
||
/// Fetch an event including tombstones as a prerequisite of an event
|
||
/// write or durable post-write side effect.
|
||
#[datastore_span(
|
||
name = "get_event_by_id_including_deleted_for_event_write",
|
||
system = "postgresql"
|
||
)]
|
||
pub async fn get_event_by_id_including_deleted_for_event_write(
|
||
&self,
|
||
community_id: CommunityId,
|
||
id_bytes: &[u8],
|
||
) -> Result<Option<StoredEvent>> {
|
||
crate::event::get_event_by_id_including_deleted_with_operation(
|
||
&self.pool,
|
||
community_id,
|
||
id_bytes,
|
||
crate::observability::WriterOperation::EventWrite,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Soft-delete the live row for an addressable coordinate `(kind, pubkey, d_tag)`
|
||
/// when it is not newer than the deletion request.
|
||
/// Used by NIP-09 a-tag deletion for parameterized-replaceable kinds;
|
||
/// `deletion_created_at_secs` is the deletion event's `created_at`.
|
||
#[datastore_span(name = "soft_delete_by_coordinate", system = "postgresql")]
|
||
pub async fn soft_delete_by_coordinate(
|
||
&self,
|
||
community_id: CommunityId,
|
||
kind: i32,
|
||
pubkey: &[u8],
|
||
d_tag: &str,
|
||
deletion_created_at_secs: i64,
|
||
) -> Result<bool> {
|
||
crate::event::soft_delete_by_coordinate(
|
||
&self.pool,
|
||
community_id,
|
||
kind,
|
||
pubkey,
|
||
d_tag,
|
||
deletion_created_at_secs,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Atomically soft-delete an event and decrement thread reply counters.
|
||
#[datastore_span(name = "soft_delete_event_and_update_thread", system = "postgresql")]
|
||
pub async fn soft_delete_event_and_update_thread(
|
||
&self,
|
||
community_id: CommunityId,
|
||
event_id: &[u8],
|
||
parent_event_id: Option<&[u8]>,
|
||
root_event_id: Option<&[u8]>,
|
||
) -> Result<bool> {
|
||
crate::event::soft_delete_event_and_update_thread(
|
||
&self.pool,
|
||
community_id,
|
||
event_id,
|
||
parent_event_id,
|
||
root_event_id,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Returns the most recent `created_at` for a channel.
|
||
#[datastore_span(name = "get_last_message_at", system = "postgresql")]
|
||
pub async fn get_last_message_at(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_id: Uuid,
|
||
) -> Result<Option<DateTime<Utc>>> {
|
||
crate::event::get_last_message_at(&self.pool, community_id, channel_id).await
|
||
}
|
||
|
||
/// Bulk-fetch the most recent `created_at` for a set of channel IDs.
|
||
#[datastore_span(name = "get_last_message_at_bulk", system = "postgresql")]
|
||
pub async fn get_last_message_at_bulk(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_ids: &[Uuid],
|
||
) -> Result<std::collections::HashMap<Uuid, DateTime<Utc>>> {
|
||
crate::event::get_last_message_at_bulk(&self.pool, community_id, channel_ids).await
|
||
}
|
||
|
||
/// Batch-fetch non-deleted events by their raw IDs.
|
||
#[datastore_span(name = "get_events_by_ids", system = "postgresql")]
|
||
pub async fn get_events_by_ids(
|
||
&self,
|
||
community_id: CommunityId,
|
||
ids: &[&[u8]],
|
||
) -> Result<Vec<StoredEvent>> {
|
||
crate::event::get_events_by_ids_with_operation(
|
||
&self.pool,
|
||
community_id,
|
||
ids,
|
||
crate::observability::WriterOperation::Authorization,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// [`Db::get_events_by_ids`] with replica routing — same contract and
|
||
/// classification-table requirement as [`Db::query_events_routed`].
|
||
///
|
||
/// By-id fetches route on the BOUNDED arm only: an id list carries no
|
||
/// channel pin, so no fence floor can prove insert-completeness — the
|
||
/// covered arm is structurally unavailable. Used for FTS hit hydration,
|
||
/// where a missing row degrades to a skipped search hit downstream.
|
||
#[datastore_span(name = "get_events_by_ids_routed", system = "postgresql")]
|
||
pub async fn get_events_by_ids_routed(
|
||
&self,
|
||
path: &'static str,
|
||
community_id: CommunityId,
|
||
ids: &[&[u8]],
|
||
) -> Result<Vec<StoredEvent>> {
|
||
match self
|
||
.route_read(
|
||
path,
|
||
crate::RoutePredicate::Bounded,
|
||
crate::observability::ReaderOperation::SubscriptionHistory,
|
||
)
|
||
.await
|
||
{
|
||
crate::RouteDecision::Replica(mut tx, _entry, reason) => {
|
||
match crate::event::get_events_by_ids_on(&mut tx, community_id, ids).await {
|
||
Ok(events) => {
|
||
Self::record_route(path, "replica", reason);
|
||
Ok(events)
|
||
}
|
||
// Surface a cancel instead of re-running it on the writer; see
|
||
// `DbError::is_statement_cancelled` for the trade-off.
|
||
Err(e) if e.is_statement_cancelled() => {
|
||
Self::record_route(path, "replica", "statement_cancelled");
|
||
Err(e)
|
||
}
|
||
Err(e) => {
|
||
tracing::warn!(path, "replica read failed; re-running on writer: {e}");
|
||
Self::record_route(path, "writer", "replica_error");
|
||
crate::event::get_events_by_ids_with_operation(
|
||
&self.pool,
|
||
community_id,
|
||
ids,
|
||
crate::observability::WriterOperation::SubscriptionHistory,
|
||
)
|
||
.await
|
||
}
|
||
}
|
||
}
|
||
crate::RouteDecision::Writer => {
|
||
crate::event::get_events_by_ids_with_operation(
|
||
&self.pool,
|
||
community_id,
|
||
ids,
|
||
crate::observability::WriterOperation::SubscriptionHistory,
|
||
)
|
||
.await
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Atomically insert an event AND its thread metadata in a single transaction.
|
||
#[datastore_span(name = "insert_event_with_thread_metadata", system = "postgresql")]
|
||
pub async fn insert_event_with_thread_metadata(
|
||
&self,
|
||
community_id: CommunityId,
|
||
event: &nostr::Event,
|
||
channel_id: Option<Uuid>,
|
||
thread_meta: Option<crate::event::ThreadMetadataParams<'_>>,
|
||
) -> Result<(StoredEvent, bool)> {
|
||
let mut tx = crate::begin_community_event_write_transaction(
|
||
&self.pool,
|
||
community_id,
|
||
crate::observability::WriterOperation::EventWrite,
|
||
)
|
||
.await?;
|
||
tx.set_push_enqueue(self.push_enqueue.clone());
|
||
crate::event::acquire_canvas_event_write_lock_if_needed(&mut tx, event, channel_id).await?;
|
||
let result = crate::event::insert_event_with_thread_metadata_tx(
|
||
&mut tx,
|
||
event,
|
||
channel_id,
|
||
thread_meta,
|
||
)
|
||
.await?;
|
||
if result.1 {
|
||
crate::insert_mentions_in_transaction(&mut tx, event, channel_id).await?;
|
||
}
|
||
tx.commit().await?;
|
||
Ok(result)
|
||
}
|
||
|
||
/// Soft-delete NIP-29 discovery events for a channel created by a specific relay pubkey.
|
||
#[datastore_span(name = "soft_delete_discovery_events", system = "postgresql")]
|
||
pub async fn soft_delete_discovery_events(
|
||
&self,
|
||
community_id: CommunityId,
|
||
channel_id: Uuid,
|
||
relay_pubkey: &[u8],
|
||
) -> Result<u64> {
|
||
let mut tx = crate::begin_community_event_write_transaction(
|
||
&self.pool,
|
||
community_id,
|
||
crate::observability::WriterOperation::EventWrite,
|
||
)
|
||
.await?;
|
||
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(tx.conn())
|
||
.await?;
|
||
|
||
tx.commit().await?;
|
||
|
||
Ok(result.rows_affected())
|
||
}
|
||
|
||
/// Conditionally append a canvas write (kind 40100) under an optimistic-concurrency
|
||
/// precondition, rejecting any other kind. Delegates to
|
||
/// [`insert_canvas_head_checked`].
|
||
///
|
||
/// Always uses the writer pool — the precondition check and the insert must
|
||
/// be serialized on the writer to prevent TOCTOU races.
|
||
#[datastore_span(name = "insert_canvas_head_checked", system = "postgresql")]
|
||
pub async fn insert_canvas_head_checked(
|
||
&self,
|
||
community_id: CommunityId,
|
||
event: &nostr::Event,
|
||
channel_id: Uuid,
|
||
precondition: ChannelHeadPrecondition<'_>,
|
||
) -> Result<(StoredEvent, ChannelHeadWriteStatus)> {
|
||
insert_canvas_head_checked(&self.pool, community_id, event, channel_id, precondition).await
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod e_tag_filter_shape_tests {
|
||
use super::*;
|
||
|
||
/// The aux closure sends one e-tag per reply. The id set must be one
|
||
/// array-bound predicate (never an N-way `OR`), and it must be filtered
|
||
/// inside a MATERIALIZED CTE with ORDER BY/LIMIT outside: in the same
|
||
/// scope the planner flips to a backward pkey walk over the whole
|
||
/// community (the long-thread timeout).
|
||
#[test]
|
||
fn e_tag_query_filters_in_materialized_fence_before_order_limit() {
|
||
let mut q = EventQuery::for_community(CommunityId::from_uuid(uuid::Uuid::nil()));
|
||
q.kinds = Some(vec![7]);
|
||
q.e_tags = Some((0..188).map(|i| format!("{i:064x}")).collect());
|
||
let qb = build_query_events_sql(&q);
|
||
let sql = qb.sql().as_str().to_owned();
|
||
|
||
assert!(sql.starts_with("WITH m AS MATERIALIZED (SELECT "));
|
||
assert_eq!(sql.matches("tags @> ANY(").count(), 1);
|
||
assert!(!sql.contains(" OR "));
|
||
let fence_end = sql.find(") SELECT * FROM m ").expect("fence closes");
|
||
assert!(sql.contains("::jsonb[])"));
|
||
assert!(!sql[..fence_end].contains("ORDER BY"));
|
||
assert!(!sql[..fence_end].contains("LIMIT"));
|
||
assert!(sql[fence_end..].contains("ORDER BY created_at DESC, id ASC LIMIT "));
|
||
}
|
||
|
||
#[test]
|
||
fn non_e_tag_query_is_not_fenced() {
|
||
let q = EventQuery::for_community(CommunityId::from_uuid(uuid::Uuid::nil()));
|
||
let qb = build_query_events_sql(&q);
|
||
assert!(!qb.sql().as_str().contains("MATERIALIZED"));
|
||
}
|
||
|
||
/// `#p` reads bound the mention side (bb-public runaway REQ): the join
|
||
/// carries the event's `created_at`, and `since`/`until` also bound
|
||
/// `m.event_created_at`, for both the page SELECT and the COUNT.
|
||
#[test]
|
||
fn p_tag_query_bounds_mention_side_by_time() {
|
||
let mut q = EventQuery::for_community(CommunityId::from_uuid(uuid::Uuid::nil()));
|
||
q.p_tag_hex = Some("ab".repeat(32));
|
||
q.channel_id = Some(uuid::Uuid::nil());
|
||
q.since = Some(Utc::now());
|
||
q.until = Some(Utc::now());
|
||
q.kinds = Some(vec![9]);
|
||
let select = build_query_events_sql(&q).sql().as_str().to_owned();
|
||
let count = count_events_sql(&q).sql().as_str().to_owned();
|
||
for (label, sql) in [("select", select), ("count", count)] {
|
||
assert!(
|
||
sql.contains("AND e.created_at = m.event_created_at"),
|
||
"{label}: {sql}"
|
||
);
|
||
assert!(
|
||
sql.contains("AND m.event_created_at >= $"),
|
||
"{label}: {sql}"
|
||
);
|
||
assert!(
|
||
sql.contains("AND m.event_created_at <= $"),
|
||
"{label}: {sql}"
|
||
);
|
||
assert!(sql.contains("AND m.event_kind IN ($"), "{label}: {sql}");
|
||
}
|
||
|
||
q.since = None;
|
||
q.until = None;
|
||
q.kinds = None;
|
||
let unbounded = build_query_events_sql(&q).sql().as_str().to_owned();
|
||
assert!(!unbounded.contains("m.event_created_at >="));
|
||
assert!(!unbounded.contains("m.event_created_at <="));
|
||
assert!(!unbounded.contains("m.event_kind"));
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod postgres_tests {
|
||
use super::*;
|
||
use crate::test_support::ScratchDb;
|
||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||
|
||
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1
|
||
|
||
async fn setup_pool() -> PgPool {
|
||
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
|
||
.or_else(|_| std::env::var("DATABASE_URL"))
|
||
.unwrap_or_else(|_| TEST_DB_URL.to_owned());
|
||
|
||
PgPool::connect(&database_url)
|
||
.await
|
||
.expect("connect to test DB")
|
||
}
|
||
|
||
async fn make_test_community(pool: &PgPool) -> Uuid {
|
||
let id = Uuid::new_v4();
|
||
let host = format!("event-test-{}.example", id.simple());
|
||
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
|
||
.bind(id)
|
||
.bind(host)
|
||
.execute(pool)
|
||
.await
|
||
.expect("insert test community");
|
||
id
|
||
}
|
||
|
||
async fn make_test_channel(
|
||
pool: &PgPool,
|
||
community_id: Uuid,
|
||
ttl_seconds: Option<i32>,
|
||
) -> Uuid {
|
||
let id = Uuid::new_v4();
|
||
sqlx::query(
|
||
"INSERT INTO channels \
|
||
(id, community_id, name, created_by, ttl_seconds, ttl_deadline) \
|
||
VALUES ($1, $2, $3, $4, $5, \
|
||
CASE WHEN $5 IS NULL THEN NULL \
|
||
ELSE clock_timestamp() + make_interval(secs => $5) END)",
|
||
)
|
||
.bind(id)
|
||
.bind(community_id)
|
||
.bind(format!("event-ttl-test-{}", id.simple()))
|
||
.bind(vec![7_u8; 32])
|
||
.bind(ttl_seconds)
|
||
.execute(pool)
|
||
.await
|
||
.expect("insert test channel");
|
||
id
|
||
}
|
||
|
||
/// Statements on `conn` that Postgres holds as prepared, matching `like`.
|
||
async fn prepared_like(conn: &mut sqlx::PgConnection, like: &str) -> i64 {
|
||
sqlx::query_scalar("SELECT count(*) FROM pg_prepared_statements WHERE statement LIKE $1")
|
||
.bind(like)
|
||
.fetch_one(&mut *conn)
|
||
.await
|
||
.expect("read prepared statements")
|
||
}
|
||
|
||
/// A label or creator match must keep parameter-aware planning. A cached
|
||
/// generic plan ignores the label value and scans every group-state row in
|
||
/// the community instead of using the `tags` GIN index.
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn tag_value_queries_are_not_cached_as_prepared_statements() {
|
||
let pool = setup_pool().await;
|
||
let community = CommunityId::from_uuid(make_test_community(&pool).await);
|
||
let mut conn = pool.acquire().await.expect("acquire");
|
||
sqlx::query("DEALLOCATE ALL")
|
||
.execute(&mut *conn)
|
||
.await
|
||
.expect("clear prepared statements");
|
||
let mut q = EventQuery::for_community(community);
|
||
q.kinds = Some(vec![39000]);
|
||
q.tag_values = vec![("t".to_owned(), vec!["for-you".to_owned()])];
|
||
for _ in 0..6 {
|
||
query_events_on(&mut conn, &q).await.expect("label query");
|
||
count_events_on(&mut conn, &q).await.expect("label count");
|
||
}
|
||
assert_eq!(prepared_like(&mut conn, "%tags @> ANY(%").await, 0);
|
||
|
||
// Control: the same query without a tag match stays a cached statement,
|
||
// so the check above can see one.
|
||
q.tag_values.clear();
|
||
query_events_on(&mut conn, &q).await.expect("plain query");
|
||
assert!(prepared_like(&mut conn, "%FROM events%").await > 0);
|
||
}
|
||
|
||
/// Effective `statement_timeout` observed *inside* the read deadline, and
|
||
/// the value restored on `conn` afterwards.
|
||
async fn deadline_inside_and_after(conn: &mut sqlx::PgConnection) -> (String, String) {
|
||
let mut qb = QueryBuilder::new("SELECT current_setting('statement_timeout')");
|
||
let rows = fetch_with_read_deadline(conn, &mut qb, true)
|
||
.await
|
||
.expect("deadline read");
|
||
let inside: String = rows[0].get(0);
|
||
let after: String = sqlx::query_scalar("SHOW statement_timeout")
|
||
.fetch_one(&mut *conn)
|
||
.await
|
||
.expect("show timeout");
|
||
(inside, after)
|
||
}
|
||
|
||
/// Every pushed-down tag filter matches by position: a newer decoy whose
|
||
/// tag holds the wanted name and value in the wrong slots must not take a
|
||
/// `limit: 1` page or be counted.
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn pushed_down_tag_filters_reject_positional_decoys() {
|
||
let pool = setup_pool().await;
|
||
let community = CommunityId::from_uuid(make_test_community(&pool).await);
|
||
let keys = Keys::generate();
|
||
let now = nostr::Timestamp::now().as_secs();
|
||
let target = "ab".repeat(32);
|
||
let insert = |kind: u16, tag: Vec<&str>, at: u64| {
|
||
let event = EventBuilder::new(Kind::Custom(kind), "")
|
||
.tags([Tag::parse(tag).unwrap()])
|
||
.custom_created_at(nostr::Timestamp::from(at))
|
||
.sign_with_keys(&keys)
|
||
.unwrap();
|
||
let pool = pool.clone();
|
||
async move {
|
||
insert_event(&pool, community, &event, None).await.unwrap();
|
||
event.id.as_bytes().to_vec()
|
||
}
|
||
};
|
||
// Removals: revisions also need an `artifact_heads` row to be read.
|
||
let [_, artifact_kind] = ARTIFACT_KINDS;
|
||
let artifact_kind = artifact_kind as u16;
|
||
|
||
struct Case<'a> {
|
||
label: &'a str,
|
||
kind: u16,
|
||
real: Vec<&'a str>,
|
||
/// Inserted after `real`, so it wins a `limit: 1` read if matched.
|
||
decoy: Vec<&'a str>,
|
||
query: EventQuery,
|
||
}
|
||
let cases = [
|
||
Case {
|
||
label: "custom tag value in name slot",
|
||
kind: 1,
|
||
real: vec!["t", "t"],
|
||
decoy: vec!["t", "other"],
|
||
query: {
|
||
let mut q = EventQuery::for_community(community);
|
||
q.custom_tag = Some(("t".into(), "t".into()));
|
||
q
|
||
},
|
||
},
|
||
Case {
|
||
label: "custom tag shifted right",
|
||
kind: 1,
|
||
real: vec!["t", "v"],
|
||
decoy: vec!["meta", "t", "v"],
|
||
query: {
|
||
let mut q = EventQuery::for_community(community);
|
||
q.custom_tag = Some(("t".into(), "v".into()));
|
||
q
|
||
},
|
||
},
|
||
Case {
|
||
label: "e tag shifted right",
|
||
kind: 7,
|
||
real: vec!["e", &target],
|
||
decoy: vec!["x", "e", &target],
|
||
query: {
|
||
let mut q = EventQuery::for_community(community);
|
||
q.e_tags = Some(vec![target.clone()]);
|
||
q
|
||
},
|
||
},
|
||
Case {
|
||
label: "artifact d tag shifted right",
|
||
kind: artifact_kind,
|
||
real: vec!["d", "a1"],
|
||
decoy: vec!["x", "d", "a1"],
|
||
query: {
|
||
let mut q = EventQuery::for_community(community);
|
||
q.d_tag_values = Some(vec!["a1".into()]);
|
||
q
|
||
},
|
||
},
|
||
];
|
||
for (offset, case) in (0u64..).step_by(10).zip(cases) {
|
||
let Case {
|
||
label,
|
||
kind,
|
||
real,
|
||
decoy,
|
||
query: mut q,
|
||
} = case;
|
||
let real_id = insert(kind, real, now - 1000 + offset).await;
|
||
insert(kind, decoy, now - 995 + offset).await;
|
||
q.kinds = Some(vec![i32::from(kind)]);
|
||
q.pubkey = Some(keys.public_key().to_bytes().to_vec());
|
||
q.since = Some(DateTime::from_timestamp((now - 1000 + offset) as i64, 0).unwrap());
|
||
q.until = Some(DateTime::from_timestamp((now - 995 + offset) as i64, 0).unwrap());
|
||
q.limit = Some(1);
|
||
let rows = query_events(&pool, &q).await.unwrap();
|
||
assert_eq!(rows.len(), 1, "{label}");
|
||
assert_eq!(rows[0].event.id.as_bytes().to_vec(), real_id, "{label}");
|
||
assert_eq!(count_events(&pool, &q).await.unwrap(), 1, "{label} count");
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn shared_gate_rejects_positional_decoy() {
|
||
let pool = setup_pool().await;
|
||
let community = CommunityId::from_uuid(make_test_community(&pool).await);
|
||
let (author, reader) = (Keys::generate(), Keys::generate());
|
||
let kind = SHARED_GATED_KINDS[0];
|
||
let now = nostr::Timestamp::now().as_secs();
|
||
let mut ids = Vec::new();
|
||
// Real shared event, then a newer private one whose shared marker is
|
||
// shifted right, so it would win a `limit: 1` read if admitted.
|
||
for (tag, at) in [
|
||
(["shared", "true"].as_slice(), now - 10),
|
||
(&["x", "shared", "true"], now),
|
||
] {
|
||
let event = EventBuilder::new(Kind::Custom(kind as u16), "")
|
||
.tags([
|
||
Tag::parse(["d", &Uuid::new_v4().to_string()]).unwrap(),
|
||
Tag::parse(tag.iter().copied()).unwrap(),
|
||
])
|
||
.custom_created_at(nostr::Timestamp::from(at))
|
||
.sign_with_keys(&author)
|
||
.unwrap();
|
||
insert_event(&pool, community, &event, None).await.unwrap();
|
||
ids.push(event.id.as_bytes().to_vec());
|
||
}
|
||
let mut q = EventQuery::for_community(community);
|
||
q.kinds = Some(vec![kind as i32]);
|
||
q.pubkey = Some(author.public_key().to_bytes().to_vec());
|
||
q.shared_gated_reader = Some(reader.public_key().to_bytes().to_vec());
|
||
q.limit = Some(1);
|
||
let rows = query_events(&pool, &q).await.unwrap();
|
||
assert_eq!(rows.len(), 1);
|
||
assert_eq!(rows[0].event.id.as_bytes().to_vec(), ids[0]);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn read_deadline_only_tightens_and_restores_timeout() {
|
||
let pool = setup_pool().await;
|
||
// (operator setting, expected effective value inside the read)
|
||
let cases = [("0", "20s"), ("5s", "5s"), ("1min", "20s")];
|
||
for (setting, expected) in cases {
|
||
// Autocommit connection (writer path).
|
||
let mut conn = pool.acquire().await.expect("acquire");
|
||
sqlx::query("SELECT set_config('statement_timeout', $1, false)")
|
||
.bind(setting)
|
||
.execute(&mut *conn)
|
||
.await
|
||
.expect("set session timeout");
|
||
let restored: String = sqlx::query_scalar("SHOW statement_timeout")
|
||
.fetch_one(&mut *conn)
|
||
.await
|
||
.expect("show timeout");
|
||
let (inside, after) = deadline_inside_and_after(&mut conn).await;
|
||
assert_eq!(inside, expected, "autocommit, operator {setting}");
|
||
assert_eq!(after, restored, "autocommit restore, operator {setting}");
|
||
|
||
// Nested in an outer read transaction (replica path -> savepoint).
|
||
let mut tx = sqlx::Connection::begin(&mut *conn).await.expect("begin");
|
||
let (inside, after) = deadline_inside_and_after(&mut tx).await;
|
||
assert_eq!(inside, expected, "savepoint, operator {setting}");
|
||
assert_eq!(after, restored, "savepoint restore, operator {setting}");
|
||
tx.rollback().await.expect("rollback outer");
|
||
|
||
sqlx::query("RESET statement_timeout")
|
||
.execute(&mut *conn)
|
||
.await
|
||
.expect("reset");
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn read_deadline_restores_timeout_after_error() {
|
||
let pool = setup_pool().await;
|
||
let mut conn = pool.acquire().await.expect("acquire");
|
||
sqlx::query("SET statement_timeout = '1min'")
|
||
.execute(&mut *conn)
|
||
.await
|
||
.expect("set session timeout");
|
||
// Autocommit connection (writer path).
|
||
let mut qb = QueryBuilder::new("SELECT 1/0");
|
||
let err = fetch_with_read_deadline(&mut conn, &mut qb, true).await;
|
||
assert!(err.is_err(), "division by zero must fail");
|
||
let after: String = sqlx::query_scalar("SHOW statement_timeout")
|
||
.fetch_one(&mut *conn)
|
||
.await
|
||
.expect("connection usable after error");
|
||
assert_eq!(after, "1min", "autocommit restore");
|
||
|
||
// Nested in an outer read transaction (replica path -> savepoint):
|
||
// the failed savepoint must roll back and leave the outer tx usable.
|
||
let mut tx = sqlx::Connection::begin(&mut *conn).await.expect("begin");
|
||
let mut qb = QueryBuilder::new("SELECT 1/0");
|
||
let err = fetch_with_read_deadline(&mut tx, &mut qb, true).await;
|
||
assert!(err.is_err(), "division by zero must fail");
|
||
let after: String = sqlx::query_scalar("SHOW statement_timeout")
|
||
.fetch_one(&mut *tx)
|
||
.await
|
||
.expect("outer transaction usable after savepoint error");
|
||
assert_eq!(after, "1min", "savepoint restore");
|
||
tx.rollback().await.expect("rollback outer");
|
||
sqlx::query("RESET statement_timeout")
|
||
.execute(&mut *conn)
|
||
.await
|
||
.expect("reset");
|
||
}
|
||
|
||
/// Pins the dispatch: a plain (non-`#e`) read — the agent `#p` + `#h` +
|
||
/// `since` REQ shape that ran away on bb-public — through the production
|
||
/// `query_events_on` is cancelled at the 20 s deadline even with the
|
||
/// session timeout disabled, while `events` is locked. (Slow by design.)
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn plain_read_is_cancelled_at_deadline_when_timeout_disabled() {
|
||
let pool = setup_pool().await;
|
||
let community = CommunityId::from_uuid(make_test_community(&pool).await);
|
||
let mut locker = pool.begin().await.expect("begin locker");
|
||
sqlx::query("LOCK TABLE events IN ACCESS EXCLUSIVE MODE")
|
||
.execute(&mut *locker)
|
||
.await
|
||
.expect("lock events");
|
||
|
||
let mut conn = pool.acquire().await.expect("acquire");
|
||
sqlx::query("SET statement_timeout = 0")
|
||
.execute(&mut *conn)
|
||
.await
|
||
.expect("disable timeout");
|
||
let mut q = EventQuery::for_community(community);
|
||
q.p_tag_hex = Some("ab".repeat(32));
|
||
q.channel_id = Some(Uuid::new_v4());
|
||
q.kinds = Some(vec![9]);
|
||
q.since = Some(Utc::now() - chrono::Duration::days(30));
|
||
let started = std::time::Instant::now();
|
||
let result = tokio::time::timeout(
|
||
std::time::Duration::from_secs(40),
|
||
query_events_on(&mut conn, &q),
|
||
)
|
||
.await
|
||
.expect("deadline must fire before the 40 s guard");
|
||
let err = result.expect_err("locked read must be cancelled");
|
||
assert!(err.is_statement_cancelled(), "want 57014, got {err}");
|
||
assert!(started.elapsed() >= std::time::Duration::from_secs(19));
|
||
|
||
locker.rollback().await.expect("unlock");
|
||
sqlx::query("RESET statement_timeout")
|
||
.execute(&mut *conn)
|
||
.await
|
||
.expect("reset");
|
||
}
|
||
|
||
/// Pins the dispatch: a plain (non-`#e`) COUNT through the production
|
||
/// `count_events_on` is cancelled at the 20 s deadline even with the
|
||
/// session timeout disabled, while `events` is locked. Connection and
|
||
/// setting are restored afterwards. (Slow by design.)
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn count_events_cancels_at_read_deadline_when_timeout_disabled() {
|
||
let pool = setup_pool().await;
|
||
let community = CommunityId::from_uuid(make_test_community(&pool).await);
|
||
let mut locker = pool.begin().await.expect("begin locker");
|
||
sqlx::query("LOCK TABLE events IN ACCESS EXCLUSIVE MODE")
|
||
.execute(&mut *locker)
|
||
.await
|
||
.expect("lock events");
|
||
|
||
let mut conn = pool.acquire().await.expect("acquire");
|
||
sqlx::query("SET statement_timeout = 0")
|
||
.execute(&mut *conn)
|
||
.await
|
||
.expect("disable timeout");
|
||
let mut q = EventQuery::for_community(community);
|
||
q.kinds = Some(vec![9]);
|
||
let started = std::time::Instant::now();
|
||
let result = tokio::time::timeout(
|
||
std::time::Duration::from_secs(40),
|
||
count_events_on(&mut conn, &q),
|
||
)
|
||
.await
|
||
.expect("deadline must fire before the 40 s guard");
|
||
let err = result.expect_err("locked COUNT must be cancelled");
|
||
assert!(err.is_statement_cancelled(), "want 57014, got {err}");
|
||
assert!(started.elapsed() >= std::time::Duration::from_secs(19));
|
||
|
||
// Connection and session timeout are restored after the error.
|
||
let after: String = sqlx::query_scalar("SHOW statement_timeout")
|
||
.fetch_one(&mut *conn)
|
||
.await
|
||
.expect("connection usable after cancellation");
|
||
assert_eq!(after, "0", "timeout must be restored to disabled");
|
||
|
||
locker.rollback().await.expect("unlock");
|
||
sqlx::query("RESET statement_timeout")
|
||
.execute(&mut *conn)
|
||
.await
|
||
.expect("reset");
|
||
}
|
||
|
||
/// The time-bounded `#p` join returns exactly the mentioned events in
|
||
/// the window, through the production mention insert, for both the page
|
||
/// and the COUNT, including the keyset cursor (`until` + `before_id`).
|
||
///
|
||
/// Every bound sits exactly on a mentioned row's second, so an
|
||
/// off-by-one on the mention side (`>=`→`>` or `<=`→`<` in
|
||
/// `push_mention_join`) drops a row and fails an assertion. The cursor
|
||
/// page includes a second mentioned event in the cursor's own second
|
||
/// whose id sorts after the cursor id: `until` must be an inclusive
|
||
/// superset bound on `m`, leaving the exact cursor test to `e`.
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn p_tag_read_with_mention_bounds_keeps_results() {
|
||
let pool = setup_pool().await;
|
||
let community_uuid = make_test_community(&pool).await;
|
||
let community = CommunityId::from_uuid(community_uuid);
|
||
let channel = make_test_channel(&pool, community_uuid, None).await;
|
||
let author = Keys::generate();
|
||
let mentioned = Keys::generate().public_key().to_hex();
|
||
let now = nostr::Timestamp::now().as_secs();
|
||
let at = |secs: u64| DateTime::from_timestamp(secs as i64, 0).unwrap();
|
||
let mut events = Vec::new();
|
||
for (i, created) in [now - 300, now - 200, now - 100, now - 100]
|
||
.into_iter()
|
||
.enumerate()
|
||
{
|
||
let event = EventBuilder::new(Kind::Custom(9), format!("mention {i}"))
|
||
.tags([Tag::parse(["p", &mentioned]).unwrap()])
|
||
.custom_created_at(nostr::Timestamp::from(created))
|
||
.sign_with_keys(&author)
|
||
.unwrap();
|
||
insert_event(&pool, community, &event, Some(channel))
|
||
.await
|
||
.unwrap();
|
||
crate::insert_mentions(&pool, community, &event, Some(channel))
|
||
.await
|
||
.unwrap();
|
||
events.push((created, event.id.as_bytes().to_vec()));
|
||
}
|
||
// Production order: created_at DESC, id ASC. `newest` is the
|
||
// same-second pair at now-100; `newest[0]` becomes the cursor.
|
||
events.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
|
||
let ids: Vec<Vec<u8>> = events.into_iter().map(|(_, id)| id).collect();
|
||
let (newest, mid, oldest) = (&ids[..2], &ids[2], &ids[3]);
|
||
let unmentioned = EventBuilder::new(Kind::Custom(9), "no mention")
|
||
.custom_created_at(nostr::Timestamp::from(now - 150))
|
||
.sign_with_keys(&author)
|
||
.unwrap();
|
||
insert_event(&pool, community, &unmentioned, Some(channel))
|
||
.await
|
||
.unwrap();
|
||
|
||
let read = |q: EventQuery| {
|
||
let pool = pool.clone();
|
||
async move {
|
||
let rows = query_events(&pool, &q).await.unwrap();
|
||
let count = count_events(&pool, &q).await.unwrap();
|
||
let ids: Vec<Vec<u8>> = rows
|
||
.iter()
|
||
.map(|row| row.event.id.as_bytes().to_vec())
|
||
.collect();
|
||
(ids, count)
|
||
}
|
||
};
|
||
let mut q = EventQuery::for_community(community);
|
||
q.p_tag_hex = Some(mentioned.to_ascii_uppercase());
|
||
q.channel_id = Some(channel);
|
||
q.kinds = Some(vec![9]);
|
||
|
||
assert_eq!(read(q.clone()).await, (ids.clone(), 4), "unbounded");
|
||
|
||
// `since` exactly on `mid`'s second keeps it (inclusive).
|
||
q.since = Some(at(now - 200));
|
||
assert_eq!(
|
||
read(q.clone()).await,
|
||
(vec![newest[0].clone(), newest[1].clone(), mid.clone()], 3),
|
||
"since on a row's second"
|
||
);
|
||
|
||
// `since` == `until` on `mid`'s second keeps exactly `mid`.
|
||
q.until = Some(at(now - 200));
|
||
assert_eq!(
|
||
read(q.clone()).await,
|
||
(vec![mid.clone()], 1),
|
||
"window collapsed onto one row's second"
|
||
);
|
||
|
||
// Keyset cursor at the newest second: the same-second sibling after
|
||
// the cursor id must survive the mention-side `until` bound.
|
||
q.since = None;
|
||
q.until = Some(at(now - 100));
|
||
q.before_id = Some(newest[0].clone());
|
||
q.limit = Some(10);
|
||
let rows = query_events(&pool, &q).await.unwrap();
|
||
let page: Vec<Vec<u8>> = rows
|
||
.iter()
|
||
.map(|row| row.event.id.as_bytes().to_vec())
|
||
.collect();
|
||
assert_eq!(
|
||
page,
|
||
vec![newest[1].clone(), mid.clone(), oldest.clone()],
|
||
"cursor page keeps the same-second sibling"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn event_insert_in_existing_transaction_rolls_back_with_caller() {
|
||
let pool = setup_pool().await;
|
||
let community_uuid = make_test_community(&pool).await;
|
||
let community = CommunityId::from_uuid(community_uuid);
|
||
let event = make_text_event("caller-owned transaction");
|
||
|
||
let mut tx = crate::begin_community_event_write_transaction(
|
||
&pool,
|
||
community,
|
||
crate::observability::WriterOperation::EventWrite,
|
||
)
|
||
.await
|
||
.expect("begin event insert transaction");
|
||
let (_, was_inserted) = insert_event_in_transaction(&mut tx, &event, None)
|
||
.await
|
||
.expect("insert event in caller transaction");
|
||
assert!(was_inserted);
|
||
tx.rollback().await.expect("roll back event insert");
|
||
|
||
let persisted: i64 =
|
||
sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id = $1 AND id = $2")
|
||
.bind(community_uuid)
|
||
.bind(event.id.as_bytes().as_slice())
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("count rolled-back event");
|
||
assert_eq!(persisted, 0);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn event_insert_ttl_refresh_handles_permanent_ephemeral_duplicate_and_activation_race() {
|
||
let pool = setup_pool().await;
|
||
let community_uuid = make_test_community(&pool).await;
|
||
let community = CommunityId::from_uuid(community_uuid);
|
||
|
||
let permanent = make_test_channel(&pool, community_uuid, None).await;
|
||
let permanent_event = make_text_event("permanent channel event");
|
||
assert!(
|
||
insert_event(&pool, community, &permanent_event, Some(permanent))
|
||
.await
|
||
.expect("insert permanent event")
|
||
.1
|
||
);
|
||
let permanent_deadline: Option<DateTime<Utc>> = sqlx::query_scalar(
|
||
"SELECT ttl_deadline FROM channels WHERE community_id = $1 AND id = $2",
|
||
)
|
||
.bind(community_uuid)
|
||
.bind(permanent)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("read permanent deadline");
|
||
assert_eq!(permanent_deadline, None);
|
||
|
||
let ephemeral = make_test_channel(&pool, community_uuid, Some(60)).await;
|
||
let initial_deadline: DateTime<Utc> = sqlx::query_scalar(
|
||
"SELECT ttl_deadline FROM channels WHERE community_id = $1 AND id = $2",
|
||
)
|
||
.bind(community_uuid)
|
||
.bind(ephemeral)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("read initial ephemeral deadline");
|
||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||
let ephemeral_event = make_text_event("ephemeral channel event");
|
||
assert!(
|
||
insert_event(&pool, community, &ephemeral_event, Some(ephemeral))
|
||
.await
|
||
.expect("insert ephemeral event")
|
||
.1
|
||
);
|
||
let bumped_deadline: DateTime<Utc> = sqlx::query_scalar(
|
||
"SELECT ttl_deadline FROM channels WHERE community_id = $1 AND id = $2",
|
||
)
|
||
.bind(community_uuid)
|
||
.bind(ephemeral)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("read bumped ephemeral deadline");
|
||
assert!(bumped_deadline > initial_deadline);
|
||
assert!(
|
||
!insert_event(&pool, community, &ephemeral_event, Some(ephemeral))
|
||
.await
|
||
.expect("insert duplicate event")
|
||
.1
|
||
);
|
||
let duplicate_deadline: DateTime<Utc> = sqlx::query_scalar(
|
||
"SELECT ttl_deadline FROM channels WHERE community_id = $1 AND id = $2",
|
||
)
|
||
.bind(community_uuid)
|
||
.bind(ephemeral)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("read deadline after duplicate");
|
||
assert_eq!(duplicate_deadline, bumped_deadline);
|
||
|
||
// Reproduce the blocked stale-prefetch ordering: ingest has already
|
||
// observed a permanent channel, then TTL activation locks/updates the
|
||
// row before the event INSERT reaches its pre-commit TTL refresh. The
|
||
// refresh must wait, then use the later event once activation commits.
|
||
let racing = make_test_channel(&pool, community_uuid, None).await;
|
||
let stale_ttl: Option<i32> = sqlx::query_scalar(
|
||
"SELECT ttl_seconds FROM channels WHERE community_id = $1 AND id = $2",
|
||
)
|
||
.bind(community_uuid)
|
||
.bind(racing)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("prefetch permanent channel");
|
||
assert_eq!(stale_ttl, None);
|
||
|
||
let mut activation = pool.begin().await.expect("begin TTL activation");
|
||
// Model the repaired update_channel protocol (migration 0024): the
|
||
// TTL transition holds the per-channel advisory key EXCLUSIVE, which
|
||
// is what the event TTL refresh's shared acquisition waits on.
|
||
sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))")
|
||
.bind(format!("buzz_channel_ttl:{community_uuid}:{racing}"))
|
||
.execute(&mut *activation)
|
||
.await
|
||
.expect("acquire exclusive channel TTL key");
|
||
let activation_deadline: DateTime<Utc> = sqlx::query_scalar(
|
||
"UPDATE channels \
|
||
SET ttl_seconds = 60, ttl_deadline = clock_timestamp() + interval '60 seconds' \
|
||
WHERE community_id = $1 AND id = $2 RETURNING ttl_deadline",
|
||
)
|
||
.bind(community_uuid)
|
||
.bind(racing)
|
||
.fetch_one(&mut *activation)
|
||
.await
|
||
.expect("activate TTL while holding channel row lock");
|
||
|
||
let race_pool = pool.clone();
|
||
let racing_event = make_text_event("event after stale permanent prefetch");
|
||
let insert = tokio::spawn(async move {
|
||
insert_event(&race_pool, community, &racing_event, Some(racing)).await
|
||
});
|
||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||
assert!(
|
||
!insert.is_finished(),
|
||
"event TTL refresh must wait on TTL activation"
|
||
);
|
||
activation.commit().await.expect("commit TTL activation");
|
||
assert!(
|
||
insert
|
||
.await
|
||
.expect("join racing insert")
|
||
.expect("racing insert")
|
||
.1
|
||
);
|
||
|
||
let final_deadline: DateTime<Utc> = sqlx::query_scalar(
|
||
"SELECT ttl_deadline FROM channels WHERE community_id = $1 AND id = $2",
|
||
)
|
||
.bind(community_uuid)
|
||
.bind(racing)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("read deadline after racing event");
|
||
assert!(
|
||
final_deadline > activation_deadline + chrono::Duration::milliseconds(50),
|
||
"later event must extend TTL beyond activation deadline: activation={activation_deadline}, final={final_deadline}"
|
||
);
|
||
}
|
||
|
||
/// T1a repair regression test (migration 0024, now app-side): commits of
|
||
/// permanent-channel events must not lock the channel row. The 0022
|
||
/// trigger took `FOR UPDATE` on the channel tuple before testing
|
||
/// `ttl_seconds`, so concurrent commits into one hot permanent channel
|
||
/// queued at commit time, which no single-connection test can see. The
|
||
/// refresh now runs in [`AdmittedTx::commit`] as the last statement before
|
||
/// COMMIT.
|
||
///
|
||
/// This inserts N events through the production admitted-transaction
|
||
/// path and holds every transaction at a barrier past its INSERT. It then
|
||
/// proves three things:
|
||
/// - (a) while all N are uncommitted, no transaction holds a row-level
|
||
/// lock on the channel tuple;
|
||
/// - (b) while an external transaction holds `FOR SHARE` on the channel
|
||
/// row, all N commits, and therefore their TTL refreshes, finish
|
||
/// without blocking and without a counted refresh failure;
|
||
/// - (c) the permanent channel's deadline stays NULL.
|
||
///
|
||
/// Mutation oracle: if the refresh takes `FOR UPDATE` on the channel row,
|
||
/// the commits queue behind the `FOR SHARE` holder. The join times out,
|
||
/// or a `lock_timeout` is counted as a refresh failure.
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn permanent_channel_event_commits_do_not_lock_the_channel_row() {
|
||
const N: usize = 8;
|
||
// setup_pool's default cap (10) covers N held transactions, the
|
||
// FOR SHARE holder, and the pg_locks inspector connection.
|
||
let pool = setup_pool().await;
|
||
let community_uuid = make_test_community(&pool).await;
|
||
let community = CommunityId::from_uuid(community_uuid);
|
||
let channel = make_test_channel(&pool, community_uuid, None).await;
|
||
|
||
// Open N admitted transactions, run the production event insert in
|
||
// each (the TTL refresh runs in `commit`), and park them at a barrier.
|
||
let mut txs = Vec::new();
|
||
for i in 0..N {
|
||
let mut tx = crate::begin_community_event_write_transaction(
|
||
&pool,
|
||
community,
|
||
crate::observability::WriterOperation::EventWrite,
|
||
)
|
||
.await
|
||
.expect("begin admitted insert txn");
|
||
let event = make_text_event(&format!("hot channel event {i}"));
|
||
let (_, inserted) = insert_event_in_transaction(&mut tx, &event, Some(channel))
|
||
.await
|
||
.expect("insert event inside held txn");
|
||
assert!(inserted, "held txn {i} must insert its event");
|
||
txs.push(tx);
|
||
}
|
||
|
||
// With all N transactions holding completed INSERTs, none may hold a
|
||
// row-level lock on the channels tuple.
|
||
let tuple_locks: i64 = sqlx::query_scalar(
|
||
"SELECT count(*) FROM pg_locks l \
|
||
JOIN pg_class c ON c.oid = l.relation \
|
||
WHERE c.relname = 'channels' AND l.locktype = 'tuple'",
|
||
)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("inspect pg_locks");
|
||
assert_eq!(tuple_locks, 0, "no channel tuple locks while txns are held");
|
||
|
||
// An external FOR SHARE holder conflicts with any FOR UPDATE / UPDATE
|
||
// row lock on the channel, so a refresh that locks the permanent row
|
||
// cannot complete while it is held.
|
||
let mut holder = pool.begin().await.expect("begin FOR SHARE holder");
|
||
sqlx::query("SELECT 1 FROM channels WHERE community_id = $1 AND id = $2 FOR SHARE")
|
||
.bind(community_uuid)
|
||
.bind(channel)
|
||
.execute(&mut *holder)
|
||
.await
|
||
.expect("holder takes FOR SHARE on the channel row");
|
||
|
||
// Release all commits concurrently while the holder is open. Join with
|
||
// a timeout so a regression fails fast instead of hanging the suite.
|
||
let recorder = metrics_util::debugging::DebuggingRecorder::new();
|
||
let snapshotter = recorder.snapshotter();
|
||
{
|
||
let _guard = metrics::set_default_local_recorder(&recorder);
|
||
let commits = futures_util::future::join_all(txs.into_iter().map(AdmittedTx::commit));
|
||
for result in tokio::time::timeout(std::time::Duration::from_secs(10), commits)
|
||
.await
|
||
.expect("permanent-channel commits must not wait on the channel row")
|
||
{
|
||
result.expect("commit succeeds");
|
||
}
|
||
}
|
||
holder.rollback().await.expect("release FOR SHARE holder");
|
||
|
||
let refresh_failures: Vec<_> = snapshotter
|
||
.snapshot()
|
||
.into_vec()
|
||
.into_iter()
|
||
.filter(|(key, ..)| key.key().name() == "buzz_db_channel_ttl_refresh_failures_total")
|
||
.collect();
|
||
assert!(
|
||
refresh_failures.is_empty(),
|
||
"permanent-channel refreshes must not fail: {refresh_failures:?}"
|
||
);
|
||
|
||
let deadline: Option<DateTime<Utc>> = sqlx::query_scalar(
|
||
"SELECT ttl_deadline FROM channels WHERE community_id = $1 AND id = $2",
|
||
)
|
||
.bind(community_uuid)
|
||
.bind(channel)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("read deadline after commits");
|
||
assert_eq!(deadline, None, "permanent channel must remain untouched");
|
||
let stored: i64 = sqlx::query_scalar(
|
||
"SELECT count(*) FROM events WHERE community_id = $1 AND channel_id = $2",
|
||
)
|
||
.bind(community_uuid)
|
||
.bind(channel)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("count stored events");
|
||
assert_eq!(stored as usize, N);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn get_event_by_id_is_scoped_when_event_id_collides_across_communities() {
|
||
let pool = setup_pool().await;
|
||
let community_a = CommunityId::from_uuid(make_test_community(&pool).await);
|
||
let community_b = CommunityId::from_uuid(make_test_community(&pool).await);
|
||
let keys = Keys::generate();
|
||
let event = EventBuilder::new(Kind::Custom(9), "same signed event")
|
||
.sign_with_keys(&keys)
|
||
.expect("sign event");
|
||
|
||
insert_event(&pool, community_a, &event, None)
|
||
.await
|
||
.expect("insert in community A");
|
||
insert_event(&pool, community_b, &event, None)
|
||
.await
|
||
.expect("insert same event in community B");
|
||
|
||
sqlx::query("UPDATE events SET content = $1 WHERE community_id = $2 AND id = $3")
|
||
.bind("community-a-copy")
|
||
.bind(community_a.as_uuid())
|
||
.bind(event.id.as_bytes())
|
||
.execute(&pool)
|
||
.await
|
||
.expect("mark community A row");
|
||
sqlx::query("UPDATE events SET content = $1 WHERE community_id = $2 AND id = $3")
|
||
.bind("community-b-copy")
|
||
.bind(community_b.as_uuid())
|
||
.bind(event.id.as_bytes())
|
||
.execute(&pool)
|
||
.await
|
||
.expect("mark community B row");
|
||
|
||
let a = get_event_by_id(&pool, community_a, event.id.as_bytes())
|
||
.await
|
||
.expect("lookup community A")
|
||
.expect("community A row exists");
|
||
let b = get_event_by_id(&pool, community_b, event.id.as_bytes())
|
||
.await
|
||
.expect("lookup community B")
|
||
.expect("community B row exists");
|
||
|
||
assert_eq!(a.event.content, "community-a-copy");
|
||
assert_eq!(b.event.content, "community-b-copy");
|
||
}
|
||
|
||
fn make_event_with_kind_and_tags(kind: u16, tags: Vec<Tag>) -> nostr::Event {
|
||
let keys = Keys::generate();
|
||
EventBuilder::new(Kind::Custom(kind), "test")
|
||
.tags(tags)
|
||
.sign_with_keys(&keys)
|
||
.expect("sign")
|
||
}
|
||
|
||
fn make_event_at(kind: u16, content: &str, created_at: u64) -> nostr::Event {
|
||
EventBuilder::new(Kind::Custom(kind), content)
|
||
.custom_created_at(nostr::Timestamp::from(created_at))
|
||
.sign_with_keys(&Keys::generate())
|
||
.expect("sign timestamped event")
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn explicit_multi_channel_scope_is_applied_before_historical_page_limit() {
|
||
let pool = setup_pool().await;
|
||
let community_uuid = make_test_community(&pool).await;
|
||
let community = CommunityId::from_uuid(community_uuid);
|
||
let channel_a = make_test_channel(&pool, community_uuid, None).await;
|
||
let channel_b = make_test_channel(&pool, community_uuid, None).await;
|
||
let unrelated_c = make_test_channel(&pool, community_uuid, None).await;
|
||
let base = 1_800_000_000;
|
||
|
||
let older_a = make_event_at(39_000, "older requested A", base + 1);
|
||
insert_event(&pool, community, &older_a, Some(channel_a))
|
||
.await
|
||
.expect("insert requested A candidate");
|
||
let requested_b = make_event_at(39_000, "requested B", base + 2);
|
||
insert_event(&pool, community, &requested_b, Some(channel_b))
|
||
.await
|
||
.expect("insert requested B candidate");
|
||
let newer_c = make_event_at(39_000, "newer unrelated C", base + 3);
|
||
insert_event(&pool, community, &newer_c, Some(unrelated_c))
|
||
.await
|
||
.expect("insert unrelated C candidate");
|
||
let global = make_event_at(39_000, "global candidate", base + 4);
|
||
insert_event(&pool, community, &global, None)
|
||
.await
|
||
.expect("insert global candidate");
|
||
|
||
let events = query_events(
|
||
&pool,
|
||
&EventQuery {
|
||
kinds: Some(vec![39_000]),
|
||
channel_ids: Some(vec![channel_a, channel_b]),
|
||
channel_ids_include_global: false,
|
||
limit: Some(1),
|
||
..EventQuery::for_community(community)
|
||
},
|
||
)
|
||
.await
|
||
.expect("query explicit multi-channel page");
|
||
|
||
assert_eq!(events.len(), 1);
|
||
assert_eq!(
|
||
events[0].event.id, requested_b.id,
|
||
"newer unrelated channel C must not consume the requested A/B limit"
|
||
);
|
||
|
||
let partial_authorization_count = count_events(
|
||
&pool,
|
||
&EventQuery {
|
||
kinds: Some(vec![39_000]),
|
||
channel_ids: Some(vec![channel_a]),
|
||
channel_ids_include_global: false,
|
||
..EventQuery::for_community(community)
|
||
},
|
||
)
|
||
.await
|
||
.expect("count one authorized channel from a multi-channel request");
|
||
assert_eq!(
|
||
partial_authorization_count, 1,
|
||
"partial authorization must exclude requested B, unrelated C, and global rows"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn access_scope_is_applied_before_historical_page_limit() {
|
||
let pool = setup_pool().await;
|
||
let community_uuid = make_test_community(&pool).await;
|
||
let community = CommunityId::from_uuid(community_uuid);
|
||
let accessible = make_test_channel(&pool, community_uuid, None).await;
|
||
let inaccessible = make_test_channel(&pool, community_uuid, None).await;
|
||
let base = 1_800_000_000;
|
||
|
||
// This is the bridge underfetch shape: newer inaccessible candidates
|
||
// outnumber the requested page, while the visible match is older.
|
||
for offset in 10..13 {
|
||
let event = make_event_at(39_000, "newer inaccessible", base + offset);
|
||
insert_event(&pool, community, &event, Some(inaccessible))
|
||
.await
|
||
.expect("insert inaccessible candidate");
|
||
}
|
||
let global = make_event_at(39_000, "newer global", base + 2);
|
||
insert_event(&pool, community, &global, None)
|
||
.await
|
||
.expect("insert global candidate");
|
||
let older_accessible = make_event_at(39_000, "older accessible", base + 1);
|
||
insert_event(&pool, community, &older_accessible, Some(accessible))
|
||
.await
|
||
.expect("insert accessible candidate");
|
||
|
||
let events = query_events(
|
||
&pool,
|
||
&EventQuery {
|
||
kinds: Some(vec![39_000]),
|
||
channel_ids: Some(vec![accessible]),
|
||
limit: Some(2),
|
||
..EventQuery::for_community(community)
|
||
},
|
||
)
|
||
.await
|
||
.expect("query access-scoped page");
|
||
|
||
assert_eq!(events.len(), 2, "visible page must be filled before EOF");
|
||
assert_eq!(events[0].event.id, global.id, "global rows remain visible");
|
||
assert_eq!(
|
||
events[1].event.id, older_accessible.id,
|
||
"older accessible row must not be hidden behind newer inaccessible rows"
|
||
);
|
||
}
|
||
|
||
fn make_text_event(content: &str) -> nostr::Event {
|
||
let keys = Keys::generate();
|
||
EventBuilder::new(Kind::Custom(9), content)
|
||
.sign_with_keys(&keys)
|
||
.expect("sign text event")
|
||
}
|
||
|
||
#[test]
|
||
fn extract_d_tag_from_nip33_event() {
|
||
let event = make_event_with_kind_and_tags(
|
||
30023,
|
||
vec![Tag::parse(["d", "my-article-slug"]).unwrap()],
|
||
);
|
||
assert_eq!(extract_d_tag(&event), Some("my-article-slug".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn extract_d_tag_first_d_wins() {
|
||
let event = make_event_with_kind_and_tags(
|
||
30023,
|
||
vec![
|
||
Tag::parse(["d", "first"]).unwrap(),
|
||
Tag::parse(["d", "second"]).unwrap(),
|
||
],
|
||
);
|
||
assert_eq!(extract_d_tag(&event), Some("first".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn extract_d_tag_missing_becomes_empty_string() {
|
||
// NIP-33: "if there is no d tag, the d tag is considered to be ''"
|
||
let event =
|
||
make_event_with_kind_and_tags(30023, vec![Tag::parse(["p", "abc123"]).unwrap()]);
|
||
assert_eq!(extract_d_tag(&event), Some(String::new()));
|
||
}
|
||
|
||
#[test]
|
||
fn extract_d_tag_empty_value_preserved() {
|
||
let event = make_event_with_kind_and_tags(30023, vec![Tag::parse(["d", ""]).unwrap()]);
|
||
assert_eq!(extract_d_tag(&event), Some(String::new()));
|
||
}
|
||
|
||
#[test]
|
||
fn extract_d_tag_non_nip33_returns_none() {
|
||
// kind:1 (text note) — not parameterized replaceable
|
||
let event =
|
||
make_event_with_kind_and_tags(1, vec![Tag::parse(["d", "should-be-ignored"]).unwrap()]);
|
||
assert_eq!(extract_d_tag(&event), None);
|
||
}
|
||
|
||
#[test]
|
||
fn extract_d_tag_nip29_group_metadata() {
|
||
// kind:39000 is in the 30000–39999 range — d_tag should be extracted
|
||
let event =
|
||
make_event_with_kind_and_tags(39000, vec![Tag::parse(["d", "group-id"]).unwrap()]);
|
||
assert_eq!(extract_d_tag(&event), Some("group-id".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn extract_d_tag_boundary_kinds() {
|
||
// kind:29999 — just below range
|
||
let below = make_event_with_kind_and_tags(29999, vec![Tag::parse(["d", "val"]).unwrap()]);
|
||
assert_eq!(extract_d_tag(&below), None);
|
||
|
||
// kind:30000 — lower bound
|
||
let lower = make_event_with_kind_and_tags(30000, vec![Tag::parse(["d", "val"]).unwrap()]);
|
||
assert_eq!(extract_d_tag(&lower), Some("val".to_string()));
|
||
|
||
// kind:39999 — upper bound
|
||
let upper = make_event_with_kind_and_tags(39999, vec![Tag::parse(["d", "val"]).unwrap()]);
|
||
assert_eq!(extract_d_tag(&upper), Some("val".to_string()));
|
||
|
||
// kind:40000 — just above range
|
||
let above = make_event_with_kind_and_tags(40000, vec![Tag::parse(["d", "val"]).unwrap()]);
|
||
assert_eq!(extract_d_tag(&above), None);
|
||
}
|
||
|
||
#[test]
|
||
fn extract_d_tag_single_element_d_tag_ignored() {
|
||
// A d tag with only one element (no value) should not match — parts.len() < 2
|
||
let event = make_event_with_kind_and_tags(30023, vec![Tag::parse(["d"]).unwrap()]);
|
||
// No d tag with a value → empty string per NIP-33
|
||
assert_eq!(extract_d_tag(&event), Some(String::new()));
|
||
}
|
||
|
||
#[test]
|
||
fn extract_d_tag_preserves_full_value() {
|
||
// extract_d_tag returns the full value — length enforcement is at the ingest layer.
|
||
let long_val = "x".repeat(2048);
|
||
let event =
|
||
make_event_with_kind_and_tags(30023, vec![Tag::parse(["d", &long_val]).unwrap()]);
|
||
let result = extract_d_tag(&event).unwrap();
|
||
assert_eq!(result.len(), 2048);
|
||
assert_eq!(result, long_val);
|
||
}
|
||
|
||
#[test]
|
||
fn extract_not_before_from_reminder() {
|
||
let event = make_event_with_kind_and_tags(
|
||
KIND_EVENT_REMINDER as u16,
|
||
vec![Tag::parse(["not_before", "1717000000"]).unwrap()],
|
||
);
|
||
assert_eq!(extract_not_before(&event), Some(1_717_000_000));
|
||
}
|
||
|
||
#[test]
|
||
fn extract_not_before_absent_returns_none() {
|
||
// A bookmark/terminal reminder carries no `not_before` tag.
|
||
let event = make_event_with_kind_and_tags(
|
||
KIND_EVENT_REMINDER as u16,
|
||
vec![Tag::parse(["d", "abc"]).unwrap()],
|
||
);
|
||
assert_eq!(extract_not_before(&event), None);
|
||
}
|
||
|
||
#[test]
|
||
fn extract_not_before_non_reminder_returns_none() {
|
||
// Only kind:30300 materializes `not_before`; other kinds stay NULL.
|
||
let event = make_event_with_kind_and_tags(
|
||
30023,
|
||
vec![Tag::parse(["not_before", "1717000000"]).unwrap()],
|
||
);
|
||
assert_eq!(extract_not_before(&event), None);
|
||
}
|
||
|
||
#[test]
|
||
fn extract_not_before_non_numeric_returns_none() {
|
||
// Malformed values are rejected by ingest; materialization just skips them.
|
||
let event = make_event_with_kind_and_tags(
|
||
KIND_EVENT_REMINDER as u16,
|
||
vec![Tag::parse(["not_before", "not-a-number"]).unwrap()],
|
||
);
|
||
assert_eq!(extract_not_before(&event), None);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn coordinate_delete_spares_head_newer_than_the_deletion() {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
let db = Db::from_pool(setup_pool().await);
|
||
let community = CommunityId::from_uuid(make_test_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(flavor = "current_thread")]
|
||
#[ignore = "requires Postgres"]
|
||
async fn huddle_started_links_batches_valid_creator_links_and_ignores_malformed_content() {
|
||
let pool = setup_pool().await;
|
||
let community_uuid = make_test_community(&pool).await;
|
||
let community = CommunityId::from_uuid(community_uuid);
|
||
let parent = make_test_channel(&pool, community_uuid, None).await;
|
||
let session = make_test_channel(&pool, community_uuid, Some(60)).await;
|
||
let creator = vec![7_u8; 32];
|
||
|
||
for (index, content) in [
|
||
"not-json".to_owned(),
|
||
serde_json::json!({ "ephemeral_channel_id": session }).to_string(),
|
||
]
|
||
.into_iter()
|
||
.enumerate()
|
||
{
|
||
sqlx::query(
|
||
"INSERT INTO events \
|
||
(community_id, id, pubkey, created_at, kind, tags, content, sig, channel_id) \
|
||
VALUES ($1, $2, $3, NOW() + make_interval(secs => $4), $5, '[]', $6, $7, $8)",
|
||
)
|
||
.bind(community_uuid)
|
||
.bind(vec![(index + 1) as u8; 32])
|
||
.bind(&creator)
|
||
.bind(index as f64)
|
||
.bind(KIND_HUDDLE_STARTED as i32)
|
||
.bind(content)
|
||
.bind(vec![0_u8; 64])
|
||
.bind(parent)
|
||
.execute(&pool)
|
||
.await
|
||
.expect("insert huddle-start candidate");
|
||
}
|
||
|
||
let recorder = metrics_util::debugging::DebuggingRecorder::new();
|
||
let snapshotter = recorder.snapshotter();
|
||
let db = Db::from_pool(pool);
|
||
let links = {
|
||
let _guard = metrics::set_default_local_recorder(&recorder);
|
||
db.huddle_started_links(community, &[parent], &[session])
|
||
.await
|
||
}
|
||
.expect("batch huddle links");
|
||
assert_eq!(links, vec![(session, parent, creator)]);
|
||
|
||
let counters = snapshotter
|
||
.snapshot()
|
||
.into_vec()
|
||
.into_iter()
|
||
.filter_map(|(key, _, _, value)| {
|
||
let labels = key
|
||
.key()
|
||
.labels()
|
||
.map(|label| (label.key().to_owned(), label.value().to_owned()))
|
||
.collect::<std::collections::BTreeMap<_, _>>();
|
||
if labels.get("pool_role").map(String::as_str) != Some("writer")
|
||
|| labels.get("operation").map(String::as_str) != Some("subscription_history")
|
||
{
|
||
return None;
|
||
}
|
||
let metrics_util::debugging::DebugValue::Counter(value) = value else {
|
||
return None;
|
||
};
|
||
Some((
|
||
(key.key().name().to_owned(), labels.get("outcome").cloned()),
|
||
value,
|
||
))
|
||
})
|
||
.collect::<std::collections::BTreeMap<_, _>>();
|
||
assert_eq!(
|
||
counters,
|
||
[
|
||
(
|
||
("buzz_db_pool_acquire_started_total".to_owned(), None),
|
||
1,
|
||
),
|
||
(
|
||
(
|
||
"buzz_db_pool_acquire_attempts_total".to_owned(),
|
||
Some("success".to_owned()),
|
||
),
|
||
1,
|
||
),
|
||
]
|
||
.into_iter()
|
||
.collect(),
|
||
"the production huddle lookup must emit one writer/subscription_history start and success terminal"
|
||
);
|
||
}
|
||
|
||
// I4 deletion-race witness:
|
||
// `huddle_started_link_exists_in_transaction` acquires FOR SHARE on the
|
||
// matching row. A concurrent `soft_delete_event` (UPDATE events SET
|
||
// deleted_at = NOW() WHERE ...) must BLOCK until the join transaction
|
||
// commits or rolls back — it cannot race past the re-read and commit
|
||
// deletion before the join completes.
|
||
//
|
||
// Test protocol:
|
||
// 1. Insert a huddle_started event row.
|
||
// 2. Open a transaction and call `huddle_started_link_exists_in_transaction`
|
||
// (acquires FOR SHARE).
|
||
// 3. Concurrently try `soft_delete_event` from a second connection —
|
||
// the UPDATE blocks because FOR SHARE conflicts with UPDATE.
|
||
// 4. Commit the first transaction.
|
||
// 5. The concurrent delete now completes — confirm it succeeds.
|
||
//
|
||
// Mutation evidence:
|
||
// Remove `FOR SHARE` from the SELECT in `huddle_started_link_exists_in_transaction` →
|
||
// the concurrent delete completes before the join tx commits →
|
||
// `link_gone_before_commit` becomes true before the tx commits →
|
||
// assertion panics ("FOR SHARE must make delete block").
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres — link deletion contends with join transaction via FOR SHARE"]
|
||
async fn i4_huddle_link_deletion_blocked_by_join_transaction_for_share() {
|
||
use std::sync::atomic::{AtomicBool, Ordering};
|
||
use std::sync::Arc;
|
||
use tokio::sync::Notify;
|
||
|
||
let pool = setup_pool().await;
|
||
let community = make_test_community(&pool).await;
|
||
let community_id = buzz_core::CommunityId::from_uuid(community);
|
||
let parent = make_test_channel(&pool, community, None).await;
|
||
let session = make_test_channel(&pool, community, None).await;
|
||
let creator = vec![0xAAu8; 32];
|
||
let event_id = vec![0xBBu8; 32];
|
||
|
||
// Insert the huddle_started event row.
|
||
let content = serde_json::json!({"ephemeral_channel_id": session.to_string()}).to_string();
|
||
sqlx::query(
|
||
"INSERT INTO events \
|
||
(community_id, id, pubkey, created_at, kind, tags, content, sig, channel_id) \
|
||
VALUES ($1, $2, $3, NOW(), $4, '[]', $5, $6, $7)",
|
||
)
|
||
.bind(community)
|
||
.bind(&event_id)
|
||
.bind(&creator)
|
||
.bind(KIND_HUDDLE_STARTED as i32)
|
||
.bind(&content)
|
||
.bind(vec![0u8; 64])
|
||
.bind(parent)
|
||
.execute(&pool)
|
||
.await
|
||
.expect("insert huddle_started event");
|
||
|
||
// Signal: join transaction has acquired FOR SHARE, delete may attempt.
|
||
let delete_may_start = Arc::new(Notify::new());
|
||
// Signal: delete completed (or timed out).
|
||
let delete_completed = Arc::new(AtomicBool::new(false));
|
||
let link_gone_before_commit = Arc::new(AtomicBool::new(false));
|
||
|
||
let delete_may_start2 = delete_may_start.clone();
|
||
let delete_completed2 = delete_completed.clone();
|
||
let link_gone2 = link_gone_before_commit.clone();
|
||
let pool2 = pool.clone();
|
||
let event_id2 = event_id.clone();
|
||
let community2 = community_id;
|
||
|
||
// Spawn the deleter: waits for the join tx to hold FOR SHARE, then tries
|
||
// to delete. It should block until the join tx commits.
|
||
let delete_handle = tokio::spawn(async move {
|
||
delete_may_start2.notified().await;
|
||
// Record whether the link row is still live at delete time.
|
||
// Under FOR SHARE this call will block until the join tx commits.
|
||
let result =
|
||
soft_delete_event_and_update_thread(&pool2, community2, &event_id2, None, None)
|
||
.await
|
||
.expect("soft_delete_event_and_update_thread should not error");
|
||
// Mark whether the link was deleted (not already gone).
|
||
link_gone2.store(result, Ordering::Relaxed);
|
||
delete_completed2.store(true, Ordering::Relaxed);
|
||
});
|
||
|
||
// Open the join transaction and acquire FOR SHARE.
|
||
let mut tx = crate::begin_community_event_write_transaction(
|
||
&pool,
|
||
community_id,
|
||
crate::observability::WriterOperation::EventWrite,
|
||
)
|
||
.await
|
||
.expect("begin join tx");
|
||
let exists = huddle_started_link_exists_in_transaction(&mut tx, parent, session, &creator)
|
||
.await
|
||
.expect("huddle_started_link_exists_in_transaction");
|
||
assert!(exists, "I4: link must exist before commit");
|
||
|
||
// Signal the deleter to attempt its UPDATE now.
|
||
delete_may_start.notify_one();
|
||
|
||
// Give the deleter a brief window to attempt the DELETE. Under correct
|
||
// FOR SHARE locking, it blocks here and `delete_completed` stays false.
|
||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||
|
||
assert!(
|
||
!delete_completed.load(Ordering::Relaxed),
|
||
"I4: FOR SHARE must make soft_delete_event block — \
|
||
delete completed before the join transaction committed, \
|
||
which proves deletion can race past the re-read. \
|
||
Remove FOR SHARE from the SELECT in \
|
||
huddle_started_link_exists_in_transaction to reproduce."
|
||
);
|
||
|
||
// Commit the join transaction — delete should unblock.
|
||
tx.commit().await.expect("commit join tx");
|
||
|
||
tokio::time::timeout(std::time::Duration::from_secs(5), delete_handle)
|
||
.await
|
||
.expect("I4: delete must complete within 5s after join tx commit")
|
||
.expect("delete_handle must not panic");
|
||
|
||
// After the join tx commits, the delete should have succeeded.
|
||
assert!(
|
||
link_gone_before_commit.load(Ordering::Relaxed),
|
||
"I4: soft_delete_event must succeed once the join tx releases FOR SHARE"
|
||
);
|
||
assert!(
|
||
delete_completed.load(Ordering::Relaxed),
|
||
"I4: delete must complete after join tx commit"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn huddle_started_content_requires_matching_ephemeral_field() {
|
||
let channel_id = Uuid::new_v4();
|
||
let matching = serde_json::json!({
|
||
"ephemeral_channel_id": channel_id.to_string(),
|
||
})
|
||
.to_string();
|
||
assert!(huddle_started_content_links(&matching, channel_id));
|
||
|
||
let wrong_field = serde_json::json!({
|
||
"other": channel_id.to_string(),
|
||
})
|
||
.to_string();
|
||
assert!(!huddle_started_content_links(&wrong_field, channel_id));
|
||
assert!(!huddle_started_content_links("not-json", channel_id));
|
||
}
|
||
|
||
// ─── canvas CAS tests ─────────────────────────────────────────────────────
|
||
|
||
fn make_canvas_event_at(content: &str, created_at: u64) -> nostr::Event {
|
||
make_event_at(buzz_core::kind::KIND_CANVAS as u16, content, created_at)
|
||
}
|
||
|
||
/// Build two canvas events sharing `created_at`, returned `(lower, higher)`
|
||
/// by event id. Regenerates until the ids differ (always, since keys differ)
|
||
/// so tests can assert deterministic head ordering on the id tiebreak.
|
||
fn same_second_ordered_pair(created_at: u64) -> (nostr::Event, nostr::Event) {
|
||
let a = make_canvas_event_at("# A", created_at);
|
||
let b = make_canvas_event_at("# B", created_at);
|
||
if a.id.as_bytes() <= b.id.as_bytes() {
|
||
(a, b)
|
||
} else {
|
||
(b, a)
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn canvas_head_checked_rejects_non_canvas_kinds_before_opening_a_transaction() {
|
||
// A lazy pool that is never connected: the kind check must reject
|
||
// before any writer acquisition, so no database is needed.
|
||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||
.connect_lazy("postgres://unused@127.0.0.1:1/unused")
|
||
.expect("lazy pool");
|
||
let event = make_event_at(9, "not a canvas", 1000);
|
||
|
||
let error = insert_canvas_head_checked(
|
||
&pool,
|
||
CommunityId::from_uuid(Uuid::new_v4()),
|
||
&event,
|
||
Uuid::new_v4(),
|
||
ChannelHeadPrecondition::ExpectNoHead,
|
||
)
|
||
.await
|
||
.expect_err("non-canvas kinds must be rejected");
|
||
assert!(
|
||
matches!(&error, DbError::InvalidData(message) if message.contains("requires kind")),
|
||
"expected a kind rejection, got: {error:#}"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn channel_head_checked_expect_no_head_creates_first_canvas() {
|
||
let pool = setup_pool().await;
|
||
let community = CommunityId::from_uuid(make_test_community(&pool).await);
|
||
let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await;
|
||
let event = make_canvas_event_at("# First", 1000);
|
||
|
||
let (_, status) = insert_canvas_head_checked(
|
||
&pool,
|
||
community,
|
||
&event,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectNoHead,
|
||
)
|
||
.await
|
||
.expect("first canvas");
|
||
assert_eq!(status, ChannelHeadWriteStatus::Inserted);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn channel_head_checked_expect_no_head_rejects_when_head_exists() {
|
||
let pool = setup_pool().await;
|
||
let community = CommunityId::from_uuid(make_test_community(&pool).await);
|
||
let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await;
|
||
let first = make_canvas_event_at("# First", 1000);
|
||
insert_canvas_head_checked(
|
||
&pool,
|
||
community,
|
||
&first,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectNoHead,
|
||
)
|
||
.await
|
||
.expect("first canvas");
|
||
|
||
let second = make_canvas_event_at("# Racing create", 1001);
|
||
let (_, status) = insert_canvas_head_checked(
|
||
&pool,
|
||
community,
|
||
&second,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectNoHead,
|
||
)
|
||
.await
|
||
.expect("second create attempt");
|
||
assert_eq!(status, ChannelHeadWriteStatus::RevisionMismatch);
|
||
|
||
// The losing create must not have been persisted.
|
||
let persisted: i64 =
|
||
sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id = $1 AND id = $2")
|
||
.bind(community.as_uuid())
|
||
.bind(second.id.as_bytes().as_slice())
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("count losing create");
|
||
assert_eq!(persisted, 0);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn channel_head_checked_expected_head_matches_and_advances() {
|
||
let pool = setup_pool().await;
|
||
let community = CommunityId::from_uuid(make_test_community(&pool).await);
|
||
let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await;
|
||
let first = make_canvas_event_at("# First", 1000);
|
||
insert_canvas_head_checked(
|
||
&pool,
|
||
community,
|
||
&first,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectNoHead,
|
||
)
|
||
.await
|
||
.expect("first canvas");
|
||
|
||
let second = make_canvas_event_at("# Second", 1001);
|
||
let head = first.id.as_bytes();
|
||
let (_, status) = insert_canvas_head_checked(
|
||
&pool,
|
||
community,
|
||
&second,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectedHead(head.as_slice()),
|
||
)
|
||
.await
|
||
.expect("edit against head");
|
||
assert_eq!(status, ChannelHeadWriteStatus::Inserted);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn channel_head_checked_expected_head_mismatch_rejects() {
|
||
let pool = setup_pool().await;
|
||
let community = CommunityId::from_uuid(make_test_community(&pool).await);
|
||
let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await;
|
||
let first = make_canvas_event_at("# First", 1000);
|
||
insert_canvas_head_checked(
|
||
&pool,
|
||
community,
|
||
&first,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectNoHead,
|
||
)
|
||
.await
|
||
.expect("first canvas");
|
||
|
||
let stale = make_canvas_event_at("# Stale edit", 1002);
|
||
let wrong_head = [0u8; 32];
|
||
let (_, status) = insert_canvas_head_checked(
|
||
&pool,
|
||
community,
|
||
&stale,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectedHead(&wrong_head),
|
||
)
|
||
.await
|
||
.expect("stale edit attempt");
|
||
assert_eq!(status, ChannelHeadWriteStatus::RevisionMismatch);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn channel_head_checked_expected_head_missing_rejects() {
|
||
let pool = setup_pool().await;
|
||
let community = CommunityId::from_uuid(make_test_community(&pool).await);
|
||
let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await;
|
||
let event = make_canvas_event_at("# Edit with no head", 1000);
|
||
let some_head = [1u8; 32];
|
||
|
||
let (_, status) = insert_canvas_head_checked(
|
||
&pool,
|
||
community,
|
||
&event,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectedHead(&some_head),
|
||
)
|
||
.await
|
||
.expect("edit with no head");
|
||
assert_eq!(status, ChannelHeadWriteStatus::RevisionMissing);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn channel_head_checked_replay_of_head_is_duplicate() {
|
||
let pool = setup_pool().await;
|
||
let community = CommunityId::from_uuid(make_test_community(&pool).await);
|
||
let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await;
|
||
let event = make_canvas_event_at("# First", 1000);
|
||
insert_canvas_head_checked(
|
||
&pool,
|
||
community,
|
||
&event,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectNoHead,
|
||
)
|
||
.await
|
||
.expect("first canvas");
|
||
|
||
// Replaying the exact head under ExpectedHead(head) is idempotent.
|
||
let head = event.id.as_bytes();
|
||
let (_, status) = insert_canvas_head_checked(
|
||
&pool,
|
||
community,
|
||
&event,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectedHead(head.as_slice()),
|
||
)
|
||
.await
|
||
.expect("replay head");
|
||
assert_eq!(status, ChannelHeadWriteStatus::Duplicate);
|
||
}
|
||
|
||
/// Idempotent-replay exception: replaying the byte-identical head succeeds
|
||
/// as a no-op even when the supplied precondition would otherwise conflict.
|
||
/// Precondition is not evaluated for a duplicate, so safe transport retry
|
||
/// never becomes a false conflict.
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn channel_head_checked_replay_skips_stale_precondition() {
|
||
let pool = setup_pool().await;
|
||
let community = CommunityId::from_uuid(make_test_community(&pool).await);
|
||
let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await;
|
||
let event = make_canvas_event_at("# First", 1000);
|
||
insert_canvas_head_checked(
|
||
&pool,
|
||
community,
|
||
&event,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectNoHead,
|
||
)
|
||
.await
|
||
.expect("first canvas");
|
||
|
||
// Replay the same bytes with a now-stale `ExpectNoHead` tag: a head
|
||
// exists, so the precondition would reject — but replay short-circuits.
|
||
let (_, status) = insert_canvas_head_checked(
|
||
&pool,
|
||
community,
|
||
&event,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectNoHead,
|
||
)
|
||
.await
|
||
.expect("replay with stale none tag");
|
||
assert_eq!(status, ChannelHeadWriteStatus::Duplicate);
|
||
}
|
||
|
||
/// Head-advancement guarantee: a candidate whose `created_at` equals the
|
||
/// head's but whose id is HIGHER cannot become the head under
|
||
/// `created_at DESC, id ASC`, so it rejects even though the precondition
|
||
/// matches. Accepting it would leave the visible canvas unchanged.
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn channel_head_checked_rejects_same_second_higher_id() {
|
||
let pool = setup_pool().await;
|
||
let community = CommunityId::from_uuid(make_test_community(&pool).await);
|
||
let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await;
|
||
|
||
let (lower, higher) = same_second_ordered_pair(1000);
|
||
insert_canvas_head_checked(
|
||
&pool,
|
||
community,
|
||
&lower,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectNoHead,
|
||
)
|
||
.await
|
||
.expect("first canvas is lower-id head");
|
||
|
||
// Candidate has the same created_at but a higher id → cannot supersede.
|
||
let (_, status) = insert_canvas_head_checked(
|
||
&pool,
|
||
community,
|
||
&higher,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectedHead(lower.id.as_bytes().as_slice()),
|
||
)
|
||
.await
|
||
.expect("same-second higher-id edit");
|
||
assert_eq!(status, ChannelHeadWriteStatus::SupersedeFailed);
|
||
|
||
// The rejected write must not have been persisted.
|
||
let persisted: i64 =
|
||
sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id = $1 AND id = $2")
|
||
.bind(community.as_uuid())
|
||
.bind(higher.id.as_bytes().as_slice())
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("count rejected write");
|
||
assert_eq!(persisted, 0);
|
||
}
|
||
|
||
/// Head-advancement guarantee: a candidate at the same second with a LOWER
|
||
/// id does sort strictly ahead of the head, so it advances.
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn channel_head_checked_accepts_same_second_lower_id() {
|
||
let pool = setup_pool().await;
|
||
let community = CommunityId::from_uuid(make_test_community(&pool).await);
|
||
let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await;
|
||
|
||
let (lower, higher) = same_second_ordered_pair(1000);
|
||
// Seed the higher-id event as the head first.
|
||
insert_canvas_head_checked(
|
||
&pool,
|
||
community,
|
||
&higher,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectNoHead,
|
||
)
|
||
.await
|
||
.expect("first canvas is higher-id head");
|
||
|
||
// Lower id at the same second sorts strictly ahead → advances.
|
||
let (_, status) = insert_canvas_head_checked(
|
||
&pool,
|
||
community,
|
||
&lower,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectedHead(higher.id.as_bytes().as_slice()),
|
||
)
|
||
.await
|
||
.expect("same-second lower-id edit");
|
||
assert_eq!(status, ChannelHeadWriteStatus::Inserted);
|
||
}
|
||
|
||
/// Head-advancement guarantee: a behind-clock writer whose `created_at`
|
||
/// predates the head cannot supersede it, even with a matching precondition.
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn channel_head_checked_rejects_behind_clock_writer() {
|
||
let pool = setup_pool().await;
|
||
let community = CommunityId::from_uuid(make_test_community(&pool).await);
|
||
let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await;
|
||
let head = make_canvas_event_at("# Head", 2000);
|
||
insert_canvas_head_checked(
|
||
&pool,
|
||
community,
|
||
&head,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectNoHead,
|
||
)
|
||
.await
|
||
.expect("head at t=2000");
|
||
|
||
// Writer's clock is behind — created_at earlier than head.
|
||
let behind = make_canvas_event_at("# Behind clock", 1100);
|
||
let (_, status) = insert_canvas_head_checked(
|
||
&pool,
|
||
community,
|
||
&behind,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectedHead(head.id.as_bytes().as_slice()),
|
||
)
|
||
.await
|
||
.expect("behind-clock edit");
|
||
assert_eq!(status, ChannelHeadWriteStatus::SupersedeFailed);
|
||
}
|
||
/// Derives the advisory-lock key for a canvas coordinate, matching the key
|
||
/// computed inside `insert_canvas_head_checked` and
|
||
/// `soft_delete_event_and_update_thread`.
|
||
fn canvas_lock_key(community: CommunityId, channel: Uuid) -> i64 {
|
||
crate::store::replaceable::event_replacement_lock_key(
|
||
community,
|
||
buzz_core::kind::KIND_CANVAS as i32,
|
||
&[],
|
||
Some(channel.as_bytes().as_slice()),
|
||
)
|
||
}
|
||
|
||
/// Polls `pg_locks` until at least `min_waiters` sessions are queued as
|
||
/// ungranted waiters on the given `int8` advisory lock key, or until
|
||
/// `timeout` elapses.
|
||
///
|
||
/// Returns `Ok(())` once the condition is met. Panics if the timeout
|
||
/// expires before the required number of waiters appear, or if any waiter
|
||
/// task completes (exits the lock wait) before the condition is met.
|
||
///
|
||
/// Postgres stores `pg_advisory_xact_lock(int8)` rows in `pg_locks` as
|
||
/// `(locktype='advisory', classid=(key>>32)::oid, objid=(key & 0xffffffff)::oid)`.
|
||
/// The `classid`/`objid` columns are of type `oid` (unsigned 32-bit integer).
|
||
async fn wait_for_advisory_waiters(
|
||
pool: &PgPool,
|
||
lock_key: i64,
|
||
min_waiters: i64,
|
||
timeout: std::time::Duration,
|
||
) {
|
||
let deadline = std::time::Instant::now() + timeout;
|
||
// Split the int8 key into its two oid halves exactly as Postgres does.
|
||
// Cast each half to int8 first (no overflow), then let sqlx bind them
|
||
// as bigint; Postgres compares oid columns via implicit cast.
|
||
let classid = ((lock_key as u64) >> 32) as i64;
|
||
let objid = ((lock_key as u64) & 0xffff_ffff) as i64;
|
||
loop {
|
||
let waiters: i64 = sqlx::query_scalar(
|
||
"SELECT count(*) FROM pg_locks \
|
||
WHERE locktype = 'advisory' \
|
||
AND classid = $1::oid \
|
||
AND objid = $2::oid \
|
||
AND NOT granted",
|
||
)
|
||
.bind(classid)
|
||
.bind(objid)
|
||
.fetch_one(pool)
|
||
.await
|
||
.expect("pg_locks waiter query");
|
||
|
||
if waiters >= min_waiters {
|
||
return;
|
||
}
|
||
|
||
assert!(
|
||
std::time::Instant::now() < deadline,
|
||
"timed out waiting for {min_waiters} advisory waiters on key {lock_key:#x}; \
|
||
only {waiters} appeared — the production lock was likely removed"
|
||
);
|
||
|
||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||
}
|
||
}
|
||
|
||
/// Tombstone regression: H → A → soft-delete A → replay A-on-H must return
|
||
/// `RevisionMismatch`. A must remain deleted and H must remain the live head.
|
||
///
|
||
/// Mutation oracle: the post-insert conflict branch returning `Duplicate`
|
||
/// unconditionally makes this test fail with status `Duplicate` instead of
|
||
/// `RevisionMismatch`.
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn channel_head_checked_deleted_replay_returns_revision_mismatch() {
|
||
let pool = setup_pool().await;
|
||
let community = CommunityId::from_uuid(make_test_community(&pool).await);
|
||
let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await;
|
||
|
||
// H: seed the initial head.
|
||
let head = make_canvas_event_at("# Head", 1000);
|
||
insert_canvas_head_checked(
|
||
&pool,
|
||
community,
|
||
&head,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectNoHead,
|
||
)
|
||
.await
|
||
.expect("seed head H");
|
||
let head_id = head.id.to_bytes().to_vec();
|
||
|
||
// A: insert a second revision.
|
||
let a = make_canvas_event_at("# A", 1001);
|
||
insert_canvas_head_checked(
|
||
&pool,
|
||
community,
|
||
&a,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectedHead(&head_id),
|
||
)
|
||
.await
|
||
.expect("insert A");
|
||
let a_id = a.id.to_bytes().to_vec();
|
||
|
||
// Soft-delete A so H becomes the live head again.
|
||
soft_delete_event_and_update_thread(&pool, community, &a_id, None, None)
|
||
.await
|
||
.expect("soft-delete A");
|
||
|
||
// Verify A is deleted and H is the live head before replay.
|
||
let a_deleted: Option<bool> = sqlx::query_scalar(
|
||
"SELECT deleted_at IS NOT NULL FROM events \
|
||
WHERE community_id = $1 AND id = $2",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(&a_id)
|
||
.fetch_optional(&pool)
|
||
.await
|
||
.expect("check A deleted");
|
||
assert_eq!(
|
||
a_deleted,
|
||
Some(true),
|
||
"A must be soft-deleted before replay"
|
||
);
|
||
|
||
// Replay byte-identical A against live head H — must not return Duplicate.
|
||
let (_, status) = insert_canvas_head_checked(
|
||
&pool,
|
||
community,
|
||
&a,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectedHead(&head_id),
|
||
)
|
||
.await
|
||
.expect("replay A");
|
||
assert_eq!(
|
||
status,
|
||
ChannelHeadWriteStatus::RevisionMismatch,
|
||
"replay of a deleted event must return RevisionMismatch, not Duplicate"
|
||
);
|
||
|
||
// A must still be deleted after the replay attempt.
|
||
let a_still_deleted: Option<bool> = sqlx::query_scalar(
|
||
"SELECT deleted_at IS NOT NULL FROM events \
|
||
WHERE community_id = $1 AND id = $2",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(&a_id)
|
||
.fetch_optional(&pool)
|
||
.await
|
||
.expect("check A still deleted");
|
||
assert_eq!(
|
||
a_still_deleted,
|
||
Some(true),
|
||
"A must remain deleted after replay"
|
||
);
|
||
|
||
// H must remain the canonical live head.
|
||
let live_head: Vec<u8> = sqlx::query_scalar(
|
||
"SELECT id FROM events \
|
||
WHERE community_id = $1 AND kind = $2 AND channel_id = $3 \
|
||
AND deleted_at IS NULL \
|
||
ORDER BY created_at DESC, id ASC LIMIT 1",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(buzz_core::kind::KIND_CANVAS as i32)
|
||
.bind(channel)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("read live head");
|
||
assert_eq!(
|
||
live_head, head_id,
|
||
"H must remain the live head after failed replay"
|
||
);
|
||
}
|
||
|
||
/// Race soundness: two concurrent authors both assert the same live head and
|
||
/// both sign strictly-advancing writes. The per-(community, channel) advisory
|
||
/// lock must serialize check+insert so exactly one wins (`Inserted`) and the
|
||
/// other observes the moved head (`RevisionMismatch`). Exactly one write may
|
||
/// become the visible head; the loser must not appear as a live row.
|
||
///
|
||
/// Causal oracle: an external connection holds the exact advisory key before
|
||
/// either writer starts. Both writers are spawned and `pg_locks` is polled
|
||
/// until both appear as ungranted waiters on this exact key. Only then is
|
||
/// the blocker released. This is a causal proof: both sessions have opened
|
||
/// their transactions and reached `pg_advisory_xact_lock` before the
|
||
/// interleaving begins — the outcome is deterministic rather than
|
||
/// scheduler-dependent.
|
||
///
|
||
/// Mutation oracle: removing `pg_advisory_xact_lock` from
|
||
/// `insert_canvas_head_checked` means neither writer queues as a waiter;
|
||
/// `wait_for_advisory_waiters` times out, or both writers read the same head,
|
||
/// both insert, and `head_count` becomes 3 instead of 2.
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn channel_head_checked_concurrent_authors_only_one_advances() {
|
||
let pool = setup_pool().await;
|
||
let community = CommunityId::from_uuid(make_test_community(&pool).await);
|
||
let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await;
|
||
|
||
let base = make_canvas_event_at("# Base", 1000);
|
||
insert_canvas_head_checked(
|
||
&pool,
|
||
community,
|
||
&base,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectNoHead,
|
||
)
|
||
.await
|
||
.expect("seed base head");
|
||
let base_id = base.id.to_bytes().to_vec();
|
||
|
||
// Acquire the exact advisory key from an external connection so both
|
||
// writers block immediately at pg_advisory_xact_lock.
|
||
let lock_key = canvas_lock_key(community, channel);
|
||
let mut blocker = pool.begin().await.expect("blocker tx");
|
||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||
.bind(lock_key)
|
||
.execute(&mut *blocker)
|
||
.await
|
||
.expect("blocker acquires key");
|
||
|
||
// Both edits assert `base` as their head and are timestamped strictly
|
||
// ahead of it (writer discipline), so neither trips SupersedeFailed —
|
||
// only the advisory-lock serialization decides the winner.
|
||
let a = make_canvas_event_at("# Author A", 1001);
|
||
let b = make_canvas_event_at("# Author B", 1002);
|
||
|
||
let (pool_a, pool_b) = (pool.clone(), pool.clone());
|
||
let (id_a, id_b) = (base_id.clone(), base_id.clone());
|
||
let ta = tokio::spawn(async move {
|
||
insert_canvas_head_checked(
|
||
&pool_a,
|
||
community,
|
||
&a,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectedHead(&id_a),
|
||
)
|
||
.await
|
||
.map(|(stored, status)| (stored.event.id.to_bytes().to_vec(), status))
|
||
});
|
||
let tb = tokio::spawn(async move {
|
||
insert_canvas_head_checked(
|
||
&pool_b,
|
||
community,
|
||
&b,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectedHead(&id_b),
|
||
)
|
||
.await
|
||
.map(|(stored, status)| (stored.event.id.to_bytes().to_vec(), status))
|
||
});
|
||
|
||
// Wait until both writers are observed as ungranted advisory-lock
|
||
// waiters in pg_locks. This is a causal proof that both sessions have
|
||
// opened their transactions and are blocked at pg_advisory_xact_lock
|
||
// before we release the blocker.
|
||
wait_for_advisory_waiters(&pool, lock_key, 2, std::time::Duration::from_secs(5)).await;
|
||
|
||
// Release — both writers unblock and race for the advisory lock.
|
||
blocker.rollback().await.expect("release blocker");
|
||
|
||
let (id_a, status_a) = ta.await.expect("join A").expect("call A");
|
||
let (id_b, status_b) = tb.await.expect("join B").expect("call B");
|
||
|
||
let (winner_id, loser_id) = if status_a == ChannelHeadWriteStatus::Inserted {
|
||
assert_eq!(status_b, ChannelHeadWriteStatus::RevisionMismatch);
|
||
(id_a, id_b)
|
||
} else {
|
||
assert_eq!(status_a, ChannelHeadWriteStatus::RevisionMismatch);
|
||
assert_eq!(status_b, ChannelHeadWriteStatus::Inserted);
|
||
(id_b, id_a)
|
||
};
|
||
|
||
// Exactly one new canvas row (beyond the base) was committed.
|
||
let head_count: i64 = sqlx::query_scalar(
|
||
"SELECT count(*) FROM events \
|
||
WHERE community_id = $1 AND kind = $2 AND channel_id = $3 AND deleted_at IS NULL",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(buzz_core::kind::KIND_CANVAS as i32)
|
||
.bind(channel)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("count committed canvas rows");
|
||
assert_eq!(head_count, 2, "base plus exactly one winning edit");
|
||
|
||
// The canonical live head must be the winner's event.
|
||
let live_head: Vec<u8> = sqlx::query_scalar(
|
||
"SELECT id FROM events \
|
||
WHERE community_id = $1 AND kind = $2 AND channel_id = $3 AND deleted_at IS NULL \
|
||
ORDER BY created_at DESC, id ASC LIMIT 1",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(buzz_core::kind::KIND_CANVAS as i32)
|
||
.bind(channel)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("read live head");
|
||
assert_eq!(
|
||
live_head, winner_id,
|
||
"live head must be the Inserted writer's event"
|
||
);
|
||
|
||
// The losing writer's event must not be present as a live row.
|
||
let loser_count: i64 = sqlx::query_scalar(
|
||
"SELECT count(*) FROM events \
|
||
WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(&loser_id)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("check loser absent");
|
||
assert_eq!(
|
||
loser_count, 0,
|
||
"the losing writer's event must not be a live row"
|
||
);
|
||
}
|
||
|
||
/// Race soundness for first-create: two writers both assert `ExpectNoHead`
|
||
/// simultaneously. Exactly one must be `Inserted`; the other gets
|
||
/// `RevisionMismatch`. The loser must not be persisted.
|
||
///
|
||
/// Causal oracle: an external connection holds the exact advisory key before
|
||
/// either writer starts. Both writers are spawned and `pg_locks` is polled
|
||
/// until both appear as ungranted waiters on this exact key, proving both
|
||
/// transactions have opened and reached `pg_advisory_xact_lock`. Only then
|
||
/// is the blocker released.
|
||
///
|
||
/// Mutation oracle: removing `pg_advisory_xact_lock` from
|
||
/// `insert_canvas_head_checked` means neither writer queues as a waiter;
|
||
/// `wait_for_advisory_waiters` times out, or both writers read `None` for
|
||
/// the head, both pass `ExpectNoHead`, both insert, and `head_count` becomes 2.
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn channel_head_checked_concurrent_first_create_only_one_wins() {
|
||
let pool = setup_pool().await;
|
||
let community = CommunityId::from_uuid(make_test_community(&pool).await);
|
||
let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await;
|
||
|
||
// Acquire the exact advisory key before either writer starts.
|
||
let lock_key = canvas_lock_key(community, channel);
|
||
let mut blocker = pool.begin().await.expect("blocker tx");
|
||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||
.bind(lock_key)
|
||
.execute(&mut *blocker)
|
||
.await
|
||
.expect("blocker acquires key");
|
||
|
||
let a = make_canvas_event_at("# First A", 1000);
|
||
let b = make_canvas_event_at("# First B", 1001);
|
||
|
||
let (pool_a, pool_b) = (pool.clone(), pool.clone());
|
||
let ta = tokio::spawn(async move {
|
||
insert_canvas_head_checked(
|
||
&pool_a,
|
||
community,
|
||
&a,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectNoHead,
|
||
)
|
||
.await
|
||
.map(|(stored, status)| (stored.event.id.to_bytes().to_vec(), status))
|
||
});
|
||
let tb = tokio::spawn(async move {
|
||
insert_canvas_head_checked(
|
||
&pool_b,
|
||
community,
|
||
&b,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectNoHead,
|
||
)
|
||
.await
|
||
.map(|(stored, status)| (stored.event.id.to_bytes().to_vec(), status))
|
||
});
|
||
|
||
// Wait until both writers are observed as ungranted advisory-lock
|
||
// waiters in pg_locks. This is a causal proof that both sessions have
|
||
// opened their transactions and are blocked at pg_advisory_xact_lock
|
||
// before we release the blocker.
|
||
wait_for_advisory_waiters(&pool, lock_key, 2, std::time::Duration::from_secs(5)).await;
|
||
|
||
// Release — both race for the advisory lock.
|
||
blocker.rollback().await.expect("release blocker");
|
||
|
||
let (id_a, status_a) = ta.await.expect("join A").expect("call A");
|
||
let (id_b, status_b) = tb.await.expect("join B").expect("call B");
|
||
|
||
let (winner_id, loser_id) = if status_a == ChannelHeadWriteStatus::Inserted {
|
||
assert_eq!(status_b, ChannelHeadWriteStatus::RevisionMismatch);
|
||
(id_a, id_b)
|
||
} else {
|
||
assert_eq!(status_a, ChannelHeadWriteStatus::RevisionMismatch);
|
||
assert_eq!(status_b, ChannelHeadWriteStatus::Inserted);
|
||
(id_b, id_a)
|
||
};
|
||
|
||
// Exactly one canvas row exists.
|
||
let head_count: i64 = sqlx::query_scalar(
|
||
"SELECT count(*) FROM events \
|
||
WHERE community_id = $1 AND kind = $2 AND channel_id = $3 AND deleted_at IS NULL",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(buzz_core::kind::KIND_CANVAS as i32)
|
||
.bind(channel)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("count canvas rows");
|
||
assert_eq!(head_count, 1, "exactly one first-create winner");
|
||
|
||
// The stored head is the winner.
|
||
let head_id: Vec<u8> = sqlx::query_scalar(
|
||
"SELECT id FROM events \
|
||
WHERE community_id = $1 AND kind = $2 AND channel_id = $3 AND deleted_at IS NULL \
|
||
ORDER BY created_at DESC, id ASC LIMIT 1",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(buzz_core::kind::KIND_CANVAS as i32)
|
||
.bind(channel)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("read head");
|
||
assert_eq!(head_id, winner_id, "stored head must be the winner");
|
||
|
||
// The loser must not appear as a live row.
|
||
let loser_count: i64 = sqlx::query_scalar(
|
||
"SELECT count(*) FROM events \
|
||
WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(&loser_id)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("check loser absent");
|
||
assert_eq!(
|
||
loser_count, 0,
|
||
"the losing first-create writer must not be a live row"
|
||
);
|
||
}
|
||
|
||
/// Serialization coverage: the relay-facing
|
||
/// `Db::insert_event_with_thread_metadata` path for an untagged
|
||
/// kind-40100 append must acquire the same `(community, kind, channel)`
|
||
/// advisory key as tagged writes so the two cannot interleave on the head
|
||
/// read.
|
||
///
|
||
/// Proof: an external connection holds the advisory key; the `Db` write
|
||
/// task is spawned. Since the production `Db` method acquires the same
|
||
/// key, a waiter appears in `pg_locks` while the holder still owns it.
|
||
/// After the holder releases, the task completes and the row is committed.
|
||
///
|
||
/// Mutation oracle: removing the centralized canvas-lock acquisition from
|
||
/// the admitted transaction path leaves no waiter on this key, so
|
||
/// `wait_for_advisory_waiters` times out and the test fails.
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn db_untagged_canvas_append_serializes_on_advisory_key() {
|
||
let pool = setup_pool().await;
|
||
let db = crate::Db::from_pool(pool.clone());
|
||
let community = CommunityId::from_uuid(make_test_community(&pool).await);
|
||
let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await;
|
||
let lock_key = canvas_lock_key(community, channel);
|
||
|
||
let mut holder = pool.begin().await.expect("holder tx");
|
||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||
.bind(lock_key)
|
||
.execute(&mut *holder)
|
||
.await
|
||
.expect("holder acquires key");
|
||
|
||
let event = make_canvas_event_at("# Untagged", 1000);
|
||
let write_task = tokio::spawn(async move {
|
||
db.insert_event_with_thread_metadata(community, &event, Some(channel), None)
|
||
.await
|
||
});
|
||
|
||
wait_for_advisory_waiters(&pool, lock_key, 1, std::time::Duration::from_secs(5)).await;
|
||
holder.rollback().await.expect("release holder");
|
||
|
||
let (stored, was_inserted) = write_task
|
||
.await
|
||
.expect("join write task")
|
||
.expect("untagged canvas insert");
|
||
assert!(
|
||
was_inserted,
|
||
"untagged canvas write must be inserted after holder releases"
|
||
);
|
||
let row_count: i64 =
|
||
sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id = $1 AND id = $2")
|
||
.bind(community.as_uuid())
|
||
.bind(stored.event.id.as_bytes().as_slice())
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("count untagged row");
|
||
assert_eq!(row_count, 1, "untagged canvas row must be committed");
|
||
}
|
||
|
||
/// Serialization coverage: `soft_delete_event_and_update_thread` for a
|
||
/// kind-40100 event must acquire the same advisory key as tagged writes.
|
||
///
|
||
/// Proof: an external connection holds the advisory key; the delete task
|
||
/// is spawned and blocks while the holder has it — `is_finished()` returns
|
||
/// false. After the holder releases, the task completes and the row is
|
||
/// soft-deleted.
|
||
///
|
||
/// Mutation oracle: removing the advisory-lock branch from
|
||
/// `soft_delete_event_and_update_thread` lets the delete bypass the key.
|
||
/// The task finishes immediately while the holder still holds the key —
|
||
/// `assert!(!delete_task.is_finished())` fails.
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn channel_head_canvas_deletion_serializes_on_advisory_key() {
|
||
let pool = setup_pool().await;
|
||
let community = CommunityId::from_uuid(make_test_community(&pool).await);
|
||
let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await;
|
||
let lock_key = canvas_lock_key(community, channel);
|
||
|
||
// Insert a canvas event to delete.
|
||
let event = make_canvas_event_at("# To delete", 1000);
|
||
insert_canvas_head_checked(
|
||
&pool,
|
||
community,
|
||
&event,
|
||
channel,
|
||
ChannelHeadPrecondition::ExpectNoHead,
|
||
)
|
||
.await
|
||
.expect("seed canvas for deletion test");
|
||
let event_id = event.id.to_bytes().to_vec();
|
||
|
||
// Hold the exact advisory key on a dedicated connection.
|
||
let mut holder = pool.begin().await.expect("holder tx");
|
||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||
.bind(lock_key)
|
||
.execute(&mut *holder)
|
||
.await
|
||
.expect("holder acquires key");
|
||
|
||
// Spawn the delete task — it should block at pg_advisory_xact_lock.
|
||
let pool_del = pool.clone();
|
||
let eid = event_id.clone();
|
||
let delete_task = tokio::spawn(async move {
|
||
soft_delete_event_and_update_thread(&pool_del, community, &eid, None, None).await
|
||
});
|
||
|
||
// Give the delete task time to open its transaction and reach the lock.
|
||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||
|
||
// The delete task must NOT have finished while the holder has the key.
|
||
assert!(
|
||
!delete_task.is_finished(),
|
||
"canvas soft-delete must block on the advisory key while holder has it; \
|
||
the task finished immediately, meaning the lock was not acquired"
|
||
);
|
||
|
||
// Release the holder — the blocked delete can now acquire the key.
|
||
holder.rollback().await.expect("release holder");
|
||
|
||
// The delete must now complete successfully.
|
||
let deleted = delete_task
|
||
.await
|
||
.expect("join delete task")
|
||
.expect("canvas soft-delete");
|
||
assert!(
|
||
deleted,
|
||
"canvas event must be deleted after holder releases"
|
||
);
|
||
|
||
let is_deleted: bool = sqlx::query_scalar(
|
||
"SELECT deleted_at IS NOT NULL FROM events \
|
||
WHERE community_id = $1 AND id = $2",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(&event_id)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("check deleted_at");
|
||
assert!(
|
||
is_deleted,
|
||
"canvas event must have deleted_at set after soft-delete"
|
||
);
|
||
}
|
||
|
||
fn make_mentioning_event(mentioned_hex: &str) -> nostr::Event {
|
||
EventBuilder::new(Kind::Custom(9), "mentions someone")
|
||
.tags(vec![Tag::parse(["p", mentioned_hex]).expect("p tag")])
|
||
.sign_with_keys(&Keys::generate())
|
||
.expect("sign mentioning event")
|
||
}
|
||
|
||
async fn install_mention_failure_injection(pool: &PgPool) {
|
||
sqlx::query(
|
||
"CREATE FUNCTION reject_test_mention() RETURNS trigger AS $$ \
|
||
BEGIN RAISE EXCEPTION 'injected mention failure'; END; \
|
||
$$ LANGUAGE plpgsql",
|
||
)
|
||
.execute(pool)
|
||
.await
|
||
.expect("create failure function");
|
||
sqlx::query(
|
||
"CREATE TRIGGER reject_test_mention BEFORE INSERT ON event_mentions \
|
||
FOR EACH ROW EXECUTE FUNCTION reject_test_mention()",
|
||
)
|
||
.execute(pool)
|
||
.await
|
||
.expect("install failure injection");
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn insert_event_mention_failure_rolls_back_the_event_insert() {
|
||
let (scratch_pool, _scratch) = ScratchDb::migrated(
|
||
&crate::test_support::database_url(),
|
||
"event_mention_rollback",
|
||
)
|
||
.await;
|
||
install_mention_failure_injection(&scratch_pool).await;
|
||
|
||
let db = Db::from_pool(scratch_pool.clone());
|
||
let community = CommunityId::from_uuid(make_test_community(&scratch_pool).await);
|
||
let mentioned = Keys::generate();
|
||
let event = make_mentioning_event(&mentioned.public_key().to_hex());
|
||
|
||
let error = db
|
||
.insert_event(community, &event, None)
|
||
.await
|
||
.expect_err("mention-indexing failure must fail the whole event insert");
|
||
assert!(
|
||
error.to_string().contains("injected mention failure"),
|
||
"unexpected error: {error}"
|
||
);
|
||
|
||
let persisted: i64 =
|
||
sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id = $1 AND id = $2")
|
||
.bind(community.as_uuid())
|
||
.bind(event.id.as_bytes().as_slice())
|
||
.fetch_one(&scratch_pool)
|
||
.await
|
||
.expect("count event rows");
|
||
assert_eq!(
|
||
persisted, 0,
|
||
"event must not persist when mention indexing fails atomically"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn insert_event_mention_success_commits_atomically_with_event() {
|
||
let pool = setup_pool().await;
|
||
let db = Db::from_pool(pool.clone());
|
||
let community = CommunityId::from_uuid(make_test_community(&pool).await);
|
||
let mentioned = Keys::generate();
|
||
let event = make_mentioning_event(&mentioned.public_key().to_hex());
|
||
|
||
let (stored, was_inserted) = db
|
||
.insert_event(community, &event, None)
|
||
.await
|
||
.expect("insert event with mention");
|
||
assert!(was_inserted);
|
||
assert_eq!(stored.event.id, event.id);
|
||
|
||
let mention_count: i64 = sqlx::query_scalar(
|
||
"SELECT count(*) FROM event_mentions WHERE community_id = $1 AND event_id = $2",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(event.id.as_bytes().as_slice())
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("count mention rows");
|
||
assert_eq!(
|
||
mention_count, 1,
|
||
"mention row must be committed atomically alongside the event"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn insert_event_with_thread_metadata_mention_failure_rolls_back_the_event_insert() {
|
||
let (scratch_pool, _scratch) = ScratchDb::migrated(
|
||
&crate::test_support::database_url(),
|
||
"event_thread_mention_rollback",
|
||
)
|
||
.await;
|
||
install_mention_failure_injection(&scratch_pool).await;
|
||
|
||
let db = Db::from_pool(scratch_pool.clone());
|
||
let community_uuid = make_test_community(&scratch_pool).await;
|
||
let community = CommunityId::from_uuid(community_uuid);
|
||
let channel = make_test_channel(&scratch_pool, community_uuid, None).await;
|
||
|
||
let mentioned = Keys::generate();
|
||
let event = make_mentioning_event(&mentioned.public_key().to_hex());
|
||
let event_ts = DateTime::from_timestamp(event.created_at.as_secs() as i64, 0)
|
||
.expect("valid timestamp");
|
||
|
||
let error = db
|
||
.insert_event_with_thread_metadata(
|
||
community,
|
||
&event,
|
||
Some(channel),
|
||
Some(ThreadMetadataParams {
|
||
event_id: event.id.as_bytes(),
|
||
event_created_at: event_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("mention-indexing failure must fail the whole event insert");
|
||
assert!(
|
||
error.to_string().contains("injected mention failure"),
|
||
"unexpected error: {error}"
|
||
);
|
||
|
||
let persisted: i64 =
|
||
sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id = $1 AND id = $2")
|
||
.bind(community_uuid)
|
||
.bind(event.id.as_bytes().as_slice())
|
||
.fetch_one(&scratch_pool)
|
||
.await
|
||
.expect("count event rows");
|
||
assert_eq!(
|
||
persisted, 0,
|
||
"event must not persist when mention indexing fails atomically"
|
||
);
|
||
|
||
let thread_meta_count: i64 = sqlx::query_scalar(
|
||
"SELECT count(*) FROM thread_metadata WHERE community_id = $1 AND event_id = $2",
|
||
)
|
||
.bind(community_uuid)
|
||
.bind(event.id.as_bytes().as_slice())
|
||
.fetch_one(&scratch_pool)
|
||
.await
|
||
.expect("count thread metadata rows");
|
||
assert_eq!(
|
||
thread_meta_count, 0,
|
||
"thread metadata must not persist when mention indexing fails atomically"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn insert_event_with_thread_metadata_mention_success_commits_atomically() {
|
||
let pool = setup_pool().await;
|
||
let db = Db::from_pool(pool.clone());
|
||
let community_uuid = make_test_community(&pool).await;
|
||
let community = CommunityId::from_uuid(community_uuid);
|
||
let channel = make_test_channel(&pool, community_uuid, None).await;
|
||
|
||
let mentioned = Keys::generate();
|
||
let event = make_mentioning_event(&mentioned.public_key().to_hex());
|
||
let event_ts = DateTime::from_timestamp(event.created_at.as_secs() as i64, 0)
|
||
.expect("valid timestamp");
|
||
|
||
let (stored, was_inserted) = db
|
||
.insert_event_with_thread_metadata(
|
||
community,
|
||
&event,
|
||
Some(channel),
|
||
Some(ThreadMetadataParams {
|
||
event_id: event.id.as_bytes(),
|
||
event_created_at: event_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 thread event with mention");
|
||
assert!(was_inserted);
|
||
assert_eq!(stored.event.id, event.id);
|
||
|
||
let mention_count: i64 = sqlx::query_scalar(
|
||
"SELECT count(*) FROM event_mentions WHERE community_id = $1 AND event_id = $2",
|
||
)
|
||
.bind(community_uuid)
|
||
.bind(event.id.as_bytes().as_slice())
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("count mention rows");
|
||
assert_eq!(
|
||
mention_count, 1,
|
||
"mention row must be committed atomically alongside the thread event"
|
||
);
|
||
}
|
||
}
|