mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
## Why Finish the replaceable-event slice of [tracker #2](https://github.com/TheSentinel454/buzz/issues/2) and [domain issue #6](https://github.com/TheSentinel454/buzz/issues/6) without disturbing the runtime/store boundary established by #6660 and #6668. PR #6700 has merged; this PR now targets current main containing its replacement lock and transaction observability. ## What - Move `Db::replace_addressable_event`, its SQL, lock/transaction instrumentation, and focused addressable/parameterized replacement tests from `lib.rs` to `replaceable.rs` - Preserve the transaction-required parameterized API, replacement ordering, rollback semantics, mention indexing, and exactly one datastore span per public operation - Keep implementation and focused PostgreSQL tests co-located; follow-up review removed the dedicated replaceable ownership source guard as low value ## Stack - Exact base: main atf249710331- Exact head: codex/issue-6-finish-replaceable-store atda018405cc- Tracker: https://github.com/TheSentinel454/buzz/issues/2 - Domain: https://github.com/TheSentinel454/buzz/issues/6 - Test/span acceptance: https://github.com/TheSentinel454/buzz/issues/17 and https://github.com/TheSentinel454/buzz/issues/19 ## Non-goals - No SQL, schema, retry, timeout, lock ordering, transaction boundary, or client-visible behavior changes - No store traits, domain-handle redesign, `PgExecutor` migration, raw pool accessor, new crate, or directory reorganization - No changes to, retargeting of, or merge action on PR #6700 ## Risk Assessment Low. This is a mechanical ownership move with unchanged signatures and SQL. The primary review risk is losing or nesting instrumentation, covered by exact moved PostgreSQL tests and the cumulative production-diff audit. ## Blox Verification Author workstation: `buzz-tornquist-issue-2-store-stack` (`2046520`), exact head `2de5444e14606ce6912d1bb932bc88bffa379a77`. - `cargo fmt --all --check` — passed - `cargo clippy -p buzz-db -p buzz-relay --all-targets -- -D warnings` — passed - `cargo test -p buzz-db --quiet` — 108 passed, 200 ignored; both integration source guards passed - Native PostgreSQL: `cargo test -p buzz-db replaceable::tests:: -- --ignored --test-threads=1` — 11 passed - `cargo test -p buzz-relay --lib api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo -- --exact --test-threads=1` — passed - Full serial relay suite was also exercised: 908 tests passed but the existing load-sensitive mesh demo test returned 504 under suite load, matching PR #6700's documented baseline; it passed in isolation and this diff does not touch that subsystem Independent exact-head Blox review: `buzz-tornquist-pr-6777-review` (`2047422`) at `2de5444e14606ce6912d1bb932bc88bffa379a77`. - Review findings — none (critical, important, or minor) - `cargo fmt --all --check` and the ownership guard — passed - `cargo clippy -p buzz-db -p buzz-relay --all-targets -- -D warnings` — passed - `cargo test -p buzz-db --quiet` — 108 passed, 200 ignored; source guards passed - Native PostgreSQL moved suite — 11 passed - `cargo test -p buzz-relay --lib` — 909 passed, 48 ignored Generated with Codex ## Superseded pre-comment restack verification PR #6700 merged before publication completed. This layer was restacked onto current main through the exact parent named above; the final cumulative tip is 2ddcc8a29f95c8c8c9cb87bb6cf59335f2190fae. Cumulative author gates passed: formatting and diff checks; buzz-db and buzz-relay all-target clippy with -D warnings; DB lib 111 passed / 200 ignored; ownership 22/22; observability 1/1; the full isolated PostgreSQL domain matrix; and relay lib 910 passed / 49 ignored. - Workstation: `buzz-tornquist-pr-6777-final-review` (`2057617`), fresh shallow checkout - Base: `f24971033178926153b49d320bd876d15d9cb2bf` - Head: `ffbeaaf00810aa359ab85818ff4820f92263e45f` - Findings: none Reviewed `base..head` for the replaceable-store extraction. The addressable replacement SQL, stale/duplicate outcomes, advisory-lock ordering, transaction rollback/commit boundaries, mention-index atomicity, and public `Db` signature are retained in `replaceable.rs`. The PR #6700 transaction timer remains around the same logical transaction through `TransactionTimer::observe`, and the public operation has exactly one datastore span. Focused addressable and parameterized tests moved with the implementation; the ownership guard excludes duplicates from `lib.rs`. Verification: format and diff checks passed; `buzz-db --all-targets` clippy passed with `-D warnings`; DB lib tests passed (111 passed, 200 PostgreSQL tests ignored); ownership (1/1) and observability (1/1) guards passed; all 11 replaceable PostgreSQL tests passed on native PostgreSQL 17 with migrations 1-32 successful; relay lib test target compiled successfully. Final worktree was detached at the exact head and clean. Complete evidence archive SHA-256: `e37f92bbde4e2343196085095b3093b1bc78196be58bc86826325e0e1639d7ea`. ## Comment-addressed restack Review follow-up removed the dedicated `replaceable_store_has_single_ownership` source test as requested. No production code changed. - Exact base: `f24971033178926153b49d320bd876d15d9cb2bf` - Exact head: `da018405cc83605362125c2d5e5a3f91492431ac` - Final cumulative tip: `6fa2f104d42c6ba85bdf62e7ccb74ceaf4a84f67` - Focused PR verification: formatting and diff checks passed; strict `buzz-db`/`buzz-relay` Clippy passed; DB lib 111 passed / 200 ignored; all 11 replaceable PostgreSQL tests passed. - Cumulative Blox gate: formatting and diff checks; strict `buzz-db`/`buzz-relay` Clippy; DB lib 111 passed / 200 ignored; ownership 21/21; observability 1/1; every moved PostgreSQL test; relay lib 910 passed / 49 ignored. - Independent re-review at this exact head: no findings; fresh exact-parent/head Blox review passed fmt/diff, strict `buzz-db` Clippy, DB lib 111 passed / 200 ignored, manual one-owner/one-span replacement checks, observability, 11 replaceable PostgreSQL tests, and relay compilation. --------- Signed-off-by: tornquist <tornquist@squareup.com>
1754 lines
66 KiB
Rust
1754 lines
66 KiB
Rust
//! Replaceable-event persistence and coordinate locking.
|
||
|
||
use buzz_core::{CommunityId, StoredEvent};
|
||
use buzz_datastore_tracing::datastore_span;
|
||
use chrono::{DateTime, Utc};
|
||
use sqlx::{Acquire, Postgres, Transaction};
|
||
use uuid::Uuid;
|
||
|
||
use crate::observability::{self, LockType, TransactionOperation};
|
||
use crate::{Db, DbError, Result};
|
||
|
||
/// Result category for a parameterized-replaceable event write.
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
pub enum ParameterizedReplaceStatus {
|
||
/// The incoming event was inserted as the coordinate's live head.
|
||
Inserted,
|
||
/// The exact event was already accepted.
|
||
Duplicate,
|
||
/// A newer event, or lower-ID same-second event, already dominates it.
|
||
Superseded,
|
||
/// A requested current revision has no live coordinate head.
|
||
RevisionMissing,
|
||
/// The live coordinate head differs from the requested revision.
|
||
RevisionMismatch,
|
||
/// An exact replay was required, but the event is not the live head.
|
||
ReplayOnlyMiss,
|
||
}
|
||
|
||
/// Structural precondition for a parameterized-replaceable write.
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
pub enum ParameterizedReplacePrecondition<'a> {
|
||
/// Apply normal NIP-33 ordering without a revision precondition.
|
||
Unconditional,
|
||
/// Require the live head to match this validated event ID.
|
||
ExpectedRevision(&'a [u8]),
|
||
/// Accept only an exact live-head replay and perform no mutation otherwise.
|
||
ExactReplayOnly,
|
||
}
|
||
|
||
/// Result of a transaction-bound parameterized-replaceable event write.
|
||
#[derive(Clone, Debug)]
|
||
pub struct ParameterizedReplaceResult {
|
||
/// Stored representation of the submitted event.
|
||
pub event: StoredEvent,
|
||
/// Whether and why the coordinate accepted the event.
|
||
pub status: ParameterizedReplaceStatus,
|
||
}
|
||
|
||
impl ParameterizedReplaceResult {
|
||
fn new(
|
||
event: &nostr::Event,
|
||
received_at: DateTime<Utc>,
|
||
channel_id: Option<Uuid>,
|
||
status: ParameterizedReplaceStatus,
|
||
) -> Self {
|
||
Self {
|
||
event: StoredEvent::with_received_at(
|
||
event.clone(),
|
||
received_at,
|
||
channel_id,
|
||
status == ParameterizedReplaceStatus::Inserted,
|
||
),
|
||
status,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Derive the transaction-scoped advisory-lock key for an event coordinate.
|
||
///
|
||
/// Hash collisions only add serialization; the SQL predicates still determine
|
||
/// which rows are read or changed.
|
||
pub(crate) fn event_replacement_lock_key(
|
||
community_id: CommunityId,
|
||
kind: i32,
|
||
pubkey: &[u8],
|
||
coordinate: Option<&[u8]>,
|
||
) -> i64 {
|
||
let mut hash: u64 = 0xcbf29ce484222325;
|
||
let kind_bytes = kind.to_le_bytes();
|
||
for bytes in [
|
||
community_id.as_uuid().as_bytes().as_slice(),
|
||
kind_bytes.as_slice(),
|
||
pubkey,
|
||
] {
|
||
for byte in bytes {
|
||
hash ^= *byte as u64;
|
||
hash = hash.wrapping_mul(0x100000001b3);
|
||
}
|
||
}
|
||
if let Some(coordinate) = coordinate {
|
||
for byte in coordinate {
|
||
hash ^= *byte as u64;
|
||
hash = hash.wrapping_mul(0x100000001b3);
|
||
}
|
||
}
|
||
hash as i64
|
||
}
|
||
|
||
/// Replace a parameterized event in a caller-owned transaction.
|
||
///
|
||
/// This function acquires a transaction-scoped advisory lock but never commits
|
||
/// or rolls back the outer transaction. The typed precondition can require the
|
||
/// current live head to have an exact event ID or restrict the operation to an
|
||
/// idempotent replay.
|
||
async fn replace_parameterized_event_in_transaction_impl(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
community_id: CommunityId,
|
||
event: &nostr::Event,
|
||
d_tag: &str,
|
||
channel_id: Option<Uuid>,
|
||
precondition: ParameterizedReplacePrecondition<'_>,
|
||
) -> Result<ParameterizedReplaceResult> {
|
||
let kind_i32 = buzz_core::kind::event_kind_i32(event);
|
||
let pubkey_bytes = event.pubkey.to_bytes();
|
||
let created_at_secs = event.created_at.as_secs() as i64;
|
||
let created_at = DateTime::from_timestamp(created_at_secs, 0)
|
||
.ok_or(DbError::InvalidTimestamp(created_at_secs))?;
|
||
let received_at = Utc::now();
|
||
|
||
let lock_key = event_replacement_lock_key(
|
||
community_id,
|
||
kind_i32,
|
||
pubkey_bytes.as_slice(),
|
||
Some(d_tag.as_bytes()),
|
||
);
|
||
observability::observe_advisory_lock(
|
||
LockType::Replacement,
|
||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||
.bind(lock_key)
|
||
.execute(&mut **tx),
|
||
)
|
||
.await?;
|
||
|
||
let d_tag_count = event
|
||
.tags
|
||
.iter()
|
||
.filter(|tag| tag.as_slice().first().is_some_and(|part| part == "d"))
|
||
.count();
|
||
let has_exact_d_tag = event.tags.iter().any(|tag| {
|
||
let parts = tag.as_slice();
|
||
parts.len() >= 2 && parts[0] == "d" && parts[1] == d_tag
|
||
});
|
||
let read_state_t_tag_count = event
|
||
.tags
|
||
.iter()
|
||
.filter(|tag| {
|
||
let parts = tag.as_slice();
|
||
parts.len() == 2 && parts[0] == "t" && parts[1] == "read-state"
|
||
})
|
||
.count();
|
||
let is_nip_rs = kind_i32 == buzz_core::kind::KIND_READ_STATE as i32
|
||
&& d_tag_count == 1
|
||
&& has_exact_d_tag
|
||
&& d_tag.strip_prefix("read-state:").is_some_and(|slot| {
|
||
slot.len() == 32
|
||
&& slot
|
||
.bytes()
|
||
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
|
||
})
|
||
&& read_state_t_tag_count == 1;
|
||
let is_buzz_mesh_status = kind_i32 == buzz_core::kind::KIND_BOOKMARK_SET as i32
|
||
&& d_tag.starts_with("buzz-mesh-member-status:")
|
||
&& event.tags.iter().any(|tag| {
|
||
let parts = tag.as_slice();
|
||
parts.len() == 2 && parts[0] == "k" && parts[1] == "buzz-mesh-status"
|
||
});
|
||
let hard_delete_superseded = is_nip_rs || is_buzz_mesh_status;
|
||
|
||
let existing: Option<(DateTime<Utc>, Vec<u8>)> = sqlx::query_as(
|
||
"SELECT created_at, id FROM events \
|
||
WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL \
|
||
ORDER BY created_at DESC, id ASC LIMIT 1",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(kind_i32)
|
||
.bind(pubkey_bytes.as_slice())
|
||
.bind(d_tag)
|
||
.fetch_optional(&mut **tx)
|
||
.await?;
|
||
let watermark: Option<(DateTime<Utc>, Vec<u8>)> = if is_nip_rs {
|
||
sqlx::query_as(
|
||
"SELECT created_at, event_id FROM parameterized_event_watermarks \
|
||
WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(kind_i32)
|
||
.bind(pubkey_bytes.as_slice())
|
||
.bind(d_tag)
|
||
.fetch_optional(&mut **tx)
|
||
.await?
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let incoming_id = event.id.as_bytes().as_slice();
|
||
if existing
|
||
.as_ref()
|
||
.is_some_and(|(_, existing_id)| existing_id.as_slice() == incoming_id)
|
||
|| watermark
|
||
.as_ref()
|
||
.is_some_and(|(_, event_id)| event_id.as_slice() == incoming_id)
|
||
{
|
||
return Ok(ParameterizedReplaceResult::new(
|
||
event,
|
||
received_at,
|
||
channel_id,
|
||
ParameterizedReplaceStatus::Duplicate,
|
||
));
|
||
}
|
||
|
||
if precondition == ParameterizedReplacePrecondition::ExactReplayOnly {
|
||
return Ok(ParameterizedReplaceResult::new(
|
||
event,
|
||
received_at,
|
||
channel_id,
|
||
ParameterizedReplaceStatus::ReplayOnlyMiss,
|
||
));
|
||
}
|
||
|
||
if let ParameterizedReplacePrecondition::ExpectedRevision(expected_revision) = precondition {
|
||
let status = match existing.as_ref() {
|
||
None => Some(ParameterizedReplaceStatus::RevisionMissing),
|
||
Some((_, existing_id)) if existing_id.as_slice() != expected_revision => {
|
||
Some(ParameterizedReplaceStatus::RevisionMismatch)
|
||
}
|
||
Some(_) => None,
|
||
};
|
||
if let Some(status) = status {
|
||
return Ok(ParameterizedReplaceResult::new(
|
||
event,
|
||
received_at,
|
||
channel_id,
|
||
status,
|
||
));
|
||
}
|
||
}
|
||
|
||
let dominated = existing
|
||
.iter()
|
||
.chain(watermark.iter())
|
||
.any(|(accepted_ts, accepted_id)| {
|
||
created_at < *accepted_ts
|
||
|| (created_at == *accepted_ts && incoming_id >= accepted_id.as_slice())
|
||
});
|
||
if dominated {
|
||
return Ok(ParameterizedReplaceResult::new(
|
||
event,
|
||
received_at,
|
||
channel_id,
|
||
ParameterizedReplaceStatus::Superseded,
|
||
));
|
||
}
|
||
|
||
let mut savepoint = tx.begin().await?;
|
||
if existing.is_some() {
|
||
let previous_nip_rs_hard_delete: Option<String> = if is_nip_rs {
|
||
sqlx::query_scalar(
|
||
"SELECT NULLIF(current_setting('buzz.nip_rs_hard_delete', true), '')",
|
||
)
|
||
.fetch_one(&mut *savepoint)
|
||
.await?
|
||
} else {
|
||
None
|
||
};
|
||
if is_nip_rs {
|
||
sqlx::query("SELECT set_config('buzz.nip_rs_hard_delete', 'on', true)")
|
||
.execute(&mut *savepoint)
|
||
.await?;
|
||
}
|
||
let statement = if hard_delete_superseded {
|
||
"DELETE FROM events \
|
||
WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL"
|
||
} else {
|
||
"UPDATE events SET deleted_at = NOW() \
|
||
WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL"
|
||
};
|
||
sqlx::query(statement)
|
||
.bind(community_id.as_uuid())
|
||
.bind(kind_i32)
|
||
.bind(pubkey_bytes.as_slice())
|
||
.bind(d_tag)
|
||
.execute(&mut *savepoint)
|
||
.await?;
|
||
|
||
if is_nip_rs {
|
||
let previous_value = previous_nip_rs_hard_delete.as_deref().unwrap_or_default();
|
||
sqlx::query("SELECT set_config('buzz.nip_rs_hard_delete', $1, true)")
|
||
.bind(previous_value)
|
||
.execute(&mut *savepoint)
|
||
.await?;
|
||
}
|
||
|
||
if hard_delete_superseded {
|
||
if let Some((_, existing_id)) = &existing {
|
||
sqlx::query("DELETE FROM event_mentions WHERE community_id = $1 AND event_id = $2")
|
||
.bind(community_id.as_uuid())
|
||
.bind(existing_id)
|
||
.execute(&mut *savepoint)
|
||
.await?;
|
||
}
|
||
}
|
||
}
|
||
|
||
let sig_bytes = event.sig.serialize();
|
||
let tags_json = serde_json::to_value(&event.tags)?;
|
||
let insert_result = sqlx::query(
|
||
"INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag, not_before) \
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) \
|
||
ON CONFLICT DO NOTHING",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(incoming_id)
|
||
.bind(pubkey_bytes.as_slice())
|
||
.bind(created_at)
|
||
.bind(kind_i32)
|
||
.bind(&tags_json)
|
||
.bind(&event.content)
|
||
.bind(sig_bytes.as_slice())
|
||
.bind(received_at)
|
||
.bind(channel_id)
|
||
.bind(d_tag)
|
||
.bind(crate::event::extract_not_before(event))
|
||
.execute(&mut *savepoint)
|
||
.await?;
|
||
|
||
if insert_result.rows_affected() == 0 {
|
||
savepoint.rollback().await?;
|
||
return Ok(ParameterizedReplaceResult::new(
|
||
event,
|
||
received_at,
|
||
channel_id,
|
||
ParameterizedReplaceStatus::Duplicate,
|
||
));
|
||
}
|
||
|
||
if is_nip_rs {
|
||
sqlx::query(
|
||
"INSERT INTO parameterized_event_watermarks \
|
||
(community_id, kind, pubkey, d_tag, created_at, event_id) \
|
||
VALUES ($1, $2, $3, $4, $5, $6) \
|
||
ON CONFLICT (community_id, kind, pubkey, d_tag) DO UPDATE SET \
|
||
created_at = EXCLUDED.created_at, event_id = EXCLUDED.event_id",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(kind_i32)
|
||
.bind(pubkey_bytes.as_slice())
|
||
.bind(d_tag)
|
||
.bind(created_at)
|
||
.bind(incoming_id)
|
||
.execute(&mut *savepoint)
|
||
.await?;
|
||
}
|
||
|
||
crate::insert_mentions_in_transaction(&mut savepoint, community_id, event, channel_id).await?;
|
||
savepoint.commit().await?;
|
||
|
||
Ok(ParameterizedReplaceResult::new(
|
||
event,
|
||
received_at,
|
||
channel_id,
|
||
ParameterizedReplaceStatus::Inserted,
|
||
))
|
||
}
|
||
|
||
impl Db {
|
||
/// Atomically replace a replaceable event: NIP-16 kinds (0, 3, 41, 10000–19999)
|
||
/// and NIP-29 discovery state (39000–39002, called from side_effects.rs).
|
||
///
|
||
/// Keeps only the event with the highest `created_at` per (kind, pubkey, channel_id).
|
||
/// Same-second ties are broken by lowest event `id` (NIP-16 deterministic ordering).
|
||
/// Returns `(event, false)` for stale writes and duplicate IDs — callers should
|
||
/// skip fan-out/dispatch when `was_inserted` is false.
|
||
#[datastore_span(name = "replace_addressable_event", system = "postgresql")]
|
||
pub async fn replace_addressable_event(
|
||
&self,
|
||
community_id: CommunityId,
|
||
event: &nostr::Event,
|
||
channel_id: Option<Uuid>,
|
||
) -> Result<(StoredEvent, bool)> {
|
||
let kind_i32 = buzz_core::kind::event_kind_i32(event);
|
||
let pubkey_bytes = event.pubkey.to_bytes();
|
||
let created_at_secs = event.created_at.as_secs() as i64;
|
||
let created_at = chrono::DateTime::from_timestamp(created_at_secs, 0)
|
||
.ok_or(DbError::InvalidTimestamp(created_at_secs))?;
|
||
|
||
// Collisions only cause extra serialization; they cannot change behavior.
|
||
let lock_key = event_replacement_lock_key(
|
||
community_id,
|
||
kind_i32,
|
||
pubkey_bytes.as_slice(),
|
||
channel_id.as_ref().map(|id| id.as_bytes().as_slice()),
|
||
);
|
||
|
||
let (mut tx, transaction_timer) = observability::begin_transaction(
|
||
&self.pool,
|
||
observability::TransactionOperation::ReplaceAddressableEvent,
|
||
)
|
||
.await?;
|
||
|
||
transaction_timer
|
||
.observe(async {
|
||
// Serialize all writers for the same (kind, pubkey, channel_id) tuple.
|
||
// Advisory lock is transaction-scoped — released on commit/rollback.
|
||
observability::observe_advisory_lock(
|
||
observability::LockType::Replacement,
|
||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||
.bind(lock_key)
|
||
.execute(&mut *tx),
|
||
)
|
||
.await?;
|
||
|
||
// Check for the newest existing event. ORDER BY + LIMIT 1 is defensive against
|
||
// historical data where prior bugs may have left multiple live rows.
|
||
let existing: Option<(chrono::DateTime<chrono::Utc>, Vec<u8>)> =
|
||
sqlx::query_as(
|
||
"SELECT created_at, id FROM events \
|
||
WHERE community_id = $1 AND kind = $2 AND pubkey = $3 \
|
||
AND channel_id IS NOT DISTINCT FROM $4 \
|
||
AND deleted_at IS NULL \
|
||
ORDER BY created_at DESC, id ASC LIMIT 1",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(kind_i32)
|
||
.bind(pubkey_bytes.as_slice())
|
||
.bind(channel_id)
|
||
.fetch_optional(&mut *tx)
|
||
.await?;
|
||
|
||
// Stale-write protection: reject if incoming is not newer.
|
||
// NIP-16: created_at is second-resolution. On same-second tie, lowest
|
||
// event id (lexicographic) wins — deterministic across relays.
|
||
let incoming_id = event.id.as_bytes().as_slice();
|
||
if let Some((existing_ts, existing_id)) = existing {
|
||
let dominated = created_at < existing_ts
|
||
|| (created_at == existing_ts
|
||
&& incoming_id >= existing_id.as_slice());
|
||
if dominated {
|
||
tx.rollback().await?;
|
||
let received_at = chrono::Utc::now();
|
||
return Ok((
|
||
StoredEvent::with_received_at(
|
||
event.clone(),
|
||
received_at,
|
||
channel_id,
|
||
false,
|
||
),
|
||
false,
|
||
));
|
||
}
|
||
}
|
||
|
||
// Soft-delete the old event (if any). IS NOT DISTINCT FROM for NULL safety.
|
||
sqlx::query(
|
||
"UPDATE events SET deleted_at = NOW() \
|
||
WHERE community_id = $1 AND kind = $2 AND pubkey = $3 \
|
||
AND channel_id IS NOT DISTINCT FROM $4 \
|
||
AND deleted_at IS NULL",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(kind_i32)
|
||
.bind(pubkey_bytes.as_slice())
|
||
.bind(channel_id)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
// Insert the new event inside the same transaction.
|
||
let sig_bytes = event.sig.serialize();
|
||
let tags_json = serde_json::to_value(&event.tags)?;
|
||
let received_at = chrono::Utc::now();
|
||
let d_tag = crate::event::extract_d_tag(event);
|
||
|
||
let insert_result = sqlx::query(
|
||
"INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag) \
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \
|
||
ON CONFLICT DO NOTHING",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(event.id.as_bytes().as_slice())
|
||
.bind(pubkey_bytes.as_slice())
|
||
.bind(created_at)
|
||
.bind(kind_i32)
|
||
.bind(&tags_json)
|
||
.bind(&event.content)
|
||
.bind(sig_bytes.as_slice())
|
||
.bind(received_at)
|
||
.bind(channel_id)
|
||
.bind(d_tag.as_deref())
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
let was_inserted = insert_result.rows_affected() > 0;
|
||
if !was_inserted {
|
||
// ON CONFLICT fired — the event ID already exists. Rollback the
|
||
// soft-delete so we don't lose the previous replaceable event.
|
||
tx.rollback().await?;
|
||
return Ok((
|
||
StoredEvent::with_received_at(
|
||
event.clone(),
|
||
received_at,
|
||
channel_id,
|
||
false,
|
||
),
|
||
false,
|
||
));
|
||
}
|
||
|
||
// The replaceable event and its denormalized mention index are one
|
||
// authoritative discovery write. An indexing error must roll back the
|
||
// new event and restore the previously-live event.
|
||
crate::insert_mentions_in_transaction(&mut tx, community_id, event, channel_id)
|
||
.await?;
|
||
|
||
tx.commit().await?;
|
||
|
||
Ok((
|
||
StoredEvent::with_received_at(event.clone(), received_at, channel_id, true),
|
||
true,
|
||
))
|
||
})
|
||
.await
|
||
}
|
||
|
||
/// Replace a NIP-33 event inside a caller-owned transaction.
|
||
///
|
||
/// The caller owns commit or rollback. Requiring [`Transaction`] here and
|
||
/// in the internal state machine makes the advisory-lock contract explicit.
|
||
pub async fn replace_parameterized_event_in_transaction(
|
||
&self,
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
community_id: CommunityId,
|
||
event: &nostr::Event,
|
||
d_tag: &str,
|
||
channel_id: Option<Uuid>,
|
||
precondition: ParameterizedReplacePrecondition<'_>,
|
||
) -> Result<ParameterizedReplaceResult> {
|
||
replace_parameterized_event_in_transaction_impl(
|
||
tx,
|
||
community_id,
|
||
event,
|
||
d_tag,
|
||
channel_id,
|
||
precondition,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Atomically replace a NIP-33 parameterized replaceable event.
|
||
///
|
||
/// Replacement keys on `(kind, pubkey, d_tag)` across channels. The
|
||
/// highest timestamp wins; same-second ties use the lowest event ID.
|
||
#[datastore_span(name = "replace_parameterized_event", system = "postgresql")]
|
||
pub async fn replace_parameterized_event(
|
||
&self,
|
||
community_id: CommunityId,
|
||
event: &nostr::Event,
|
||
d_tag: &str,
|
||
channel_id: Option<Uuid>,
|
||
) -> Result<(StoredEvent, bool)> {
|
||
let (mut tx, transaction_timer) = observability::begin_transaction(
|
||
&self.pool,
|
||
TransactionOperation::ReplaceParameterizedEvent,
|
||
)
|
||
.await?;
|
||
transaction_timer
|
||
.observe(async {
|
||
let result = self
|
||
.replace_parameterized_event_in_transaction(
|
||
&mut tx,
|
||
community_id,
|
||
event,
|
||
d_tag,
|
||
channel_id,
|
||
ParameterizedReplacePrecondition::Unconditional,
|
||
)
|
||
.await?;
|
||
let was_inserted = result.status == ParameterizedReplaceStatus::Inserted;
|
||
if was_inserted {
|
||
tx.commit().await?;
|
||
} else {
|
||
tx.rollback().await?;
|
||
}
|
||
Ok((result.event, was_inserted))
|
||
})
|
||
.await
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::{event, migration, replaceable};
|
||
use sqlx::postgres::PgPoolOptions;
|
||
use sqlx::{Acquire, PgPool};
|
||
use std::time::Duration;
|
||
|
||
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials
|
||
|
||
async fn setup_db() -> Db {
|
||
let database_url =
|
||
std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into());
|
||
let pool = PgPool::connect(&database_url)
|
||
.await
|
||
.expect("connect to test DB");
|
||
Db::from_pool(pool)
|
||
}
|
||
|
||
async fn make_community(pool: &PgPool) -> Uuid {
|
||
let id = Uuid::new_v4();
|
||
let host = format!("communities-of-channels-{}.example", id.simple());
|
||
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
|
||
.bind(id)
|
||
.bind(host)
|
||
.execute(pool)
|
||
.await
|
||
.expect("insert community");
|
||
id
|
||
}
|
||
|
||
async fn admin_url() -> String {
|
||
std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into())
|
||
}
|
||
|
||
/// Create a fresh scratch database on the same server and optionally run migrations.
|
||
async fn create_scratch_db_through(
|
||
admin: &PgPool,
|
||
prefix: &str,
|
||
target: Option<i64>,
|
||
) -> (PgPool, String) {
|
||
let name = format!("{}_{}", prefix, Uuid::new_v4().simple());
|
||
sqlx::query(sqlx::AssertSqlSafe(format!("CREATE DATABASE {name}")))
|
||
.execute(admin)
|
||
.await
|
||
.expect("create scratch db");
|
||
let base = admin_url().await;
|
||
// Swap the database path segment of the admin URL for the scratch name.
|
||
let scratch_url = {
|
||
let idx = base.rfind('/').expect("db url has a path segment");
|
||
format!("{}/{}", &base[..idx], name)
|
||
};
|
||
let pool = PgPool::connect(&scratch_url)
|
||
.await
|
||
.expect("connect scratch db");
|
||
match target {
|
||
Some(target) => migration::run_migrations_through(&pool, target)
|
||
.await
|
||
.expect("migrate scratch db through target"),
|
||
None => migration::run_migrations(&pool)
|
||
.await
|
||
.expect("migrate scratch db"),
|
||
}
|
||
(pool, name)
|
||
}
|
||
|
||
/// Create a fresh scratch database on the same server and run all migrations.
|
||
/// Returns (pool, db_name); callers should `drop_scratch_db` when done.
|
||
async fn create_scratch_db(admin: &PgPool, prefix: &str) -> (PgPool, String) {
|
||
create_scratch_db_through(admin, prefix, None).await
|
||
}
|
||
|
||
async fn drop_scratch_db(admin: &PgPool, pool: PgPool, name: &str) {
|
||
pool.close().await;
|
||
let _ = sqlx::query(sqlx::AssertSqlSafe(format!(
|
||
"DROP DATABASE IF EXISTS {name} WITH (FORCE)"
|
||
)))
|
||
.execute(admin)
|
||
.await;
|
||
}
|
||
|
||
/// Insert identical community + channel rows into a database so the same
|
||
/// (community, channel) ids resolve in both writer and replica.
|
||
async fn seed_community_channel(
|
||
pool: &PgPool,
|
||
community: Uuid,
|
||
channel: Uuid,
|
||
author: &nostr::Keys,
|
||
) {
|
||
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
|
||
.bind(community)
|
||
.bind(format!("replica-routing-{}.example", community.simple()))
|
||
.execute(pool)
|
||
.await
|
||
.expect("insert community");
|
||
crate::channel::create_channel_with_id(
|
||
pool,
|
||
CommunityId::from_uuid(community),
|
||
channel,
|
||
&format!("replica-routing-{channel}"),
|
||
crate::channel::ChannelType::Stream,
|
||
crate::channel::ChannelVisibility::Open,
|
||
None,
|
||
author.public_key().to_bytes().as_slice(),
|
||
None,
|
||
)
|
||
.await
|
||
.expect("create channel");
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn addressable_replacement_rolls_back_when_mention_indexing_fails() {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
let admin = PgPool::connect(&admin_url().await)
|
||
.await
|
||
.expect("connect admin");
|
||
let (pool, scratch_name) = create_scratch_db(&admin, "atomic_addressable").await;
|
||
let db = Db::from_pool(pool.clone());
|
||
let community_uuid = Uuid::new_v4();
|
||
let channel = Uuid::new_v4();
|
||
let keys = Keys::generate();
|
||
let owner_keys = Keys::generate();
|
||
seed_community_channel(&pool, community_uuid, channel, &owner_keys).await;
|
||
let community = CommunityId::from_uuid(community_uuid);
|
||
let member = owner_keys.public_key().to_hex();
|
||
let tags = || {
|
||
vec![
|
||
Tag::parse(["d", channel.to_string().as_str()]).expect("d tag"),
|
||
Tag::parse(["p", member.as_str(), "", "owner"]).expect("p tag"),
|
||
]
|
||
};
|
||
let base = Timestamp::now().as_secs();
|
||
let old = EventBuilder::new(Kind::Custom(39002), "old")
|
||
.tags(tags())
|
||
.custom_created_at(Timestamp::from(base))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign old");
|
||
db.replace_addressable_event(community, &old, Some(channel))
|
||
.await
|
||
.expect("insert old roster");
|
||
|
||
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");
|
||
|
||
let new = EventBuilder::new(Kind::Custom(39002), "new")
|
||
.tags(tags())
|
||
.custom_created_at(Timestamp::from(base + 1))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign new");
|
||
let error = db
|
||
.replace_addressable_event(community, &new, Some(channel))
|
||
.await
|
||
.expect_err("mention failure must fail replacement");
|
||
assert!(error.to_string().contains("injected mention failure"));
|
||
|
||
let live_id: Vec<u8> = sqlx::query_scalar(
|
||
"SELECT id FROM events WHERE community_id=$1 AND channel_id=$2 \
|
||
AND kind=39002 AND deleted_at IS NULL",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(channel)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("query live roster");
|
||
assert_eq!(live_id, old.id.as_bytes(), "old roster must remain live");
|
||
let new_rows: i64 =
|
||
sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id=$1 AND id=$2")
|
||
.bind(community.as_uuid())
|
||
.bind(new.id.as_bytes().as_slice())
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("count rolled-back event");
|
||
assert_eq!(new_rows, 0, "new roster must roll back with its index");
|
||
|
||
drop_scratch_db(&admin, pool, &scratch_name).await;
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn stale_legacy_roster_cannot_replace_new_locked_snapshot() {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
let admin = PgPool::connect(&admin_url().await)
|
||
.await
|
||
.expect("connect admin");
|
||
let (setup_pool, scratch_name) = create_scratch_db(&admin, "mixed_roster_writer").await;
|
||
let base_url = admin_url().await;
|
||
let slash = base_url.rfind('/').expect("database URL has path segment");
|
||
let scratch_url = format!("{}/{}", &base_url[..slash], scratch_name);
|
||
let pool = PgPoolOptions::new()
|
||
.max_connections(1)
|
||
.acquire_timeout(Duration::from_secs(1))
|
||
.connect(&scratch_url)
|
||
.await
|
||
.expect("connect one-connection scratch pool");
|
||
setup_pool.close().await;
|
||
let db = Db::from_pool(pool.clone());
|
||
let community_uuid = Uuid::new_v4();
|
||
let community = CommunityId::from_uuid(community_uuid);
|
||
let channel = Uuid::new_v4();
|
||
let relay_keys = Keys::generate();
|
||
let owner_keys = Keys::generate();
|
||
let owner = owner_keys.public_key().to_bytes();
|
||
seed_community_channel(&pool, community_uuid, channel, &owner_keys).await;
|
||
|
||
// This is the old pod's unlocked capture A. It remains in process memory
|
||
// while a role-only canonical mutation advances and the new pod publishes B.
|
||
let base = Timestamp::now().as_secs();
|
||
let roster = |members: &[(&[u8], &str)], timestamp| {
|
||
let tags =
|
||
std::iter::once(Tag::parse(["d", channel.to_string().as_str()]).expect("d tag"))
|
||
.chain(members.iter().map(|(member, role)| {
|
||
Tag::parse(["p", hex::encode(member).as_str(), "", *role]).expect("p tag")
|
||
}))
|
||
.collect::<Vec<_>>();
|
||
EventBuilder::new(Kind::Custom(39002), "")
|
||
.tags(tags)
|
||
.custom_created_at(Timestamp::from(timestamp))
|
||
.sign_with_keys(&relay_keys)
|
||
.expect("sign roster")
|
||
};
|
||
|
||
let newcomer = Keys::generate().public_key().to_bytes();
|
||
sqlx::query(
|
||
"INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) \
|
||
VALUES ($1, $2, $3, 'member', $4)",
|
||
)
|
||
.bind(community_uuid)
|
||
.bind(channel)
|
||
.bind(newcomer.as_slice())
|
||
.bind(owner.as_slice())
|
||
.execute(&pool)
|
||
.await
|
||
.expect("seed member before legacy capture");
|
||
let stale_a = roster(
|
||
&[(owner.as_slice(), "owner"), (newcomer.as_slice(), "member")],
|
||
base + 2,
|
||
);
|
||
|
||
sqlx::query(
|
||
"UPDATE channel_members SET role = 'admin' \
|
||
WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3",
|
||
)
|
||
.bind(community_uuid)
|
||
.bind(channel)
|
||
.bind(newcomer.as_slice())
|
||
.execute(&pool)
|
||
.await
|
||
.expect("commit newer canonical role");
|
||
|
||
let relay_pubkey = relay_keys.public_key().to_bytes();
|
||
let mut snapshot = db
|
||
.lock_member_snapshot(community, channel, &relay_pubkey)
|
||
.await
|
||
.expect("new writer captures locked roster B");
|
||
let fresh_b = roster(
|
||
&[(owner.as_slice(), "owner"), (newcomer.as_slice(), "admin")],
|
||
base + 1,
|
||
);
|
||
assert!(
|
||
snapshot
|
||
.replace_member_event(community, channel, &fresh_b)
|
||
.await
|
||
.expect("new writer publishes B")
|
||
.1
|
||
);
|
||
snapshot
|
||
.release()
|
||
.await
|
||
.expect("commit B and release locks");
|
||
|
||
// The legacy canonical path takes the replacement key, soft-deletes B,
|
||
// then attempts its newer-timestamp stale A. Migration 0032 rejects the
|
||
// INSERT; transaction rollback must restore B. A one-connection pool
|
||
// proves the lock order does not turn this compatibility path into a
|
||
// self-deadlock.
|
||
let error = tokio::time::timeout(
|
||
Duration::from_secs(3),
|
||
db.replace_addressable_event(community, &stale_a, Some(channel)),
|
||
)
|
||
.await
|
||
.expect("legacy replacement must not deadlock")
|
||
.expect_err("stale captured roster A must be rejected");
|
||
assert!(
|
||
matches!(
|
||
error,
|
||
DbError::Sqlx(sqlx::Error::Database(ref db_error))
|
||
if db_error.code().as_deref() == Some("23514")
|
||
),
|
||
"expected roster fence check violation, got {error:?}"
|
||
);
|
||
|
||
let live_ids: Vec<Vec<u8>> = sqlx::query_scalar(
|
||
"SELECT id FROM events WHERE community_id=$1 AND channel_id=$2 \
|
||
AND kind=39002 AND pubkey=$3 AND deleted_at IS NULL",
|
||
)
|
||
.bind(community_uuid)
|
||
.bind(channel)
|
||
.bind(relay_pubkey.as_slice())
|
||
.fetch_all(&pool)
|
||
.await
|
||
.expect("load live roster heads");
|
||
assert_eq!(live_ids, vec![fresh_b.id.as_bytes().to_vec()]);
|
||
let stale_rows: i64 =
|
||
sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id=$1 AND id=$2")
|
||
.bind(community_uuid)
|
||
.bind(stale_a.id.as_bytes().as_slice())
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("count rejected stale roster");
|
||
assert_eq!(stale_rows, 0, "stale roster insert must roll back");
|
||
|
||
drop_scratch_db(&admin, pool, &scratch_name).await;
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn nip_rs_replacement_hard_deletes_payload_and_watermark_rejects_replay() {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
let db = setup_db().await;
|
||
let community = CommunityId::from_uuid(make_community(&db.pool).await);
|
||
let keys = Keys::generate();
|
||
let d_tag = format!("read-state:{}", "a".repeat(32));
|
||
let tags = vec![
|
||
Tag::parse(["d", d_tag.as_str()]).expect("d tag"),
|
||
Tag::parse(["t", "read-state"]).expect("t tag"),
|
||
];
|
||
let base = Timestamp::now().as_secs();
|
||
let old = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "old")
|
||
.tags(tags.clone())
|
||
.custom_created_at(Timestamp::from(base))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign old");
|
||
let new = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "new")
|
||
.tags(tags)
|
||
.custom_created_at(Timestamp::from(base + 1))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign new");
|
||
|
||
assert!(
|
||
db.replace_parameterized_event(community, &old, &d_tag, None)
|
||
.await
|
||
.expect("insert old")
|
||
.1
|
||
);
|
||
assert!(
|
||
db.replace_parameterized_event(community, &new, &d_tag, None)
|
||
.await
|
||
.expect("replace with new")
|
||
.1
|
||
);
|
||
|
||
let rows: i64 = sqlx::query_scalar(
|
||
"SELECT count(*) FROM events WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(&d_tag)
|
||
.fetch_one(&db.pool)
|
||
.await
|
||
.expect("count NIP-RS rows");
|
||
assert_eq!(rows, 1, "superseded payload must be physically deleted");
|
||
|
||
sqlx::query(
|
||
"UPDATE events SET deleted_at=NOW() WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(&d_tag)
|
||
.execute(&db.pool)
|
||
.await
|
||
.expect("simulate NIP-09 coordinate deletion");
|
||
|
||
assert!(
|
||
!db.replace_parameterized_event(community, &old, &d_tag, None)
|
||
.await
|
||
.expect("replay old")
|
||
.1
|
||
);
|
||
let live: i64 = sqlx::query_scalar(
|
||
"SELECT count(*) FROM events WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(&d_tag)
|
||
.fetch_one(&db.pool)
|
||
.await
|
||
.expect("count live NIP-RS rows");
|
||
assert_eq!(live, 0, "watermark must block stale resurrection");
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn nip_rs_transaction_operation_restores_hard_delete_opt_in() {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
let db = setup_db().await;
|
||
let community = CommunityId::from_uuid(make_community(&db.pool).await);
|
||
let keys = Keys::generate();
|
||
let base = Timestamp::now().as_secs();
|
||
let replace_d_tag = format!("read-state:{}", "b".repeat(32));
|
||
let victim_d_tag = format!("read-state:{}", "c".repeat(32));
|
||
let event = |d_tag: &str, content: &str, timestamp: u64| {
|
||
EventBuilder::new(
|
||
Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16),
|
||
content,
|
||
)
|
||
.tags(vec![
|
||
Tag::parse(["d", d_tag]).expect("d tag"),
|
||
Tag::parse(["t", "read-state"]).expect("t tag"),
|
||
])
|
||
.custom_created_at(Timestamp::from(timestamp))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign read state")
|
||
};
|
||
let old = event(&replace_d_tag, "old", base);
|
||
let new = event(&replace_d_tag, "new", base + 1);
|
||
let victim = event(&victim_d_tag, "victim", base);
|
||
|
||
assert!(
|
||
db.replace_parameterized_event(community, &old, &replace_d_tag, None)
|
||
.await
|
||
.expect("insert old head")
|
||
.1
|
||
);
|
||
assert!(
|
||
db.replace_parameterized_event(community, &victim, &victim_d_tag, None)
|
||
.await
|
||
.expect("insert victim head")
|
||
.1
|
||
);
|
||
|
||
let mut tx = db
|
||
.begin_transaction()
|
||
.await
|
||
.expect("begin caller transaction");
|
||
let result = db
|
||
.replace_parameterized_event_in_transaction(
|
||
&mut tx,
|
||
community,
|
||
&new,
|
||
&replace_d_tag,
|
||
None,
|
||
replaceable::ParameterizedReplacePrecondition::Unconditional,
|
||
)
|
||
.await
|
||
.expect("replace inside caller transaction");
|
||
assert_eq!(
|
||
result.status,
|
||
replaceable::ParameterizedReplaceStatus::Inserted
|
||
);
|
||
|
||
let leaked: Option<String> = sqlx::query_scalar(
|
||
"SELECT NULLIF(current_setting('buzz.nip_rs_hard_delete', true), '')",
|
||
)
|
||
.fetch_one(&mut *tx)
|
||
.await
|
||
.expect("read hard-delete opt-in after replacement");
|
||
assert_ne!(leaked.as_deref(), Some("on"));
|
||
|
||
let unauthorized = sqlx::query(
|
||
"DELETE FROM events WHERE community_id=$1 AND kind=30078 \
|
||
AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(&victim_d_tag)
|
||
.execute(&mut *tx)
|
||
.await;
|
||
assert!(
|
||
unauthorized.is_err(),
|
||
"replacement opt-in must not authorize later caller SQL"
|
||
);
|
||
tx.rollback().await.expect("roll back caller transaction");
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn parameterized_replacement_in_existing_transaction_honors_revision_and_rollback() {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
let db = setup_db().await;
|
||
let community = CommunityId::from_uuid(make_community(&db.pool).await);
|
||
let keys = Keys::generate();
|
||
let d_tag = format!("transactional-project-{}", Uuid::new_v4().simple());
|
||
let base = Timestamp::now().as_secs();
|
||
let event = |content: &str, timestamp: u64| {
|
||
EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), content)
|
||
.tags(vec![Tag::parse(["d", d_tag.as_str()]).expect("d tag")])
|
||
.custom_created_at(Timestamp::from(timestamp))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign project")
|
||
};
|
||
let old = event("old", base);
|
||
let new = event("new", base + 1);
|
||
|
||
assert!(
|
||
db.replace_parameterized_event(community, &old, &d_tag, None)
|
||
.await
|
||
.expect("insert old head")
|
||
.1
|
||
);
|
||
|
||
let mut tx = db.begin_transaction().await.expect("begin replacement tx");
|
||
let outcome = db
|
||
.replace_parameterized_event_in_transaction(
|
||
&mut tx,
|
||
community,
|
||
&new,
|
||
&d_tag,
|
||
None,
|
||
replaceable::ParameterizedReplacePrecondition::ExpectedRevision(
|
||
old.id.as_bytes().as_slice(),
|
||
),
|
||
)
|
||
.await
|
||
.expect("replace inside caller transaction");
|
||
assert_eq!(
|
||
outcome.status,
|
||
replaceable::ParameterizedReplaceStatus::Inserted
|
||
);
|
||
tx.rollback().await.expect("roll back replacement tx");
|
||
|
||
let live_id: Vec<u8> = sqlx::query_scalar(
|
||
"SELECT id 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(buzz_core::kind::KIND_PROJECT as i32)
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(&d_tag)
|
||
.fetch_one(&db.pool)
|
||
.await
|
||
.expect("load live head after rollback");
|
||
assert_eq!(live_id, old.id.as_bytes().to_vec());
|
||
|
||
let mut tx = db
|
||
.begin_transaction()
|
||
.await
|
||
.expect("begin stale revision tx");
|
||
let mismatch = db
|
||
.replace_parameterized_event_in_transaction(
|
||
&mut tx,
|
||
community,
|
||
&new,
|
||
&d_tag,
|
||
None,
|
||
replaceable::ParameterizedReplacePrecondition::ExpectedRevision(
|
||
[0x42; 32].as_slice(),
|
||
),
|
||
)
|
||
.await
|
||
.expect("evaluate stale revision");
|
||
assert_eq!(
|
||
mismatch.status,
|
||
replaceable::ParameterizedReplaceStatus::RevisionMismatch
|
||
);
|
||
tx.rollback().await.expect("roll back stale revision tx");
|
||
|
||
let missing_d_tag = format!("missing-project-{}", Uuid::new_v4().simple());
|
||
let missing = EventBuilder::new(
|
||
Kind::Custom(buzz_core::kind::KIND_PROJECT as u16),
|
||
"missing",
|
||
)
|
||
.tags(vec![
|
||
Tag::parse(["d", missing_d_tag.as_str()]).expect("missing d tag")
|
||
])
|
||
.custom_created_at(Timestamp::from(base + 2))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign missing project");
|
||
let mut tx = db
|
||
.begin_transaction()
|
||
.await
|
||
.expect("begin missing revision tx");
|
||
let missing_result = db
|
||
.replace_parameterized_event_in_transaction(
|
||
&mut tx,
|
||
community,
|
||
&missing,
|
||
&missing_d_tag,
|
||
None,
|
||
replaceable::ParameterizedReplacePrecondition::ExpectedRevision(
|
||
[0x24; 32].as_slice(),
|
||
),
|
||
)
|
||
.await
|
||
.expect("evaluate missing revision");
|
||
assert_eq!(
|
||
missing_result.status,
|
||
replaceable::ParameterizedReplaceStatus::RevisionMissing
|
||
);
|
||
tx.rollback().await.expect("roll back missing revision tx");
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn parameterized_replacement_rolls_back_when_mention_indexing_fails() {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
let admin = PgPool::connect(&admin_url().await)
|
||
.await
|
||
.expect("connect admin");
|
||
let (pool, scratch_name) = create_scratch_db(&admin, "atomic_parameterized").await;
|
||
let db = Db::from_pool(pool.clone());
|
||
let community = CommunityId::from_uuid(make_community(&pool).await);
|
||
let keys = Keys::generate();
|
||
let mentioned = Keys::generate().public_key().to_hex();
|
||
let d_tag = format!("mention-project-{}", Uuid::new_v4().simple());
|
||
let tags = || {
|
||
vec![
|
||
Tag::parse(["d", d_tag.as_str()]).expect("d tag"),
|
||
Tag::parse(["p", mentioned.as_str()]).expect("p tag"),
|
||
]
|
||
};
|
||
let base = Timestamp::now().as_secs();
|
||
let old = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), "old")
|
||
.tags(tags())
|
||
.custom_created_at(Timestamp::from(base))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign old project");
|
||
let new = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), "new")
|
||
.tags(tags())
|
||
.custom_created_at(Timestamp::from(base + 1))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign new project");
|
||
|
||
assert!(
|
||
db.replace_parameterized_event(community, &old, &d_tag, None)
|
||
.await
|
||
.expect("insert old project")
|
||
.1
|
||
);
|
||
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");
|
||
|
||
let mut tx = db
|
||
.begin_transaction()
|
||
.await
|
||
.expect("begin caller transaction");
|
||
let error = db
|
||
.replace_parameterized_event_in_transaction(
|
||
&mut tx,
|
||
community,
|
||
&new,
|
||
&d_tag,
|
||
None,
|
||
replaceable::ParameterizedReplacePrecondition::Unconditional,
|
||
)
|
||
.await
|
||
.expect_err("mention failure must fail replacement");
|
||
assert!(error.to_string().contains("injected mention failure"));
|
||
|
||
let probe: i32 = sqlx::query_scalar("SELECT 1")
|
||
.fetch_one(&mut *tx)
|
||
.await
|
||
.expect("inner failure must leave caller transaction usable");
|
||
assert_eq!(probe, 1);
|
||
|
||
let live_id: Vec<u8> = sqlx::query_scalar(
|
||
"SELECT id 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(buzz_core::kind::KIND_PROJECT as i32)
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(&d_tag)
|
||
.fetch_one(&mut *tx)
|
||
.await
|
||
.expect("load live project after failed indexing");
|
||
assert_eq!(live_id, old.id.as_bytes().to_vec());
|
||
let new_rows: i64 =
|
||
sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id=$1 AND id=$2")
|
||
.bind(community.as_uuid())
|
||
.bind(new.id.as_bytes().as_slice())
|
||
.fetch_one(&mut *tx)
|
||
.await
|
||
.expect("count rolled-back project");
|
||
assert_eq!(new_rows, 0);
|
||
tx.commit().await.expect("commit usable caller transaction");
|
||
|
||
drop_scratch_db(&admin, pool, &scratch_name).await;
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn parameterized_duplicate_restores_live_head_inside_caller_transaction() {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
let db = setup_db().await;
|
||
let community = CommunityId::from_uuid(make_community(&db.pool).await);
|
||
let keys = Keys::generate();
|
||
let d_tag = format!("duplicate-project-{}", Uuid::new_v4().simple());
|
||
let base = Timestamp::now().as_secs();
|
||
let event = |content: &str, timestamp: u64| {
|
||
EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), content)
|
||
.tags(vec![Tag::parse(["d", d_tag.as_str()]).expect("d tag")])
|
||
.custom_created_at(Timestamp::from(timestamp))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign project")
|
||
};
|
||
let old = event("old-live-head", base);
|
||
let duplicate = event("soft-deleted-duplicate", base + 1);
|
||
|
||
assert!(
|
||
db.replace_parameterized_event(community, &duplicate, &d_tag, None)
|
||
.await
|
||
.expect("insert future duplicate")
|
||
.1
|
||
);
|
||
sqlx::query("UPDATE events SET deleted_at=NOW() WHERE community_id=$1 AND id=$2")
|
||
.bind(community.as_uuid())
|
||
.bind(duplicate.id.as_bytes().as_slice())
|
||
.execute(&db.pool)
|
||
.await
|
||
.expect("soft-delete duplicate row");
|
||
|
||
let mut seed_tx = db
|
||
.begin_transaction()
|
||
.await
|
||
.expect("begin seed transaction");
|
||
let (_, was_inserted) =
|
||
event::insert_event_in_transaction(&mut seed_tx, community, &old, None)
|
||
.await
|
||
.expect("insert older live head");
|
||
assert!(was_inserted);
|
||
seed_tx.commit().await.expect("commit older live head");
|
||
|
||
let mut tx = db
|
||
.begin_transaction()
|
||
.await
|
||
.expect("begin caller transaction");
|
||
let result = db
|
||
.replace_parameterized_event_in_transaction(
|
||
&mut tx,
|
||
community,
|
||
&duplicate,
|
||
&d_tag,
|
||
None,
|
||
replaceable::ParameterizedReplacePrecondition::Unconditional,
|
||
)
|
||
.await
|
||
.expect("evaluate soft-deleted duplicate");
|
||
assert_eq!(
|
||
result.status,
|
||
replaceable::ParameterizedReplaceStatus::Duplicate
|
||
);
|
||
|
||
let live_id: Vec<u8> = sqlx::query_scalar(
|
||
"SELECT id 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(buzz_core::kind::KIND_PROJECT as i32)
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(&d_tag)
|
||
.fetch_one(&mut *tx)
|
||
.await
|
||
.expect("caller transaction remains usable after duplicate");
|
||
assert_eq!(live_id, old.id.as_bytes().to_vec());
|
||
tx.rollback().await.expect("roll back caller transaction");
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn concurrent_parameterized_replacement_keeps_deterministic_head() {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
let db = setup_db().await;
|
||
let community = CommunityId::from_uuid(make_community(&db.pool).await);
|
||
let keys = Keys::generate();
|
||
let d_tag = format!("concurrent-project-{}", Uuid::new_v4().simple());
|
||
let created_at = Timestamp::now().as_secs();
|
||
let event = |content: &str, timestamp: u64| {
|
||
EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), content)
|
||
.tags(vec![Tag::parse(["d", d_tag.as_str()]).expect("d tag")])
|
||
.custom_created_at(Timestamp::from(timestamp))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign project")
|
||
};
|
||
let first = event("first", created_at);
|
||
let second = event("second", created_at);
|
||
let expected = if first.id.as_bytes() < second.id.as_bytes() {
|
||
&first
|
||
} else {
|
||
&second
|
||
};
|
||
|
||
let (first_result, second_result) = tokio::join!(
|
||
db.replace_parameterized_event(community, &first, &d_tag, None),
|
||
db.replace_parameterized_event(community, &second, &d_tag, None),
|
||
);
|
||
let first_inserted = first_result.expect("first concurrent writer").1;
|
||
let second_inserted = second_result.expect("second concurrent writer").1;
|
||
assert!(
|
||
first_inserted || second_inserted,
|
||
"at least one concurrent writer must insert",
|
||
);
|
||
|
||
let live_ids: Vec<Vec<u8>> = sqlx::query_scalar(
|
||
"SELECT id 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(buzz_core::kind::KIND_PROJECT as i32)
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(&d_tag)
|
||
.fetch_all(&db.pool)
|
||
.await
|
||
.expect("load concurrent live head");
|
||
assert_eq!(live_ids, vec![expected.id.as_bytes().to_vec()]);
|
||
|
||
assert!(
|
||
!db.replace_parameterized_event(community, expected, &d_tag, None)
|
||
.await
|
||
.expect("replay winning event")
|
||
.1,
|
||
"replaying the live event must be idempotent",
|
||
);
|
||
let stale = event("stale", created_at.saturating_sub(1));
|
||
assert!(
|
||
!db.replace_parameterized_event(community, &stale, &d_tag, None)
|
||
.await
|
||
.expect("submit stale event")
|
||
.1,
|
||
"an older event must not replace the live head",
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn mesh_status_replacement_keeps_one_physical_row() {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
let db = setup_db().await;
|
||
let community = CommunityId::from_uuid(make_community(&db.pool).await);
|
||
let keys = Keys::generate();
|
||
let d_tag = "buzz-mesh-member-status:owner-test";
|
||
let tags = vec![
|
||
Tag::parse(["d", d_tag]).expect("d tag"),
|
||
Tag::parse(["k", "buzz-mesh-status"]).expect("k tag"),
|
||
];
|
||
let base = Timestamp::now().as_secs();
|
||
for (offset, content) in [(0, "running"), (1, "running-again"), (2, "stopped")] {
|
||
let event = EventBuilder::new(
|
||
Kind::Custom(buzz_core::kind::KIND_BOOKMARK_SET as u16),
|
||
content,
|
||
)
|
||
.tags(tags.clone())
|
||
.custom_created_at(Timestamp::from(base + offset))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign mesh status");
|
||
assert!(
|
||
db.replace_parameterized_event(community, &event, d_tag, None)
|
||
.await
|
||
.expect("replace mesh status")
|
||
.1
|
||
);
|
||
}
|
||
|
||
let (rows, live): (i64, i64) = sqlx::query_as(
|
||
"SELECT count(*), count(*) FILTER (WHERE deleted_at IS NULL) FROM events \
|
||
WHERE community_id=$1 AND kind=30003 AND pubkey=$2 AND d_tag=$3",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(d_tag)
|
||
.fetch_one(&db.pool)
|
||
.await
|
||
.expect("count mesh status rows");
|
||
assert_eq!((rows, live), (1, 1));
|
||
|
||
sqlx::query(
|
||
"UPDATE events SET deleted_at=NOW() \
|
||
WHERE community_id=$1 AND kind=30003 AND pubkey=$2 AND d_tag=$3",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(d_tag)
|
||
.execute(&db.pool)
|
||
.await
|
||
.expect("simulate old relay soft delete");
|
||
let rows_after_legacy_delete: i64 = sqlx::query_scalar(
|
||
"SELECT count(*) FROM events \
|
||
WHERE community_id=$1 AND kind=30003 AND pubkey=$2 AND d_tag=$3",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(d_tag)
|
||
.fetch_one(&db.pool)
|
||
.await
|
||
.expect("count rows after old relay soft delete");
|
||
assert_eq!(
|
||
rows_after_legacy_delete, 0,
|
||
"migration trigger must purge soft-deleted mesh status"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn duplicate_nip_rs_discriminator_tags_keep_legacy_retention() {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
let db = setup_db().await;
|
||
let community = CommunityId::from_uuid(make_community(&db.pool).await);
|
||
let keys = Keys::generate();
|
||
let base = Timestamp::now().as_secs();
|
||
|
||
for (case, tags) in [
|
||
(
|
||
"duplicate-d",
|
||
vec![
|
||
Tag::parse(["d", &format!("read-state:{}", "c".repeat(32))])
|
||
.expect("first d tag"),
|
||
Tag::parse(["d", &format!("read-state:{}", "d".repeat(32))])
|
||
.expect("second d tag"),
|
||
Tag::parse(["t", "read-state"]).expect("t tag"),
|
||
],
|
||
),
|
||
(
|
||
"duplicate-t",
|
||
vec![
|
||
Tag::parse(["d", &format!("read-state:{}", "e".repeat(32))]).expect("d tag"),
|
||
Tag::parse(["t", "read-state"]).expect("first t tag"),
|
||
Tag::parse(["t", "read-state"]).expect("second t tag"),
|
||
],
|
||
),
|
||
] {
|
||
let d_tag = tags
|
||
.iter()
|
||
.find_map(|tag| {
|
||
let parts = tag.as_slice();
|
||
(parts.first().is_some_and(|part| part == "d") && parts.len() >= 2)
|
||
.then(|| parts[1].clone())
|
||
})
|
||
.expect("first d-tag value");
|
||
let old = EventBuilder::new(
|
||
Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16),
|
||
format!("{case}-old"),
|
||
)
|
||
.tags(tags.clone())
|
||
.custom_created_at(Timestamp::from(base))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign old event");
|
||
let new = EventBuilder::new(
|
||
Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16),
|
||
format!("{case}-new"),
|
||
)
|
||
.tags(tags)
|
||
.custom_created_at(Timestamp::from(base + 1))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign new event");
|
||
|
||
assert!(
|
||
db.replace_parameterized_event(community, &old, &d_tag, None)
|
||
.await
|
||
.expect("insert old event")
|
||
.1
|
||
);
|
||
assert!(
|
||
db.replace_parameterized_event(community, &new, &d_tag, None)
|
||
.await
|
||
.expect("replace with new event")
|
||
.1
|
||
);
|
||
|
||
let (rows, live): (i64, i64) = sqlx::query_as(
|
||
"SELECT count(*), count(*) FILTER (WHERE deleted_at IS NULL) FROM events \
|
||
WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(&d_tag)
|
||
.fetch_one(&db.pool)
|
||
.await
|
||
.expect("count retained rows");
|
||
assert_eq!((rows, live), (2, 1), "{case} must retain legacy history");
|
||
|
||
let watermarks: i64 = sqlx::query_scalar(
|
||
"SELECT count(*) FROM parameterized_event_watermarks \
|
||
WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(&d_tag)
|
||
.fetch_one(&db.pool)
|
||
.await
|
||
.expect("count watermarks");
|
||
assert_eq!(watermarks, 0, "{case} must not create a watermark");
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn nip_rs_hard_delete_fence_fails_closed_and_scopes_opt_in_to_transaction() {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
let db = setup_db().await;
|
||
let community = CommunityId::from_uuid(make_community(&db.pool).await);
|
||
let keys = Keys::generate();
|
||
let base = Timestamp::now().as_secs();
|
||
let conforming_d = format!("read-state:{}", "6".repeat(32));
|
||
let conforming = EventBuilder::new(
|
||
Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16),
|
||
"fenced-conforming",
|
||
)
|
||
.tags(vec![
|
||
Tag::parse(["d", conforming_d.as_str()]).expect("d tag"),
|
||
Tag::parse(["t", "read-state"]).expect("t tag"),
|
||
])
|
||
.custom_created_at(Timestamp::from(base))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign conforming event");
|
||
assert!(
|
||
db.replace_parameterized_event(community, &conforming, &conforming_d, None)
|
||
.await
|
||
.expect("insert conforming event")
|
||
.1
|
||
);
|
||
sqlx::query(
|
||
"INSERT INTO event_mentions \
|
||
(community_id, pubkey_hex, event_id, event_created_at, event_kind) \
|
||
VALUES ($1, $2, $3, to_timestamp($4), 30078)",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind("6".repeat(64))
|
||
.bind(conforming.id.as_bytes().as_slice())
|
||
.bind(conforming.created_at.as_secs() as f64)
|
||
.execute(&db.pool)
|
||
.await
|
||
.expect("insert mention");
|
||
|
||
// Model ce10's first destructive statement. RAISE aborts the transaction,
|
||
// so its later mention delete and incoming insert can never commit.
|
||
let mut old_writer = db.pool.begin().await.expect("begin old-writer tx");
|
||
let rejected = sqlx::query(
|
||
"DELETE FROM events WHERE community_id=$1 AND kind=30078 \
|
||
AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(&conforming_d)
|
||
.execute(&mut *old_writer)
|
||
.await;
|
||
assert!(rejected.is_err(), "old-writer hard delete must be rejected");
|
||
old_writer.rollback().await.expect("rollback rejected tx");
|
||
let preserved: (i64, i64) = sqlx::query_as(
|
||
"SELECT (SELECT count(*) FROM events WHERE community_id=$1 AND id=$2), \
|
||
(SELECT count(*) FROM event_mentions WHERE community_id=$1 AND event_id=$2)",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(conforming.id.as_bytes().as_slice())
|
||
.fetch_one(&db.pool)
|
||
.await
|
||
.expect("count preserved payload and mention");
|
||
assert_eq!(preserved, (1, 1));
|
||
|
||
let nonconforming_d = format!("read-state:{}", "7".repeat(32));
|
||
let nonconforming = EventBuilder::new(
|
||
Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16),
|
||
"fenced-nonconforming",
|
||
)
|
||
.tags(vec![
|
||
Tag::parse(["d", nonconforming_d.as_str()]).expect("first d tag"),
|
||
Tag::parse(["d", "other"]).expect("second d tag"),
|
||
Tag::parse(["t", "read-state"]).expect("t tag"),
|
||
])
|
||
.custom_created_at(Timestamp::from(base + 1))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign nonconforming event");
|
||
assert!(
|
||
db.replace_parameterized_event(community, &nonconforming, &nonconforming_d, None,)
|
||
.await
|
||
.expect("insert nonconforming event")
|
||
.1
|
||
);
|
||
let rejected_nonconforming = sqlx::query(
|
||
"DELETE FROM events WHERE community_id=$1 AND id=$2 AND created_at=to_timestamp($3)",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(nonconforming.id.as_bytes().as_slice())
|
||
.bind(nonconforming.created_at.as_secs() as f64)
|
||
.execute(&db.pool)
|
||
.await;
|
||
assert!(
|
||
rejected_nonconforming.is_err(),
|
||
"fence must cover a nonconforming OLD row at a regex coordinate"
|
||
);
|
||
|
||
let unrelated_d = format!("read-state:{}", "8".repeat(32));
|
||
let unrelated = EventBuilder::new(Kind::Custom(30023), "unrelated")
|
||
.tags(vec![Tag::parse(["d", unrelated_d.as_str()]).expect("d tag")])
|
||
.custom_created_at(Timestamp::from(base + 2))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign unrelated event");
|
||
assert!(
|
||
db.replace_parameterized_event(community, &unrelated, &unrelated_d, None)
|
||
.await
|
||
.expect("insert unrelated event")
|
||
.1
|
||
);
|
||
let unrelated_delete = sqlx::query(
|
||
"DELETE FROM events WHERE community_id=$1 AND id=$2 AND created_at=to_timestamp($3)",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(unrelated.id.as_bytes().as_slice())
|
||
.bind(unrelated.created_at.as_secs() as f64)
|
||
.execute(&db.pool)
|
||
.await
|
||
.expect("delete unrelated event");
|
||
assert_eq!(unrelated_delete.rows_affected(), 1);
|
||
|
||
// Check both transaction exits on one physical session; pool selection
|
||
// cannot accidentally hide a leaked session-local authorization value.
|
||
let mut conn = db.pool.acquire().await.expect("acquire dedicated session");
|
||
for commit in [true, false] {
|
||
let mut tx = conn.begin().await.expect("begin GUC transaction");
|
||
let value: String =
|
||
sqlx::query_scalar("SELECT set_config('buzz.nip_rs_hard_delete', 'on', true)")
|
||
.fetch_one(&mut *tx)
|
||
.await
|
||
.expect("set transaction-local GUC");
|
||
assert_eq!(value, "on");
|
||
if commit {
|
||
tx.commit().await.expect("commit GUC transaction");
|
||
} else {
|
||
tx.rollback().await.expect("rollback GUC transaction");
|
||
}
|
||
let leaked: Option<String> = sqlx::query_scalar(
|
||
"SELECT NULLIF(current_setting('buzz.nip_rs_hard_delete', true), '')",
|
||
)
|
||
.fetch_one(&mut *conn)
|
||
.await
|
||
.expect("read GUC after transaction");
|
||
assert_ne!(leaked.as_deref(), Some("on"));
|
||
}
|
||
}
|
||
}
|