mirror of
https://github.com/block/buzz.git
synced 2026-11-03 07:51:22 +00:00
Co-authored-by: Codex <codex@openai.com> Signed-off-by: jm <jm@squareup.com> Co-authored-by: Codex <noreply@openai.com>
2447 lines
93 KiB
Rust
2447 lines
93 KiB
Rust
//! Replaceable-event persistence and coordinate locking.
|
||
|
||
use buzz_core::{CommunityId, StoredEvent};
|
||
use buzz_datastore_tracing::datastore_span;
|
||
use chrono::{DateTime, Utc};
|
||
use uuid::Uuid;
|
||
|
||
use crate::observability::{self, LockType, TransactionOperation};
|
||
use crate::AdmittedTx;
|
||
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]),
|
||
/// Require the coordinate to have no live head.
|
||
ExpectNoHead,
|
||
/// 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
|
||
}
|
||
|
||
pub(crate) async fn acquire_parameterized_event_write_lock(
|
||
tx: &mut AdmittedTx,
|
||
event: &nostr::Event,
|
||
d_tag: &str,
|
||
) -> Result<()> {
|
||
let lock_key = event_replacement_lock_key(
|
||
tx.community(),
|
||
buzz_core::kind::event_kind_i32(event),
|
||
event.pubkey.as_bytes(),
|
||
Some(d_tag.as_bytes()),
|
||
);
|
||
observability::observe_advisory_lock(
|
||
LockType::Replacement,
|
||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||
.bind(lock_key)
|
||
.execute(tx.conn()),
|
||
)
|
||
.await?;
|
||
Ok(())
|
||
}
|
||
|
||
/// 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 AdmittedTx,
|
||
event: &nostr::Event,
|
||
d_tag: &str,
|
||
channel_id: Option<Uuid>,
|
||
precondition: ParameterizedReplacePrecondition<'_>,
|
||
) -> Result<ParameterizedReplaceResult> {
|
||
let community_id = tx.community();
|
||
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();
|
||
|
||
acquire_parameterized_event_write_lock(tx, event, d_tag).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(tx.conn())
|
||
.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(tx.conn())
|
||
.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 precondition == ParameterizedReplacePrecondition::ExpectNoHead && existing.is_some() {
|
||
return Ok(ParameterizedReplaceResult::new(
|
||
event,
|
||
received_at,
|
||
channel_id,
|
||
ParameterizedReplaceStatus::RevisionMismatch,
|
||
));
|
||
}
|
||
|
||
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,
|
||
));
|
||
}
|
||
|
||
// Everything from the supersede through the mention index runs under one
|
||
// savepoint on the admitted transaction. A duplicate insert or any failure
|
||
// (including mention indexing) rolls the whole replacement back and leaves
|
||
// the caller's transaction usable with the previous live head restored.
|
||
sqlx::query("SAVEPOINT parameterized_replace")
|
||
.execute(tx.conn())
|
||
.await?;
|
||
let written: Result<bool> = async {
|
||
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(tx.conn())
|
||
.await?
|
||
} else {
|
||
None
|
||
};
|
||
if is_nip_rs {
|
||
sqlx::query("SELECT set_config('buzz.nip_rs_hard_delete', 'on', true)")
|
||
.execute(tx.conn())
|
||
.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(tx.conn())
|
||
.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(tx.conn())
|
||
.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(tx.conn())
|
||
.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(tx.conn())
|
||
.await?;
|
||
|
||
if insert_result.rows_affected() == 0 {
|
||
return Ok(false);
|
||
}
|
||
|
||
if is_nip_rs {
|
||
sqlx::query(
|
||
"INSERT INTO parameterized_event_watermarks \
|
||
(community_id, kind, pubkey, d_tag, created_at, event_id) \
|
||
VALUES ($1, $2, $3, $4, $5, $6) \
|
||
ON CONFLICT (community_id, kind, pubkey, d_tag) DO UPDATE SET \
|
||
created_at = EXCLUDED.created_at, event_id = EXCLUDED.event_id",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(kind_i32)
|
||
.bind(pubkey_bytes.as_slice())
|
||
.bind(d_tag)
|
||
.bind(created_at)
|
||
.bind(incoming_id)
|
||
.execute(tx.conn())
|
||
.await?;
|
||
}
|
||
|
||
crate::insert_mentions_in_transaction(&mut *tx, event, channel_id).await?;
|
||
Ok(true)
|
||
}
|
||
.await;
|
||
|
||
let status = match written {
|
||
Ok(true) => ParameterizedReplaceStatus::Inserted,
|
||
Ok(false) => {
|
||
sqlx::query("ROLLBACK TO SAVEPOINT parameterized_replace")
|
||
.execute(tx.conn())
|
||
.await?;
|
||
ParameterizedReplaceStatus::Duplicate
|
||
}
|
||
Err(error) => {
|
||
// Restore the caller's transaction; the original error is the
|
||
// one worth reporting if the rollback itself also fails.
|
||
if let Err(rollback_error) = sqlx::query("ROLLBACK TO SAVEPOINT parameterized_replace")
|
||
.execute(tx.conn())
|
||
.await
|
||
{
|
||
tracing::warn!(error = %rollback_error, "parameterized replacement savepoint rollback failed");
|
||
}
|
||
return Err(error);
|
||
}
|
||
};
|
||
sqlx::query("RELEASE SAVEPOINT parameterized_replace")
|
||
.execute(tx.conn())
|
||
.await?;
|
||
if status == ParameterizedReplaceStatus::Inserted {
|
||
crate::store::event_follow_up::after_admitted_insert(tx, incoming_id, kind_i32, channel_id)
|
||
.await?;
|
||
}
|
||
|
||
Ok(ParameterizedReplaceResult::new(
|
||
event,
|
||
received_at,
|
||
channel_id,
|
||
status,
|
||
))
|
||
}
|
||
|
||
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 = crate::begin_community_event_write_transaction_with_legacy_metrics(
|
||
&self.pool,
|
||
community_id,
|
||
observability::WriterOperation::EventWrite,
|
||
)
|
||
.await?;
|
||
tx.set_push_enqueue(self.push_enqueue.clone());
|
||
let transaction_timer = observability::TransactionTimer::start(
|
||
observability::TransactionOperation::ReplaceAddressableEvent,
|
||
);
|
||
|
||
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(tx.conn()),
|
||
)
|
||
.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(tx.conn())
|
||
.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(tx.conn())
|
||
.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(tx.conn())
|
||
.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,
|
||
));
|
||
}
|
||
|
||
crate::store::event_follow_up::after_admitted_insert(
|
||
&mut tx,
|
||
event.id.as_bytes().as_slice(),
|
||
kind_i32,
|
||
channel_id,
|
||
)
|
||
.await?;
|
||
|
||
// 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, 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 [`AdmittedTx`] here and
|
||
/// in the internal state machine makes the advisory-lock contract explicit.
|
||
///
|
||
/// On a duplicate or an error the replacement rolls back to its own
|
||
/// savepoint and the caller's transaction stays usable. This future is not
|
||
/// cancel-safe: if it is dropped before completing, drop the transaction
|
||
/// too rather than continuing to write on it.
|
||
pub async fn replace_parameterized_event_in_transaction(
|
||
&self,
|
||
tx: &mut AdmittedTx,
|
||
event: &nostr::Event,
|
||
d_tag: &str,
|
||
channel_id: Option<Uuid>,
|
||
precondition: ParameterizedReplacePrecondition<'_>,
|
||
) -> Result<ParameterizedReplaceResult> {
|
||
replace_parameterized_event_in_transaction_impl(tx, 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 = crate::begin_community_event_write_transaction_with_legacy_metrics(
|
||
&self.pool,
|
||
community_id,
|
||
observability::WriterOperation::EventWrite,
|
||
)
|
||
.await?;
|
||
tx.set_push_enqueue(self.push_enqueue.clone());
|
||
let transaction_timer =
|
||
observability::TransactionTimer::start(TransactionOperation::ReplaceParameterizedEvent);
|
||
transaction_timer
|
||
.observe(async {
|
||
let result = self
|
||
.replace_parameterized_event_in_transaction(
|
||
&mut tx,
|
||
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 postgres_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");
|
||
if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() == Ok("migration") {
|
||
migration::run_migrations(&pool)
|
||
.await
|
||
.expect("apply migration schema");
|
||
}
|
||
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(&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 replaceable_writes_fail_closed_when_community_is_fenced() {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
let db = setup_db().await;
|
||
let community = CommunityId::from_uuid(make_community(&db.pool).await);
|
||
let store = db.deletion_store();
|
||
let host: String = sqlx::query_scalar("SELECT host FROM communities WHERE id = $1")
|
||
.bind(community.as_uuid())
|
||
.fetch_one(&db.pool)
|
||
.await
|
||
.expect("load community host");
|
||
let submitted = store
|
||
.submit(&host, "test-operator", Some("replaceable fence regression"))
|
||
.await
|
||
.expect("submit deletion request");
|
||
let empty_digest = crate::deletion::KeyStreamDigest::new().finish().0;
|
||
let empty_storage = crate::deletion::StorageManifest {
|
||
version: 4,
|
||
prefixes: vec![
|
||
crate::deletion::PrefixManifest {
|
||
prefix: format!("_meta/{community}/"),
|
||
object_count: 0,
|
||
total_bytes: 0,
|
||
keys_digest: empty_digest.clone(),
|
||
},
|
||
crate::deletion::PrefixManifest {
|
||
prefix: format!("_uploads/{community}/"),
|
||
object_count: 0,
|
||
total_bytes: 0,
|
||
keys_digest: empty_digest.clone(),
|
||
},
|
||
crate::deletion::PrefixManifest {
|
||
prefix: format!("repos/{community}/"),
|
||
object_count: 0,
|
||
total_bytes: 0,
|
||
keys_digest: empty_digest,
|
||
},
|
||
],
|
||
};
|
||
let inventory = crate::deletion::FrozenInventory {
|
||
schema: store
|
||
.inventory_schema(community)
|
||
.await
|
||
.expect("inventory schema"),
|
||
storage: empty_storage,
|
||
};
|
||
let request = store
|
||
.freeze_inventory(submitted.id, &inventory)
|
||
.await
|
||
.expect("freeze inventory");
|
||
store
|
||
.approve(request.id, "test-approver", Some("approved"))
|
||
.await
|
||
.expect("approve request");
|
||
let claim = store
|
||
.claim_specific(
|
||
request.id,
|
||
"test-executor",
|
||
crate::deletion::DEFAULT_LEASE_DURATION,
|
||
)
|
||
.await
|
||
.expect("claim request")
|
||
.expect("claim winner");
|
||
store
|
||
.begin_quiescing(&claim.lease)
|
||
.await
|
||
.expect("begin quiescing");
|
||
store.fence(&claim.lease).await.expect("fence community");
|
||
|
||
let keys = Keys::generate();
|
||
let base = Timestamp::now().as_secs();
|
||
let addressable = EventBuilder::new(Kind::Custom(0), "fenced-addressable")
|
||
.custom_created_at(Timestamp::from(base))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign addressable event");
|
||
let addressable_error = db
|
||
.replace_addressable_event(community, &addressable, None)
|
||
.await
|
||
.expect_err("fenced community must reject addressable replacement");
|
||
assert!(
|
||
matches!(&addressable_error, DbError::AccessDenied(message) if message.contains("write-fenced")),
|
||
"expected write-fenced access denial, got: {addressable_error:#}"
|
||
);
|
||
|
||
let d_tag = format!("read-state:{}", "f".repeat(32));
|
||
let parameterized = EventBuilder::new(
|
||
Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16),
|
||
"fenced-parameterized",
|
||
)
|
||
.tags(vec![Tag::parse(["d", d_tag.as_str()]).expect("d tag")])
|
||
.custom_created_at(Timestamp::from(base + 1))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign parameterized event");
|
||
let parameterized_error = db
|
||
.replace_parameterized_event(community, ¶meterized, &d_tag, None)
|
||
.await
|
||
.expect_err("fenced community must reject parameterized replacement");
|
||
assert!(
|
||
matches!(¶meterized_error, DbError::AccessDenied(message) if message.contains("write-fenced")),
|
||
"expected write-fenced access denial, got: {parameterized_error:#}"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn migration_schema_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_event_write_transaction(community)
|
||
.await
|
||
.expect("begin caller transaction");
|
||
let result = db
|
||
.replace_parameterized_event_in_transaction(
|
||
&mut tx,
|
||
&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(tx.conn())
|
||
.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(tx.conn())
|
||
.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_event_write_transaction(community)
|
||
.await
|
||
.expect("begin replacement tx");
|
||
let outcome = db
|
||
.replace_parameterized_event_in_transaction(
|
||
&mut tx,
|
||
&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_event_write_transaction(community)
|
||
.await
|
||
.expect("begin stale revision tx");
|
||
let mismatch = db
|
||
.replace_parameterized_event_in_transaction(
|
||
&mut tx,
|
||
&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 mut tx = db
|
||
.begin_event_write_transaction(community)
|
||
.await
|
||
.expect("begin occupied-coordinate tx");
|
||
let occupied = db
|
||
.replace_parameterized_event_in_transaction(
|
||
&mut tx,
|
||
&new,
|
||
&d_tag,
|
||
None,
|
||
replaceable::ParameterizedReplacePrecondition::ExpectNoHead,
|
||
)
|
||
.await
|
||
.expect("evaluate occupied coordinate");
|
||
assert_eq!(
|
||
occupied.status,
|
||
replaceable::ParameterizedReplaceStatus::RevisionMismatch
|
||
);
|
||
tx.rollback()
|
||
.await
|
||
.expect("roll back occupied-coordinate 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_event_write_transaction(community)
|
||
.await
|
||
.expect("begin missing revision tx");
|
||
let missing_result = db
|
||
.replace_parameterized_event_in_transaction(
|
||
&mut tx,
|
||
&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");
|
||
|
||
let mut tx = db
|
||
.begin_event_write_transaction(community)
|
||
.await
|
||
.expect("begin absent-coordinate tx");
|
||
let absent = db
|
||
.replace_parameterized_event_in_transaction(
|
||
&mut tx,
|
||
&missing,
|
||
&missing_d_tag,
|
||
None,
|
||
replaceable::ParameterizedReplacePrecondition::ExpectNoHead,
|
||
)
|
||
.await
|
||
.expect("insert absent coordinate");
|
||
assert_eq!(
|
||
absent.status,
|
||
replaceable::ParameterizedReplaceStatus::Inserted
|
||
);
|
||
tx.rollback().await.expect("roll back absent-coordinate 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_event_write_transaction(community)
|
||
.await
|
||
.expect("begin caller transaction");
|
||
let error = db
|
||
.replace_parameterized_event_in_transaction(
|
||
&mut tx,
|
||
&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(tx.conn())
|
||
.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(tx.conn())
|
||
.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(tx.conn())
|
||
.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_event_write_transaction(community)
|
||
.await
|
||
.expect("begin seed transaction");
|
||
let (_, was_inserted) = event::insert_event_in_transaction(&mut seed_tx, &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_event_write_transaction(community)
|
||
.await
|
||
.expect("begin caller transaction");
|
||
let result = db
|
||
.replace_parameterized_event_in_transaction(
|
||
&mut tx,
|
||
&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(tx.conn())
|
||
.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 migration_schema_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 migration_schema_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"));
|
||
}
|
||
}
|
||
|
||
/// NIP-09 deletion of a retention-free coordinate (NIP-RS read state, mesh
|
||
/// status) is physical in application code, so it holds on desired-state
|
||
/// databases that never installed the migration 0009/0019 purge triggers.
|
||
/// Ordinary addressable events, and look-alikes that fail classification,
|
||
/// keep the soft-delete contract.
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn nip09_deletion_purges_retention_free_rows_and_mentions() {
|
||
assert_nip09_deletion_purges_retention_free_rows_and_mentions(false).await;
|
||
}
|
||
|
||
/// The same contract on a migration-built database, where the 0009/0011/
|
||
/// 0019 purge and hard-delete fence triggers are still installed. Each row
|
||
/// shape is also soft-deleted the way a pre-0009 writer would, so the
|
||
/// table proves the app classification agrees with the triggers.
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn migration_schema_nip09_deletion_purges_retention_free_rows_and_mentions() {
|
||
assert_nip09_deletion_purges_retention_free_rows_and_mentions(true).await;
|
||
}
|
||
|
||
async fn assert_nip09_deletion_purges_retention_free_rows_and_mentions(migration: bool) {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
#[derive(Clone, Copy, Debug)]
|
||
enum Delete {
|
||
Coordinate,
|
||
Id,
|
||
}
|
||
|
||
let db = setup_db().await;
|
||
let triggers_installed: bool = sqlx::query_scalar(
|
||
"SELECT EXISTS(SELECT 1 FROM pg_trigger \
|
||
WHERE tgname = 'trg_events_purge_soft_deleted_nip_rs')",
|
||
)
|
||
.fetch_one(&db.pool)
|
||
.await
|
||
.expect("inspect purge trigger");
|
||
assert_eq!(
|
||
triggers_installed, migration,
|
||
"schema mode must match the test variant (run through the Postgres wrapper)"
|
||
);
|
||
let community = CommunityId::from_uuid(make_community(&db.pool).await);
|
||
let nip_rs = buzz_core::kind::KIND_READ_STATE as u16;
|
||
let mesh = buzz_core::kind::KIND_BOOKMARK_SET as u16;
|
||
let read_state_d = format!("read-state:{}", "b".repeat(32));
|
||
let mesh_d = "buzz-mesh-member-status:purge-test".to_string();
|
||
let tag = |parts: &[&str]| parts.iter().map(|p| p.to_string()).collect::<Vec<_>>();
|
||
let t_read_state = tag(&["t", "read-state"]);
|
||
let k_mesh = tag(&["k", "buzz-mesh-status"]);
|
||
// (label, kind, column d_tag, tags, physically purged)
|
||
type Case = (&'static str, u16, String, Vec<Vec<String>>, bool);
|
||
let cases: Vec<Case> = vec![
|
||
(
|
||
"nip-rs",
|
||
nip_rs,
|
||
read_state_d.clone(),
|
||
vec![tag(&["d", &read_state_d]), t_read_state.clone()],
|
||
true,
|
||
),
|
||
(
|
||
"nip-rs without t tag",
|
||
nip_rs,
|
||
read_state_d.clone(),
|
||
vec![tag(&["d", &read_state_d])],
|
||
false,
|
||
),
|
||
(
|
||
"nip-rs uppercase hex slot",
|
||
nip_rs,
|
||
format!("read-state:{}", "B".repeat(32)),
|
||
vec![
|
||
tag(&["d", &format!("read-state:{}", "B".repeat(32))]),
|
||
t_read_state.clone(),
|
||
],
|
||
false,
|
||
),
|
||
(
|
||
"nip-rs 31-char slot",
|
||
nip_rs,
|
||
format!("read-state:{}", "b".repeat(31)),
|
||
vec![
|
||
tag(&["d", &format!("read-state:{}", "b".repeat(31))]),
|
||
t_read_state.clone(),
|
||
],
|
||
false,
|
||
),
|
||
(
|
||
"nip-rs duplicate d tag",
|
||
nip_rs,
|
||
read_state_d.clone(),
|
||
vec![
|
||
tag(&["d", &read_state_d]),
|
||
tag(&["d", &read_state_d]),
|
||
t_read_state.clone(),
|
||
],
|
||
false,
|
||
),
|
||
(
|
||
"nip-rs duplicate t tag",
|
||
nip_rs,
|
||
read_state_d.clone(),
|
||
vec![
|
||
tag(&["d", &read_state_d]),
|
||
t_read_state.clone(),
|
||
t_read_state.clone(),
|
||
],
|
||
false,
|
||
),
|
||
(
|
||
"nip-rs d tag differs from column",
|
||
nip_rs,
|
||
read_state_d.clone(),
|
||
vec![
|
||
tag(&["d", &format!("read-state:{}", "c".repeat(32))]),
|
||
t_read_state.clone(),
|
||
],
|
||
false,
|
||
),
|
||
(
|
||
"mesh-status",
|
||
mesh,
|
||
mesh_d.clone(),
|
||
vec![tag(&["d", &mesh_d]), k_mesh.clone()],
|
||
true,
|
||
),
|
||
(
|
||
// Containment semantics (`tags @>`), shared by trigger and app.
|
||
"mesh-status k tag with extra element",
|
||
mesh,
|
||
mesh_d.clone(),
|
||
vec![
|
||
tag(&["d", &mesh_d]),
|
||
tag(&["k", "buzz-mesh-status", "extra"]),
|
||
],
|
||
true,
|
||
),
|
||
(
|
||
"mesh-status wrong d prefix",
|
||
mesh,
|
||
"buzz-mesh-member:purge-test".into(),
|
||
vec![tag(&["d", "buzz-mesh-member:purge-test"]), k_mesh.clone()],
|
||
false,
|
||
),
|
||
(
|
||
"mesh-status without k tag",
|
||
mesh,
|
||
mesh_d.clone(),
|
||
vec![tag(&["d", &mesh_d])],
|
||
false,
|
||
),
|
||
(
|
||
"mesh-status wrong k value",
|
||
mesh,
|
||
mesh_d.clone(),
|
||
vec![tag(&["d", &mesh_d]), tag(&["k", "buzz-mesh-other"])],
|
||
false,
|
||
),
|
||
(
|
||
"ordinary addressable",
|
||
buzz_core::kind::KIND_PROJECT as u16,
|
||
"retained-project".into(),
|
||
vec![tag(&["d", "retained-project"])],
|
||
false,
|
||
),
|
||
];
|
||
|
||
// Store one event of this shape with one mention; return
|
||
// (keys, id, created_at secs).
|
||
let store = |label: &'static str, kind: u16, d_tag: String, tags: Vec<Vec<String>>| {
|
||
let db = &db;
|
||
async move {
|
||
let keys = Keys::generate();
|
||
let created_at = Timestamp::now().as_secs();
|
||
let event = EventBuilder::new(Kind::Custom(kind), label)
|
||
.tags(
|
||
tags.iter()
|
||
.map(|tag| Tag::parse(tag.clone()).expect("tag"))
|
||
.collect::<Vec<_>>(),
|
||
)
|
||
.custom_created_at(Timestamp::from(created_at))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign event");
|
||
assert!(
|
||
db.replace_parameterized_event(community, &event, &d_tag, None)
|
||
.await
|
||
.expect("store event")
|
||
.1,
|
||
"{label}: event must be stored"
|
||
);
|
||
let id = event.id.as_bytes().to_vec();
|
||
sqlx::query(
|
||
"INSERT INTO event_mentions \
|
||
(community_id, pubkey_hex, event_id, event_created_at, channel_id, event_kind) \
|
||
SELECT community_id, $3, id, created_at, NULL, kind \
|
||
FROM events WHERE community_id = $1 AND id = $2",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(&id)
|
||
.bind("c".repeat(64))
|
||
.execute(&db.pool)
|
||
.await
|
||
.expect("index mention");
|
||
(keys, id, created_at)
|
||
}
|
||
};
|
||
// (event rows, live event rows, mention rows) for one id.
|
||
let state = |id: Vec<u8>| {
|
||
let pool = db.pool.clone();
|
||
async move {
|
||
let (rows, live): (i64, i64) = sqlx::query_as(
|
||
"SELECT count(*), count(*) FILTER (WHERE deleted_at IS NULL) \
|
||
FROM events WHERE community_id=$1 AND id=$2",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(&id)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("count event rows");
|
||
let mentions: i64 = sqlx::query_scalar(
|
||
"SELECT count(*) FROM event_mentions WHERE community_id=$1 AND event_id=$2",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(&id)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("count mentions");
|
||
(rows, live, mentions)
|
||
}
|
||
};
|
||
let expected = |purged: bool| -> (i64, i64, i64) {
|
||
if purged {
|
||
(0, 0, 0)
|
||
} else {
|
||
(1, 0, 1)
|
||
}
|
||
};
|
||
|
||
for (label, kind, d_tag, tags, purged) in cases {
|
||
if migration {
|
||
// Trigger half of the parity table: a pre-0009 writer's plain
|
||
// soft delete, classified only by the 0009/0011/0019 triggers.
|
||
let (_, id, _) = store(label, kind, d_tag.clone(), tags.clone()).await;
|
||
sqlx::query("UPDATE events SET deleted_at = NOW() WHERE community_id=$1 AND id=$2")
|
||
.bind(community.as_uuid())
|
||
.bind(&id)
|
||
.execute(&db.pool)
|
||
.await
|
||
.expect("legacy soft delete");
|
||
assert_eq!(
|
||
state(id).await,
|
||
expected(purged),
|
||
"{label}: trigger classification"
|
||
);
|
||
}
|
||
|
||
for mode in [Delete::Coordinate, Delete::Id] {
|
||
let (keys, id, created_at) = store(label, kind, d_tag.clone(), tags.clone()).await;
|
||
let delete = || async {
|
||
match mode {
|
||
Delete::Coordinate => event::soft_delete_by_coordinate(
|
||
&db.pool,
|
||
community,
|
||
i32::from(kind),
|
||
&keys.public_key().to_bytes(),
|
||
&d_tag,
|
||
(created_at + 1) as i64,
|
||
)
|
||
.await
|
||
.expect("coordinate delete"),
|
||
Delete::Id => event::soft_delete_event_and_update_thread(
|
||
&db.pool, community, &id, None, None,
|
||
)
|
||
.await
|
||
.expect("id delete"),
|
||
}
|
||
};
|
||
assert!(
|
||
delete().await,
|
||
"{label}/{mode:?}: deletion must report success"
|
||
);
|
||
assert_eq!(
|
||
state(id.clone()).await,
|
||
expected(purged),
|
||
"{label}/{mode:?}: app classification"
|
||
);
|
||
assert!(
|
||
!delete().await,
|
||
"{label}/{mode:?}: repeated deletion is a no-op"
|
||
);
|
||
}
|
||
}
|
||
|
||
// A mesh-status-shaped row whose `d_tag` column was never backfilled:
|
||
// `kind = 30003 AND NULL LIKE … AND tags @> …` makes the purge
|
||
// predicate NULL (a read-state row cannot: its tag/column EXISTS is
|
||
// false), which the triggers treat as false. The id path must still
|
||
// soft-delete it (the `COALESCE` in the generic soft delete), not
|
||
// silently skip it. Coordinate deletes key on `d_tag`, so only the id
|
||
// path can reach this row.
|
||
let null_d_tag_row = || async {
|
||
let (_, id, _) = store(
|
||
"mesh-status with NULL d_tag column",
|
||
mesh,
|
||
mesh_d.clone(),
|
||
vec![tag(&["d", &mesh_d]), k_mesh.clone()],
|
||
)
|
||
.await;
|
||
sqlx::query("UPDATE events SET d_tag = NULL WHERE community_id=$1 AND id=$2")
|
||
.bind(community.as_uuid())
|
||
.bind(&id)
|
||
.execute(&db.pool)
|
||
.await
|
||
.expect("clear d_tag column");
|
||
id
|
||
};
|
||
if migration {
|
||
let id = null_d_tag_row().await;
|
||
sqlx::query("UPDATE events SET deleted_at = NOW() WHERE community_id=$1 AND id=$2")
|
||
.bind(community.as_uuid())
|
||
.bind(&id)
|
||
.execute(&db.pool)
|
||
.await
|
||
.expect("legacy soft delete");
|
||
assert_eq!(
|
||
state(id).await,
|
||
expected(false),
|
||
"NULL d_tag: trigger classification"
|
||
);
|
||
}
|
||
let id = null_d_tag_row().await;
|
||
assert!(
|
||
event::soft_delete_event_and_update_thread(&db.pool, community, &id, None, None)
|
||
.await
|
||
.expect("id delete"),
|
||
"NULL d_tag/Id: deletion must report success"
|
||
);
|
||
assert_eq!(
|
||
state(id).await,
|
||
expected(false),
|
||
"NULL d_tag/Id: app classification"
|
||
);
|
||
}
|
||
|
||
/// A coordinate deletion racing a NIP-RS replacement must never leave a
|
||
/// soft-deleted read-state row behind. The purge waits on the old head's
|
||
/// row lock; the replacement commits a newer head that the purge's snapshot
|
||
/// cannot see; the generic soft delete then runs on a fresh snapshot and
|
||
/// must spare that head rather than soft-delete it.
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn coordinate_deletion_racing_nip_rs_replacement_never_soft_deletes_read_state() {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
use sqlx::postgres::PgConnectOptions;
|
||
use std::str::FromStr;
|
||
|
||
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:{}", "d".repeat(32));
|
||
let created_at = Timestamp::now().as_secs();
|
||
let old_head = EventBuilder::new(
|
||
Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16),
|
||
"old head",
|
||
)
|
||
.tags([
|
||
Tag::parse(["d", d_tag.as_str()]).expect("d tag"),
|
||
Tag::parse(["t", "read-state"]).expect("t tag"),
|
||
])
|
||
.custom_created_at(Timestamp::from(created_at))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign old head");
|
||
assert!(
|
||
db.replace_parameterized_event(community, &old_head, &d_tag, None)
|
||
.await
|
||
.expect("store old head")
|
||
.1
|
||
);
|
||
let old_id = old_head.id.as_bytes().to_vec();
|
||
let new_id = vec![0x5a_u8; 32];
|
||
|
||
// The replacement: insert a newer head (still at or before the
|
||
// tombstone), hard-delete the old head, and hold the transaction open.
|
||
let mut replacement = db.pool.begin().await.expect("begin replacement");
|
||
sqlx::query("SELECT set_config('buzz.nip_rs_hard_delete', 'on', true)")
|
||
.execute(&mut *replacement)
|
||
.await
|
||
.expect("opt in to hard delete");
|
||
sqlx::query(
|
||
"INSERT INTO events \
|
||
(community_id, id, pubkey, created_at, kind, tags, content, sig, channel_id, d_tag) \
|
||
SELECT community_id, $3, pubkey, created_at + interval '1 second', kind, tags, \
|
||
'new head', sig, channel_id, d_tag \
|
||
FROM events WHERE community_id = $1 AND id = $2",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(&old_id)
|
||
.bind(&new_id)
|
||
.execute(&mut *replacement)
|
||
.await
|
||
.expect("insert new head");
|
||
sqlx::query("DELETE FROM events WHERE community_id = $1 AND id = $2")
|
||
.bind(community.as_uuid())
|
||
.bind(&old_id)
|
||
.execute(&mut *replacement)
|
||
.await
|
||
.expect("delete old head");
|
||
|
||
let database_url =
|
||
std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into());
|
||
let application_name = format!("nip-rs-race-{}", Uuid::new_v4().simple());
|
||
let deleter_pool = PgPoolOptions::new()
|
||
.max_connections(1)
|
||
.connect_with(
|
||
PgConnectOptions::from_str(&database_url)
|
||
.expect("parse test database URL")
|
||
.application_name(&application_name),
|
||
)
|
||
.await
|
||
.expect("connect deleter pool");
|
||
let pubkey = keys.public_key().to_bytes();
|
||
let deletion = tokio::spawn({
|
||
let d_tag = d_tag.clone();
|
||
async move {
|
||
event::soft_delete_by_coordinate(
|
||
&deleter_pool,
|
||
community,
|
||
buzz_core::kind::KIND_READ_STATE as i32,
|
||
&pubkey,
|
||
&d_tag,
|
||
(created_at + 5) as i64,
|
||
)
|
||
.await
|
||
}
|
||
});
|
||
|
||
tokio::time::timeout(Duration::from_secs(10), async {
|
||
loop {
|
||
let waiting: bool = sqlx::query_scalar(
|
||
"SELECT EXISTS(SELECT 1 FROM pg_stat_activity \
|
||
WHERE datname = current_database() AND application_name = $1 \
|
||
AND wait_event_type = 'Lock')",
|
||
)
|
||
.bind(&application_name)
|
||
.fetch_one(&db.pool)
|
||
.await
|
||
.expect("inspect deleter lock");
|
||
if waiting {
|
||
break;
|
||
}
|
||
tokio::task::yield_now().await;
|
||
}
|
||
})
|
||
.await
|
||
.expect("deletion reached the old head's row lock");
|
||
|
||
replacement.commit().await.expect("commit replacement");
|
||
deletion
|
||
.await
|
||
.expect("join deletion")
|
||
.expect("coordinate delete");
|
||
|
||
let rows: Vec<(Vec<u8>, bool)> = sqlx::query_as(
|
||
"SELECT id, deleted_at IS NOT NULL FROM events \
|
||
WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(buzz_core::kind::KIND_READ_STATE as i32)
|
||
.bind(keys.public_key().to_bytes().as_slice())
|
||
.bind(&d_tag)
|
||
.fetch_all(&db.pool)
|
||
.await
|
||
.expect("read coordinate rows");
|
||
assert_eq!(
|
||
rows,
|
||
vec![(new_id, false)],
|
||
"the racing head is spared and no soft-deleted read-state row remains"
|
||
);
|
||
}
|
||
}
|