From bb3c39049d313a40cbaca445096e9078d6a82a65 Mon Sep 17 00:00:00 2001 From: npub1z3hmzc9ryehxzedl5wzlvpyvja0d483peaja5zt6pd0209f9x2jspe2dxh <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 10:30:35 -0400 Subject: [PATCH 01/16] refactor(db): gate postgres operations by backend Signed-off-by: npub1z3hmzc9ryehxzedl5wzlvpyvja0d483peaja5zt6pd0209f9x2jspe2dxh <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> --- crates/buzz-db/src/error.rs | 4 + crates/buzz-db/src/lib.rs | 601 +++++++++++++++++++++--------------- 2 files changed, 361 insertions(+), 244 deletions(-) diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index f8b8a2eb56..2dbfa210ab 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -13,6 +13,10 @@ pub enum DbError { #[error("migration error: {0}")] Migrate(#[from] sqlx::migrate::MigrateError), + /// Operation is unsupported by the selected database backend. + #[error("unsupported database backend operation: {0}")] + UnsupportedBackend(&'static str), + /// Attempted to store an AUTH event (kind 22242), which is forbidden. #[error("AUTH events (kind 22242) must not be stored")] AuthEventRejected, diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 9b26876747..0475fb4d66 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -168,9 +168,18 @@ pub async fn insert_mentions( Ok(()) } +/// Selected backing store for a [`Db`] handle. +#[derive(Clone, Debug)] +enum DbBackend { + Postgres, + SQLite(sqlx::SqlitePool), +} + /// Database handle. Clone is cheap (Arc-backed pool). #[derive(Clone, Debug)] pub struct Db { + backend: DbBackend, + // Retained for Postgres test helpers. All production method access goes through `pg_pool`. pub(crate) pool: PgPool, /// Maximum connections configured for this pool (from [`DbConfig::max_connections`]). pub(crate) max_connections: u32, @@ -639,6 +648,24 @@ pub struct TokenSummary { } impl Db { + /// Returns the PostgreSQL pool or a typed error for local SQLite handles. + fn pg_pool(&self) -> Result<&PgPool> { + match self.backend { + DbBackend::Postgres => Ok(&self.pool), + DbBackend::SQLite(_) => Err(DbError::UnsupportedBackend( + "PostgreSQL operation on SQLite Db", + )), + } + } + + /// Returns the SQLite pool for the local profile, if selected. + fn sqlite_pool(&self) -> Option<&sqlx::SqlitePool> { + match &self.backend { + DbBackend::SQLite(pool) => Some(pool), + DbBackend::Postgres => None, + } + } + /// Creates a new `Db` by connecting a Postgres pool with the given config. /// /// When `config.read_database_url` is set, a second pool with the same @@ -659,6 +686,7 @@ impl Db { }; let replica_read_max_age = read_budget_from_ms(config.replica_read_max_age_ms); Ok(Self { + backend: DbBackend::Postgres, pool, max_connections: config.max_connections, read_pool, @@ -774,6 +802,7 @@ impl Db { /// Creates a `Db` from an existing `PgPool` (useful in tests). pub fn from_pool(pool: PgPool) -> Self { Self { + backend: DbBackend::Postgres, max_connections: pool.options().get_max_connections(), read_max_connections: pool.options().get_max_connections(), pool, @@ -793,6 +822,7 @@ impl Db { /// [`Db::fence`]). pub fn from_pools(pool: PgPool, read_pool: PgPool) -> Self { Self { + backend: DbBackend::Postgres, max_connections: pool.options().get_max_connections(), read_max_connections: read_pool.options().get_max_connections(), pool, @@ -834,10 +864,10 @@ impl Db { if self.read_pool.is_none() { return Ok(false); } - replica_fence::verify_floor_guard_catalog(&self.pool).await?; - replica_fence::verify_floor_guard_behavior(&self.pool).await?; + replica_fence::verify_floor_guard_catalog(self.pg_pool()?).await?; + replica_fence::verify_floor_guard_behavior(self.pg_pool()?).await?; tokio::spawn(replica_fence::run_probe( - self.pool.clone(), + self.pg_pool()?.clone(), std::sync::Arc::clone(&self.fence), )); Ok(true) @@ -852,8 +882,8 @@ impl Db { /// reads must go through [`Db::route_read`]-backed entry points; this /// remains only for the fence's own plumbing tests. #[cfg(test)] - fn read(&self) -> &PgPool { - self.read_pool.as_ref().unwrap_or(&self.pool) + fn read(&self) -> Result<&PgPool> { + Ok(self.read_pool.as_ref().unwrap_or(self.pg_pool()?)) } /// Whether a distinct read-replica pool is configured. @@ -1010,12 +1040,15 @@ impl Db { /// Run pending database migrations. pub async fn migrate(&self) -> Result<()> { - migration::run_migrations(&self.pool).await + migration::run_migrations(self.pg_pool()?).await } /// Returns `true` if the database is reachable (used by readiness probes). pub async fn ping(&self) -> bool { - sqlx::query("SELECT 1").execute(&self.pool).await.is_ok() + match self.pg_pool() { + Ok(pool) => sqlx::query("SELECT 1").execute(pool).await.is_ok(), + Err(_) => false, + } } /// Returns pool utilisation stats for metrics emission. @@ -1024,9 +1057,19 @@ impl Db { /// `idle` — connections available for immediate reuse /// `max` — pool ceiling set at construction pub fn pool_stats(&self) -> DbPoolStats { + let Some(pool) = (match &self.backend { + DbBackend::Postgres => Some(&self.pool), + DbBackend::SQLite(_) => None, + }) else { + return DbPoolStats { + size: 0, + idle: 0, + max: 0, + }; + }; DbPoolStats { - size: self.pool.size(), - idle: self.pool.num_idle() as u32, + size: pool.size(), + idle: pool.num_idle() as u32, max: self.max_connections, } } @@ -1057,7 +1100,7 @@ impl Db { &self, lock_key: i64, ) -> Result> { - let mut connection = self.pool.acquire().await?; + let mut connection = self.pg_pool()?.acquire().await?; let acquired = sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)") .bind(lock_key) .fetch_one(&mut *connection) @@ -1085,7 +1128,7 @@ impl Db { limit: i64, ) -> Result> { admin_moderation::list_reports( - &self.pool, + self.pg_pool()?, community_id, status, report_type, @@ -1103,7 +1146,7 @@ impl Db { &self, id: Uuid, ) -> Result> { - admin_moderation::get_report(&self.pool, id).await + admin_moderation::get_report(self.pg_pool()?, id).await } /// List feedback for the deployment-global read-only admin plane. @@ -1111,7 +1154,7 @@ impl Db { &self, limit: i64, ) -> Result> { - admin_moderation::list_feedback(&self.pool, limit).await + admin_moderation::list_feedback(self.pg_pool()?, limit).await } /// Fetch one feedback submission for the deployment-global admin plane. @@ -1119,42 +1162,42 @@ impl Db { &self, id: Uuid, ) -> Result> { - admin_moderation::get_feedback(&self.pool, id).await + admin_moderation::get_feedback(self.pg_pool()?, id).await } /// Return total number of communities on this relay. pub async fn usage_community_count(&self) -> Result { - usage::community_count(&self.pool).await + usage::community_count(self.pg_pool()?).await } /// Return per-community user counts split by human/agent. pub async fn usage_user_counts(&self) -> Result> { - usage::user_counts(&self.pool).await + usage::user_counts(self.pg_pool()?).await } /// Return per-community channel counts by type. pub async fn usage_channel_counts(&self) -> Result> { - usage::channel_counts(&self.pool).await + usage::channel_counts(self.pg_pool()?).await } /// Return per-community kind=9 message counts. pub async fn usage_message_counts(&self) -> Result> { - usage::message_counts(&self.pool).await + usage::message_counts(self.pg_pool()?).await } /// Return per-community relay-member counts by role. pub async fn usage_relay_member_counts(&self) -> Result> { - usage::relay_member_counts(&self.pool).await + usage::relay_member_counts(self.pg_pool()?).await } /// Return per-community workflow counts by status. pub async fn usage_workflow_counts(&self) -> Result> { - usage::workflow_counts(&self.pool).await + usage::workflow_counts(self.pg_pool()?).await } /// Return per-community git-repo counts. pub async fn usage_git_repo_counts(&self) -> Result> { - usage::git_repo_counts(&self.pool).await + usage::git_repo_counts(self.pg_pool()?).await } /// Return per-community distinct active-user counts for a given SQL interval. @@ -1164,7 +1207,7 @@ impl Db { &self, interval_sql: &'static str, ) -> Result> { - usage::active_user_counts(&self.pool, interval_sql).await + usage::active_user_counts(self.pg_pool()?, interval_sql).await } /// Return per-community active-channel counts for a given SQL interval. @@ -1172,12 +1215,12 @@ impl Db { &self, interval_sql: &'static str, ) -> Result> { - usage::active_channel_counts(&self.pool, interval_sql).await + usage::active_channel_counts(self.pg_pool()?, interval_sql).await } /// Return all community id → host mappings. pub async fn usage_community_hosts(&self) -> Result> { - usage::community_hosts(&self.pool).await + usage::community_hosts(self.pg_pool()?).await } /// Begin a database transaction for atomic multi-statement operations. @@ -1185,7 +1228,7 @@ impl Db { /// Returns a `'static` transaction because `PgPool` is `Arc`-backed internally. /// The transaction holds an owned pool handle, not a borrow. pub async fn begin_transaction(&self) -> Result> { - self.pool.begin().await.map_err(Into::into) + self.pg_pool()?.begin().await.map_err(Into::into) } /// Returns the community mapped to a normalized request host, if one exists. @@ -1205,7 +1248,7 @@ impl Db { "#, ) .bind(normalized_host) - .fetch_optional(&self.pool) + .fetch_optional(self.pg_pool()?) .await?; row.map(|row| { @@ -1226,7 +1269,7 @@ impl Db { "SELECT EXISTS(SELECT 1 FROM communities WHERE id = $1 AND archived_at IS NULL)", ) .bind(community_id.as_uuid()) - .fetch_one(&self.pool) + .fetch_one(self.pg_pool()?) .await?; Ok(active) } @@ -1238,7 +1281,7 @@ impl Db { ) -> Result> { let row = sqlx::query("SELECT id, host FROM communities WHERE lower(host) = lower($1)") .bind(normalized_host) - .fetch_optional(&self.pool) + .fetch_optional(self.pg_pool()?) .await?; row.map(|row| { Ok(CommunityRecord { @@ -1269,7 +1312,7 @@ impl Db { "#, ) .bind(owner_pubkey) - .fetch_all(&self.pool) + .fetch_all(self.pg_pool()?) .await?; rows.into_iter() @@ -1308,7 +1351,7 @@ impl Db { "#, ) .bind(community_id.as_uuid()) - .fetch_optional(&self.pool) + .fetch_optional(self.pg_pool()?) .await?; row.map(|row| { @@ -1331,7 +1374,7 @@ impl Db { "#, ) .bind(community_id.as_uuid()) - .fetch_optional(&self.pool) + .fetch_optional(self.pg_pool()?) .await?; Ok(row @@ -1356,7 +1399,7 @@ impl Db { ) .bind(community_id.as_uuid()) .bind(icon) - .execute(&self.pool) + .execute(self.pg_pool()?) .await?; Ok(()) } @@ -1379,7 +1422,7 @@ impl Db { "#, ) .bind(normalized_host) - .fetch_one(&self.pool) + .fetch_one(self.pg_pool()?) .await?; let id: Uuid = row.try_get("id")?; @@ -1404,7 +1447,7 @@ impl Db { owner_pubkey: &str, ) -> Result { let owner_pubkey = owner_pubkey.to_ascii_lowercase(); - let mut tx = self.pool.begin().await?; + let mut tx = self.pg_pool()?.begin().await?; // Serialize on the owner pubkey so concurrent creates to the same // owner cannot both pass the ownership count check. @@ -1503,7 +1546,7 @@ impl Db { .bind(normalized_host) .bind(owner_pubkey) .bind(protected_deployment_host) - .fetch_optional(&self.pool) + .fetch_optional(self.pg_pool()?) .await?; row.map(|row| { Ok(ArchivedCommunityRecord { @@ -1533,7 +1576,7 @@ impl Db { ) .bind(normalized_host) .bind(owner_pubkey) - .fetch_optional(&self.pool) + .fetch_optional(self.pg_pool()?) .await?; row.map(|row| { Ok(UnarchivedCommunityRecord { @@ -1558,7 +1601,7 @@ impl Db { "#, ) .bind(channel_id) - .fetch_optional(&self.pool) + .fetch_optional(self.pg_pool()?) .await?; row.map(|row| { @@ -1602,7 +1645,7 @@ impl Db { "#, ) .bind(channel_ids) - .fetch_all(&self.pool) + .fetch_all(self.pg_pool()?) .await?; let mut out = std::collections::HashMap::with_capacity(rows.len()); @@ -1621,9 +1664,10 @@ impl Db { event: &nostr::Event, channel_id: Option, ) -> Result<(StoredEvent, bool)> { - let result = event::insert_event(&self.pool, community_id, event, channel_id).await?; + let result = event::insert_event(self.pg_pool()?, community_id, event, channel_id).await?; if result.1 { - if let Err(e) = insert_mentions(&self.pool, community_id, event, channel_id).await { + if let Err(e) = insert_mentions(self.pg_pool()?, community_id, event, channel_id).await + { tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); } } @@ -1638,7 +1682,7 @@ impl Db { /// [`Db::query_events_routed`] instead — converting a caller is an /// explicit, per-callsite decision, never a change to this method. pub async fn query_events(&self, q: &EventQuery) -> Result> { - event::query_events(&self.pool, q).await + event::query_events(self.pg_pool()?, q).await } /// [`Db::query_events`] with replica routing — the opt-in fast path for @@ -1675,11 +1719,11 @@ impl Db { // writer rather than surfacing a routed error. tracing::warn!(path, "replica read failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); - event::query_events(&self.pool, q).await + event::query_events(self.pg_pool()?, q).await } } } - RouteDecision::Writer => event::query_events(&self.pool, q).await, + RouteDecision::Writer => event::query_events(self.pg_pool()?, q).await, } } @@ -1706,11 +1750,11 @@ impl Db { Err(e) => { tracing::warn!(path, "replica read failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); - event::query_events(&self.pool, q).await + event::query_events(self.pg_pool()?, q).await } } } - RouteDecision::Writer => event::query_events(&self.pool, q).await, + RouteDecision::Writer => event::query_events(self.pg_pool()?, q).await, } } @@ -1719,7 +1763,7 @@ impl Db { /// Always reads from the WRITER pool — see [`Db::query_events`] for the /// writer-vs-routed rule. pub async fn count_events(&self, q: &EventQuery) -> Result { - event::count_events(&self.pool, q).await + event::count_events(self.pg_pool()?, q).await } /// [`Db::count_events`] with replica routing — same contract, rules, @@ -1743,11 +1787,11 @@ impl Db { Err(e) => { tracing::warn!(path, "replica count failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); - event::count_events(&self.pool, q).await + event::count_events(self.pg_pool()?, q).await } } } - RouteDecision::Writer => event::count_events(&self.pool, q).await, + RouteDecision::Writer => event::count_events(self.pg_pool()?, q).await, } } @@ -1761,7 +1805,7 @@ impl Db { creator_pubkey: &[u8], ) -> Result { event::huddle_started_link_exists( - &self.pool, + self.pg_pool()?, community_id, parent_channel_id, ephemeral_channel_id, @@ -1781,7 +1825,8 @@ impl Db { kind: i32, pubkey_bytes: &[u8], ) -> Result> { - event::get_latest_global_replaceable(&self.pool, community_id, kind, pubkey_bytes).await + event::get_latest_global_replaceable(self.pg_pool()?, community_id, kind, pubkey_bytes) + .await } /// Fetches a single non-deleted event by its raw ID bytes. @@ -1792,7 +1837,7 @@ impl Db { community_id: CommunityId, id_bytes: &[u8], ) -> Result> { - event::get_event_by_id(&self.pool, community_id, id_bytes).await + event::get_event_by_id(self.pg_pool()?, community_id, id_bytes).await } /// Fetches a single event by its raw ID bytes, **including soft-deleted rows**. @@ -1801,7 +1846,7 @@ impl Db { community_id: CommunityId, id_bytes: &[u8], ) -> Result> { - event::get_event_by_id_including_deleted(&self.pool, community_id, id_bytes).await + event::get_event_by_id_including_deleted(self.pg_pool()?, community_id, id_bytes).await } /// Soft-deletes an event. Returns `Ok(true)` if deleted, `Ok(false)` if already deleted. @@ -1810,7 +1855,7 @@ impl Db { community_id: CommunityId, event_id: &[u8], ) -> Result { - event::soft_delete_event(&self.pool, community_id, event_id).await + event::soft_delete_event(self.pg_pool()?, community_id, event_id).await } /// Soft-delete the live row for an addressable coordinate `(kind, pubkey, d_tag)` @@ -1826,7 +1871,7 @@ impl Db { deletion_created_at_secs: i64, ) -> Result { event::soft_delete_by_coordinate( - &self.pool, + self.pg_pool()?, community_id, kind, pubkey, @@ -1845,7 +1890,7 @@ impl Db { root_event_id: Option<&[u8]>, ) -> Result { event::soft_delete_event_and_update_thread( - &self.pool, + self.pg_pool()?, community_id, event_id, parent_event_id, @@ -1860,7 +1905,7 @@ impl Db { community_id: CommunityId, channel_id: Uuid, ) -> Result>> { - event::get_last_message_at(&self.pool, community_id, channel_id).await + event::get_last_message_at(self.pg_pool()?, community_id, channel_id).await } /// Bulk-fetch the most recent `created_at` for a set of channel IDs. @@ -1869,7 +1914,7 @@ impl Db { community_id: CommunityId, channel_ids: &[Uuid], ) -> Result>> { - event::get_last_message_at_bulk(&self.pool, community_id, channel_ids).await + event::get_last_message_at_bulk(self.pg_pool()?, community_id, channel_ids).await } /// Batch-fetch non-deleted events by their raw IDs. @@ -1878,7 +1923,7 @@ impl Db { community_id: CommunityId, ids: &[&[u8]], ) -> Result> { - event::get_events_by_ids(&self.pool, community_id, ids).await + event::get_events_by_ids(self.pg_pool()?, community_id, ids).await } /// [`Db::get_events_by_ids`] with replica routing — same contract and @@ -1904,11 +1949,13 @@ impl Db { Err(e) => { tracing::warn!(path, "replica read failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); - event::get_events_by_ids(&self.pool, community_id, ids).await + event::get_events_by_ids(self.pg_pool()?, community_id, ids).await } } } - RouteDecision::Writer => event::get_events_by_ids(&self.pool, community_id, ids).await, + RouteDecision::Writer => { + event::get_events_by_ids(self.pg_pool()?, community_id, ids).await + } } } @@ -1918,7 +1965,7 @@ impl Db { limit: i64, lease_until: DateTime, ) -> Result> { - push::claim_due_match_batch(&self.pool, limit, lease_until).await + push::claim_due_match_batch(self.pg_pool()?, limit, lease_until).await } /// Load active endpoint-enabled leases eligible for push matching. @@ -1926,7 +1973,7 @@ impl Db { &self, community: CommunityId, ) -> Result> { - push::active_match_leases(&self.pool, community).await + push::active_match_leases(self.pg_pool()?, community).await } /// Complete matcher jobs from one claimed batch while the fence holds. @@ -1936,7 +1983,7 @@ impl Db { claim_id: uuid::Uuid, event_ids: &[Vec], ) -> Result { - push::complete_match_batch(&self.pool, community, claim_id, event_ids).await + push::complete_match_batch(self.pg_pool()?, community, claim_id, event_ids).await } /// Release fenced matcher claims from one batch for retry. @@ -1947,12 +1994,12 @@ impl Db { event_ids: &[Vec], next: DateTime, ) -> Result { - push::retry_match_batch(&self.pool, community, claim_id, event_ids, next).await + push::retry_match_batch(self.pg_pool()?, community, claim_id, event_ids, next).await } /// Delete exhausted matcher jobs (periodic sweep, off the claim path). pub async fn reap_exhausted_push_matches(&self) -> Result { - push::reap_exhausted_matches(&self.pool).await + push::reap_exhausted_matches(self.pg_pool()?).await } /// Idempotently enqueue a wake for a matched lease and event. @@ -1963,7 +2010,7 @@ impl Db { installation_id: &str, wake: push::NewWake<'_>, ) -> Result { - push::enqueue_wake(&self.pool, community, author, installation_id, wake).await + push::enqueue_wake(self.pg_pool()?, community, author, installation_id, wake).await } /// Set-wise [`Self::enqueue_push_wake`]: one transaction per batch. @@ -1972,7 +2019,7 @@ impl Db { community: CommunityId, requests: &[push::WakeRequest], ) -> Result> { - push::enqueue_wakes(&self.pool, community, requests).await + push::enqueue_wakes(self.pg_pool()?, community, requests).await } /// Exclusively claim due wake jobs for one community. @@ -1982,7 +2029,7 @@ impl Db { limit: i64, lease_until: DateTime, ) -> Result> { - push::claim_due_wakes(&self.pool, community, limit, lease_until).await + push::claim_due_wakes(self.pg_pool()?, community, limit, lease_until).await } /// Revalidate a wake's claim, source event, and current lease before send. @@ -1992,7 +2039,7 @@ impl Db { id: Uuid, claim_id: Uuid, ) -> Result { - push::revalidate_wake_for_send(&self.pool, community, id, claim_id).await + push::revalidate_wake_for_send(self.pg_pool()?, community, id, claim_id).await } /// Mark a fenced wake claim delivered. @@ -2002,7 +2049,7 @@ impl Db { id: Uuid, claim_id: Uuid, ) -> Result { - push::complete_wake(&self.pool, community, id, claim_id).await + push::complete_wake(self.pg_pool()?, community, id, claim_id).await } /// Release a fenced wake claim for retry at the supplied time. @@ -2013,7 +2060,7 @@ impl Db { claim_id: Uuid, next: DateTime, ) -> Result { - push::retry_wake(&self.pool, community, id, claim_id, next).await + push::retry_wake(self.pg_pool()?, community, id, claim_id, next).await } /// Mark a fenced wake claim terminally failed. @@ -2023,7 +2070,7 @@ impl Db { id: Uuid, claim_id: Uuid, ) -> Result { - push::fail_wake(&self.pool, community, id, claim_id).await + push::fail_wake(self.pg_pool()?, community, id, claim_id).await } /// Disable an endpoint only if the specified lease generation is current. @@ -2035,7 +2082,7 @@ impl Db { generation: i64, ) -> Result { push::disable_endpoint_generation( - &self.pool, + self.pg_pool()?, community, author, installation_id, @@ -2056,7 +2103,7 @@ impl Db { max_active_leases: i64, ) -> Result { push::accept_lease_event( - &self.pool, + self.pg_pool()?, community, event, installation_id, @@ -2076,7 +2123,7 @@ impl Db { thread_meta: Option>, ) -> Result<(StoredEvent, bool)> { let result = event::insert_event_with_thread_metadata( - &self.pool, + self.pg_pool()?, community_id, event, channel_id, @@ -2084,7 +2131,8 @@ impl Db { ) .await?; if result.1 { - if let Err(e) = insert_mentions(&self.pool, community_id, event, channel_id).await { + if let Err(e) = insert_mentions(self.pg_pool()?, community_id, event, channel_id).await + { tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); } } @@ -2104,7 +2152,7 @@ impl Db { emoji: &str, ) -> Result { let outcome = event::insert_reaction_event_with_thread_metadata( - &self.pool, + self.pg_pool()?, community_id, event, channel_id, @@ -2118,7 +2166,8 @@ impl Db { was_inserted: true, .. } = &outcome { - if let Err(e) = insert_mentions(&self.pool, community_id, event, channel_id).await { + if let Err(e) = insert_mentions(self.pg_pool()?, community_id, event, channel_id).await + { tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); } } @@ -2138,7 +2187,7 @@ impl Db { ttl_seconds: Option, ) -> Result { channel::create_channel( - &self.pool, + self.pg_pool()?, community_id, name, channel_type, @@ -2166,7 +2215,7 @@ impl Db { ttl_seconds: Option, ) -> Result<(channel::ChannelRecord, bool)> { channel::create_channel_with_id( - &self.pool, + self.pg_pool()?, community_id, channel_id, name, @@ -2185,7 +2234,7 @@ impl Db { community_id: CommunityId, channel_id: Uuid, ) -> Result { - channel::get_channel(&self.pool, community_id, channel_id).await + channel::get_channel(self.pg_pool()?, community_id, channel_id).await } /// Returns the canvas content for a channel, if any. @@ -2194,7 +2243,7 @@ impl Db { community_id: CommunityId, channel_id: Uuid, ) -> Result> { - channel::get_canvas(&self.pool, community_id, channel_id).await + channel::get_canvas(self.pg_pool()?, community_id, channel_id).await } /// Sets or clears the canvas content for a channel. @@ -2204,7 +2253,7 @@ impl Db { channel_id: Uuid, canvas: Option<&str>, ) -> Result<()> { - channel::set_canvas(&self.pool, community_id, channel_id, canvas).await + channel::set_canvas(self.pg_pool()?, community_id, channel_id, canvas).await } /// Adds a member to a channel. @@ -2217,7 +2266,7 @@ impl Db { invited_by: Option<&[u8]>, ) -> Result { channel::add_member( - &self.pool, + self.pg_pool()?, community_id, channel_id, pubkey, @@ -2235,7 +2284,14 @@ impl Db { pubkey: &[u8], actor_pubkey: &[u8], ) -> Result<()> { - channel::remove_member(&self.pool, community_id, channel_id, pubkey, actor_pubkey).await + channel::remove_member( + self.pg_pool()?, + community_id, + channel_id, + pubkey, + actor_pubkey, + ) + .await } /// Returns `true` if the pubkey is an active member. @@ -2245,7 +2301,7 @@ impl Db { channel_id: Uuid, pubkey: &[u8], ) -> Result { - channel::is_member(&self.pool, community_id, channel_id, pubkey).await + channel::is_member(self.pg_pool()?, community_id, channel_id, pubkey).await } /// Return the active (channel, pubkey) membership pairs among the given @@ -2256,7 +2312,7 @@ impl Db { channel_ids: &[Uuid], pubkeys: &[Vec], ) -> Result)>> { - channel::membership_pairs(&self.pool, community_id, channel_ids, pubkeys).await + channel::membership_pairs(self.pg_pool()?, community_id, channel_ids, pubkeys).await } /// Returns all active members of a channel. @@ -2265,7 +2321,7 @@ impl Db { community_id: CommunityId, channel_id: Uuid, ) -> Result> { - channel::get_members(&self.pool, community_id, channel_id).await + channel::get_members(self.pg_pool()?, community_id, channel_id).await } /// Returns active members for multiple channels in a single query. @@ -2274,7 +2330,7 @@ impl Db { community_id: CommunityId, channel_ids: &[Uuid], ) -> Result> { - channel::get_members_bulk(&self.pool, community_id, channel_ids).await + channel::get_members_bulk(self.pg_pool()?, community_id, channel_ids).await } /// Get all channel IDs accessible to a pubkey. @@ -2283,7 +2339,7 @@ impl Db { community_id: CommunityId, pubkey: &[u8], ) -> Result> { - channel::get_accessible_channel_ids(&self.pool, community_id, pubkey).await + channel::get_accessible_channel_ids(self.pg_pool()?, community_id, pubkey).await } /// Lists channels, optionally filtered by visibility. @@ -2292,7 +2348,7 @@ impl Db { community_id: CommunityId, visibility: Option<&str>, ) -> Result> { - channel::list_channels(&self.pool, community_id, visibility).await + channel::list_channels(self.pg_pool()?, community_id, visibility).await } /// Returns full channel records for all channels a user can access. @@ -2304,7 +2360,7 @@ impl Db { member_only: Option, ) -> Result> { channel::get_accessible_channels( - &self.pool, + self.pg_pool()?, community_id, pubkey, visibility_filter, @@ -2318,7 +2374,7 @@ impl Db { &self, community_id: CommunityId, ) -> Result> { - channel::get_bot_members(&self.pool, community_id).await + channel::get_bot_members(self.pg_pool()?, community_id).await } /// Bulk-fetch user records by pubkey. @@ -2327,7 +2383,7 @@ impl Db { community_id: CommunityId, pubkeys: &[Vec], ) -> Result> { - channel::get_users_bulk(&self.pool, community_id, pubkeys).await + channel::get_users_bulk(self.pg_pool()?, community_id, pubkeys).await } /// Updates a channel's name and/or description. @@ -2337,7 +2393,7 @@ impl Db { channel_id: Uuid, updates: channel::ChannelUpdate, ) -> Result { - channel::update_channel(&self.pool, community_id, channel_id, updates).await + channel::update_channel(self.pg_pool()?, community_id, channel_id, updates).await } /// Sets the topic for a channel. @@ -2348,7 +2404,7 @@ impl Db { topic: &str, set_by: &[u8], ) -> Result<()> { - channel::set_topic(&self.pool, community_id, channel_id, topic, set_by).await + channel::set_topic(self.pg_pool()?, community_id, channel_id, topic, set_by).await } /// Sets the purpose for a channel. @@ -2359,12 +2415,12 @@ impl Db { purpose: &str, set_by: &[u8], ) -> Result<()> { - channel::set_purpose(&self.pool, community_id, channel_id, purpose, set_by).await + channel::set_purpose(self.pg_pool()?, community_id, channel_id, purpose, set_by).await } /// Archives a channel. pub async fn archive_channel(&self, community_id: CommunityId, channel_id: Uuid) -> Result<()> { - channel::archive_channel(&self.pool, community_id, channel_id).await + channel::archive_channel(self.pg_pool()?, community_id, channel_id).await } /// Unarchives a channel. @@ -2373,7 +2429,7 @@ impl Db { community_id: CommunityId, channel_id: Uuid, ) -> Result<()> { - channel::unarchive_channel(&self.pool, community_id, channel_id).await + channel::unarchive_channel(self.pg_pool()?, community_id, channel_id).await } /// Soft-delete a channel. @@ -2382,7 +2438,7 @@ impl Db { community_id: CommunityId, channel_id: Uuid, ) -> Result { - channel::soft_delete_channel(&self.pool, community_id, channel_id).await + channel::soft_delete_channel(self.pg_pool()?, community_id, channel_id).await } /// Returns the count of active members in a channel. @@ -2391,7 +2447,7 @@ impl Db { community_id: CommunityId, channel_id: Uuid, ) -> Result { - channel::get_member_count(&self.pool, community_id, channel_id).await + channel::get_member_count(self.pg_pool()?, community_id, channel_id).await } /// Bulk-fetch member counts for a set of channel IDs. @@ -2400,7 +2456,7 @@ impl Db { community_id: CommunityId, channel_ids: &[Uuid], ) -> Result> { - channel::get_member_counts_bulk(&self.pool, community_id, channel_ids).await + channel::get_member_counts_bulk(self.pg_pool()?, community_id, channel_ids).await } /// Get the active role of a pubkey in a channel. @@ -2410,14 +2466,14 @@ impl Db { channel_id: Uuid, pubkey: &[u8], ) -> Result> { - channel::get_member_role(&self.pool, community_id, channel_id, pubkey).await + channel::get_member_role(self.pg_pool()?, community_id, channel_id, pubkey).await } /// Archive ephemeral channels whose TTL deadline has passed. pub async fn reap_expired_ephemeral_channels( &self, ) -> Result> { - channel::reap_expired_ephemeral_channels(&self.pool).await + channel::reap_expired_ephemeral_channels(self.pg_pool()?).await } /// Query due reminders ready for delivery. @@ -2426,7 +2482,7 @@ impl Db { now_secs: i64, batch_limit: i64, ) -> Result> { - event::query_due_reminders(&self.pool, now_secs, batch_limit).await + event::query_due_reminders(self.pg_pool()?, now_secs, batch_limit).await } /// Atomically claim a due reminder for delivery (cross-pod dedup). @@ -2436,7 +2492,7 @@ impl Db { event_id: &[u8], event_created_at: chrono::DateTime, ) -> Result { - event::claim_due_reminder(&self.pool, community_id, event_id, event_created_at).await + event::claim_due_reminder(self.pg_pool()?, community_id, event_id, event_created_at).await } /// Atomically claim a due reminder using a caller-supplied delivery stamp. @@ -2448,7 +2504,7 @@ impl Db { delivery_stamp: i64, ) -> Result { event::claim_due_reminder_with_stamp( - &self.pool, + self.pg_pool()?, community_id, event_id, event_created_at, @@ -2466,7 +2522,7 @@ impl Db { delivery_stamp: i64, ) -> Result { event::release_due_reminder( - &self.pool, + self.pg_pool()?, community_id, event_id, event_created_at, @@ -2481,7 +2537,7 @@ impl Db { /// already existed. Callers use the `true` return to increment /// `buzz_users_created_total`. pub async fn ensure_user(&self, community_id: CommunityId, pubkey: &[u8]) -> Result { - user::ensure_user(&self.pool, community_id, pubkey).await + user::ensure_user(self.pg_pool()?, community_id, pubkey).await } /// Get a single user record by pubkey. @@ -2490,7 +2546,7 @@ impl Db { community_id: CommunityId, pubkey: &[u8], ) -> Result> { - user::get_user(&self.pool, community_id, pubkey).await + user::get_user(self.pg_pool()?, community_id, pubkey).await } /// Update a user's profile fields. @@ -2504,7 +2560,7 @@ impl Db { nip05_handle: Option<&str>, ) -> Result<()> { user::update_user_profile( - &self.pool, + self.pg_pool()?, community_id, pubkey, display_name, @@ -2522,7 +2578,7 @@ impl Db { local_part: &str, domain: &str, ) -> Result> { - user::get_user_by_nip05(&self.pool, community_id, local_part, domain).await + user::get_user_by_nip05(self.pg_pool()?, community_id, local_part, domain).await } /// Search users by display name, NIP-05 handle, or pubkey prefix. @@ -2532,7 +2588,7 @@ impl Db { query: &str, limit: u32, ) -> Result> { - user::search_users(&self.pool, community_id, query, limit).await + user::search_users(self.pg_pool()?, community_id, query, limit).await } /// Atomically set agent owner — only if no owner is currently assigned. @@ -2543,7 +2599,7 @@ impl Db { agent_pubkey: &[u8], owner_pubkey: &[u8], ) -> Result { - user::set_agent_owner(&self.pool, community_id, agent_pubkey, owner_pubkey).await + user::set_agent_owner(self.pg_pool()?, community_id, agent_pubkey, owner_pubkey).await } /// Get the channel_add_policy and agent_owner_pubkey for a user. @@ -2552,7 +2608,7 @@ impl Db { community_id: CommunityId, pubkey: &[u8], ) -> Result>)>> { - user::get_agent_channel_policy(&self.pool, community_id, pubkey).await + user::get_agent_channel_policy(self.pg_pool()?, community_id, pubkey).await } /// Check whether `actor_pubkey` is the agent owner of `target_pubkey`. @@ -2562,7 +2618,7 @@ impl Db { target_pubkey: &[u8], actor_pubkey: &[u8], ) -> Result { - user::is_agent_owner(&self.pool, community_id, target_pubkey, actor_pubkey).await + user::is_agent_owner(self.pg_pool()?, community_id, target_pubkey, actor_pubkey).await } /// Set the channel_add_policy for a user. @@ -2572,7 +2628,7 @@ impl Db { pubkey: &[u8], policy: &str, ) -> Result<()> { - user::set_channel_add_policy(&self.pool, community_id, pubkey, policy).await + user::set_channel_add_policy(self.pg_pool()?, community_id, pubkey, policy).await } /// Find an existing DM by its participant hash. @@ -2581,7 +2637,7 @@ impl Db { community_id: CommunityId, participant_hash: &[u8], ) -> Result> { - dm::find_dm_by_participants(&self.pool, community_id, participant_hash).await + dm::find_dm_by_participants(self.pg_pool()?, community_id, participant_hash).await } /// Create or return an existing DM channel. @@ -2591,7 +2647,7 @@ impl Db { participants: &[&[u8]], created_by: &[u8], ) -> Result { - dm::create_dm(&self.pool, community_id, participants, created_by).await + dm::create_dm(self.pg_pool()?, community_id, participants, created_by).await } /// List all DMs for a user. @@ -2602,7 +2658,7 @@ impl Db { limit: u32, cursor: Option, ) -> Result> { - dm::list_dms_for_user(&self.pool, community_id, pubkey, limit, cursor).await + dm::list_dms_for_user(self.pg_pool()?, community_id, pubkey, limit, cursor).await } /// Open or retrieve a DM for the given participants. @@ -2612,7 +2668,7 @@ impl Db { pubkeys: &[&[u8]], created_by: &[u8], ) -> Result<(channel::ChannelRecord, bool)> { - dm::open_dm(&self.pool, community_id, pubkeys, created_by).await + dm::open_dm(self.pg_pool()?, community_id, pubkeys, created_by).await } /// Hide a DM channel for a specific user. @@ -2625,7 +2681,7 @@ impl Db { channel_id: Uuid, pubkey: &[u8], ) -> Result<()> { - dm::hide_dm(&self.pool, community_id, channel_id, pubkey).await + dm::hide_dm(self.pg_pool()?, community_id, channel_id, pubkey).await } /// Unhide a DM channel for a specific user. @@ -2635,7 +2691,7 @@ impl Db { channel_id: Uuid, pubkey: &[u8], ) -> Result<()> { - dm::unhide_dm(&self.pool, community_id, channel_id, pubkey).await + dm::unhide_dm(self.pg_pool()?, community_id, channel_id, pubkey).await } /// List the channel IDs of all DMs the given user currently has hidden. @@ -2644,7 +2700,7 @@ impl Db { community_id: CommunityId, pubkey: &[u8], ) -> Result> { - dm::list_hidden_dms(&self.pool, community_id, pubkey).await + dm::list_hidden_dms(self.pg_pool()?, community_id, pubkey).await } /// Insert thread metadata. @@ -2663,7 +2719,7 @@ impl Db { broadcast: bool, ) -> Result<()> { thread::insert_thread_metadata( - &self.pool, + self.pg_pool()?, community_id, event_id, event_created_at, @@ -2762,7 +2818,7 @@ impl Db { } } thread::get_thread_replies( - &self.pool, + self.pg_pool()?, community_id, root_event_id, depth_limit, @@ -2778,7 +2834,7 @@ impl Db { community_id: CommunityId, event_id: &[u8], ) -> Result> { - thread::get_thread_summary(&self.pool, community_id, event_id).await + thread::get_thread_summary(self.pg_pool()?, community_id, event_id).await } /// One channel window: top-level rows + summaries + server `has_more`. @@ -2862,7 +2918,7 @@ impl Db { ReadSession { inner: ReadSessionInner::Replica { tx, - writer: self.pool.clone(), + writer: self.pg_pool()?.clone(), }, }, )); @@ -2886,7 +2942,7 @@ impl Db { RouteDecision::Writer => {} } let window = thread::get_channel_window( - &self.pool, + self.pg_pool()?, community_id, channel_id, limit, @@ -2897,7 +2953,7 @@ impl Db { Ok(( window, ReadSession { - inner: ReadSessionInner::Writer(self.pool.clone()), + inner: ReadSessionInner::Writer(self.pg_pool()?.clone()), }, )) } @@ -2994,7 +3050,7 @@ impl Db { community_id: CommunityId, event_id: &[u8], ) -> Result> { - thread::get_thread_metadata_by_event(&self.pool, community_id, event_id).await + thread::get_thread_metadata_by_event(self.pg_pool()?, community_id, event_id).await } /// Decrement reply counts. @@ -3004,8 +3060,13 @@ impl Db { parent_event_id: &[u8], root_event_id: Option<&[u8]>, ) -> Result<()> { - thread::decrement_reply_count(&self.pool, community_id, parent_event_id, root_event_id) - .await + thread::decrement_reply_count( + self.pg_pool()?, + community_id, + parent_event_id, + root_event_id, + ) + .await } /// Add (or re-activate) a reaction. @@ -3019,7 +3080,7 @@ impl Db { reaction_event_id: Option<&[u8]>, ) -> Result { reaction::add_reaction( - &self.pool, + self.pg_pool()?, community, event_id, event_created_at, @@ -3040,7 +3101,7 @@ impl Db { emoji: &str, ) -> Result { reaction::remove_reaction( - &self.pool, + self.pg_pool()?, community, event_id, event_created_at, @@ -3056,7 +3117,8 @@ impl Db { community: CommunityId, reaction_event_id: &[u8], ) -> Result { - reaction::remove_reaction_by_source_event_id(&self.pool, community, reaction_event_id).await + reaction::remove_reaction_by_source_event_id(self.pg_pool()?, community, reaction_event_id) + .await } /// Look up the active reaction row for one actor + emoji + target tuple. @@ -3069,7 +3131,7 @@ impl Db { emoji: &str, ) -> Result> { reaction::get_active_reaction_record( - &self.pool, + self.pg_pool()?, community, event_id, event_created_at, @@ -3090,7 +3152,7 @@ impl Db { reaction_event_id: &[u8], ) -> Result { reaction::set_reaction_event_id( - &self.pool, + self.pg_pool()?, community, event_id, event_created_at, @@ -3111,7 +3173,7 @@ impl Db { cursor: Option<&str>, ) -> Result> { reaction::get_reactions( - &self.pool, + self.pg_pool()?, community, event_id, event_created_at, @@ -3127,7 +3189,7 @@ impl Db { community: CommunityId, event_ids: &[(&[u8], DateTime)], ) -> Result> { - reaction::get_reactions_bulk(&self.pool, community, event_ids).await + reaction::get_reactions_bulk(self.pg_pool()?, community, event_ids).await } /// Find events that @mention the given pubkey. @@ -3140,7 +3202,7 @@ impl Db { limit: i64, ) -> Result> { feed::query_mentions( - &self.pool, + self.pg_pool()?, community, pubkey_bytes, accessible_channel_ids, @@ -3186,7 +3248,7 @@ impl Db { tracing::warn!(path, "replica read failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); feed::query_mentions( - &self.pool, + self.pg_pool()?, community, pubkey_bytes, accessible_channel_ids, @@ -3199,7 +3261,7 @@ impl Db { } RouteDecision::Writer => { feed::query_mentions( - &self.pool, + self.pg_pool()?, community, pubkey_bytes, accessible_channel_ids, @@ -3221,7 +3283,7 @@ impl Db { limit: i64, ) -> Result> { feed::query_needs_action( - &self.pool, + self.pg_pool()?, community, pubkey_bytes, accessible_channel_ids, @@ -3263,7 +3325,7 @@ impl Db { tracing::warn!(path, "replica read failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); feed::query_needs_action( - &self.pool, + self.pg_pool()?, community, pubkey_bytes, accessible_channel_ids, @@ -3276,7 +3338,7 @@ impl Db { } RouteDecision::Writer => { feed::query_needs_action( - &self.pool, + self.pg_pool()?, community, pubkey_bytes, accessible_channel_ids, @@ -3296,7 +3358,14 @@ impl Db { since: Option>, limit: i64, ) -> Result> { - feed::query_activity(&self.pool, community, accessible_channel_ids, since, limit).await + feed::query_activity( + self.pg_pool()?, + community, + accessible_channel_ids, + since, + limit, + ) + .await } /// [`Db::query_feed_activity`] with replica routing — BOUNDED arm only; @@ -3329,7 +3398,7 @@ impl Db { tracing::warn!(path, "replica read failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); feed::query_activity( - &self.pool, + self.pg_pool()?, community, accessible_channel_ids, since, @@ -3340,8 +3409,14 @@ impl Db { } } RouteDecision::Writer => { - feed::query_activity(&self.pool, community, accessible_channel_ids, since, limit) - .await + feed::query_activity( + self.pg_pool()?, + community, + accessible_channel_ids, + since, + limit, + ) + .await } } } @@ -3359,7 +3434,7 @@ impl Db { expires_at: Option>, ) -> Result { api_token::create_api_token( - &self.pool, + self.pg_pool()?, *community_id.as_uuid(), token_hash, owner_pubkey, @@ -3384,7 +3459,7 @@ impl Db { expires_at: Option>, ) -> Result> { api_token::create_api_token_if_under_limit( - &self.pool, + self.pg_pool()?, *community_id.as_uuid(), token_hash, owner_pubkey, @@ -3417,7 +3492,7 @@ impl Db { ) .bind(community_id.as_uuid()) .bind(hash) - .fetch_optional(&self.pool) + .fetch_optional(self.pg_pool()?) .await?; match row { @@ -3433,7 +3508,7 @@ impl Db { hash: &[u8], ) -> Result> { api_token::get_api_token_by_hash_including_revoked( - &self.pool, + self.pg_pool()?, *community_id.as_uuid(), hash, ) @@ -3447,7 +3522,7 @@ impl Db { ) .bind(community_id.as_uuid()) .bind(hash) - .execute(&self.pool) + .execute(self.pg_pool()?) .await?; Ok(()) } @@ -3473,7 +3548,7 @@ impl Db { "#, ) .bind(community_id.as_uuid()) - .fetch_all(&self.pool) + .fetch_all(self.pg_pool()?) .await?; let mut out = Vec::with_capacity(rows.len()); @@ -3501,7 +3576,7 @@ impl Db { community_id: CommunityId, pubkey: &[u8], ) -> Result> { - api_token::list_tokens_by_owner(&self.pool, *community_id.as_uuid(), pubkey).await + api_token::list_tokens_by_owner(self.pg_pool()?, *community_id.as_uuid(), pubkey).await } /// Revoke a single token by ID, scoped to (community, owner). @@ -3513,7 +3588,7 @@ impl Db { revoked_by: &[u8], ) -> Result { api_token::revoke_token( - &self.pool, + self.pg_pool()?, *community_id.as_uuid(), id, owner_pubkey, @@ -3530,7 +3605,7 @@ impl Db { revoked_by: &[u8], ) -> Result { api_token::revoke_all_tokens( - &self.pool, + self.pg_pool()?, *community_id.as_uuid(), owner_pubkey, revoked_by, @@ -3549,7 +3624,7 @@ impl Db { definition_hash: &[u8], ) -> Result { workflow::create_workflow( - &self.pool, + self.pg_pool()?, community_id, channel_id, owner_pubkey, @@ -3573,7 +3648,7 @@ impl Db { definition_hash: &[u8], ) -> Result<()> { workflow::upsert_workflow( - &self.pool, + self.pg_pool()?, community_id, id, channel_id, @@ -3591,7 +3666,7 @@ impl Db { community_id: CommunityId, id: Uuid, ) -> Result { - workflow::get_workflow(&self.pool, community_id, id).await + workflow::get_workflow(self.pg_pool()?, community_id, id).await } /// List workflows for a channel. @@ -3602,7 +3677,8 @@ impl Db { limit: Option, offset: Option, ) -> Result> { - workflow::list_channel_workflows(&self.pool, community_id, channel_id, limit, offset).await + workflow::list_channel_workflows(self.pg_pool()?, community_id, channel_id, limit, offset) + .await } /// List active, enabled workflows for a channel. @@ -3611,12 +3687,12 @@ impl Db { community_id: CommunityId, channel_id: Uuid, ) -> Result> { - workflow::list_enabled_channel_workflows(&self.pool, community_id, channel_id).await + workflow::list_enabled_channel_workflows(self.pg_pool()?, community_id, channel_id).await } /// List all active, enabled schedule-triggered workflows. pub async fn list_all_enabled_workflows(&self) -> Result> { - workflow::list_all_enabled_workflows(&self.pool).await + workflow::list_all_enabled_workflows(self.pg_pool()?).await } /// Claim a scheduled workflow fire for an authoritative schedule instant. @@ -3634,7 +3710,7 @@ impl Db { scheduled_for: chrono::DateTime, ) -> Result> { workflow::claim_scheduled_workflow_fire( - &self.pool, + self.pg_pool()?, community_id, workflow_id, scheduled_for, @@ -3648,7 +3724,7 @@ impl Db { community_id: CommunityId, workflow_id: Uuid, ) -> Result>> { - workflow::latest_scheduled_workflow_fire(&self.pool, community_id, workflow_id).await + workflow::latest_scheduled_workflow_fire(self.pg_pool()?, community_id, workflow_id).await } /// Attach the workflow run id created from a won scheduled-fire claim. @@ -3660,7 +3736,7 @@ impl Db { workflow_run_id: Uuid, ) -> Result { workflow::attach_scheduled_workflow_run( - &self.pool, + self.pg_pool()?, community_id, workflow_id, scheduled_for, @@ -3674,7 +3750,7 @@ impl Db { &self, older_than: chrono::DateTime, ) -> Result { - workflow::prune_scheduled_workflow_fires_before(&self.pool, older_than).await + workflow::prune_scheduled_workflow_fires_before(self.pg_pool()?, older_than).await } /// Update a workflow's name, definition, and hash. @@ -3687,7 +3763,7 @@ impl Db { definition_hash: &[u8], ) -> Result<()> { workflow::update_workflow( - &self.pool, + self.pg_pool()?, community_id, id, name, @@ -3704,7 +3780,7 @@ impl Db { id: Uuid, status: workflow::WorkflowStatus, ) -> Result<()> { - workflow::update_workflow_status(&self.pool, community_id, id, status).await + workflow::update_workflow_status(self.pg_pool()?, community_id, id, status).await } /// Enable or disable a workflow. @@ -3714,7 +3790,7 @@ impl Db { id: Uuid, enabled: bool, ) -> Result<()> { - workflow::set_workflow_enabled(&self.pool, community_id, id, enabled).await + workflow::set_workflow_enabled(self.pg_pool()?, community_id, id, enabled).await } /// Disable all of an owner's workflows in a channel (SEC-006, on @@ -3726,7 +3802,7 @@ impl Db { owner_pubkey: &[u8], ) -> Result { workflow::disable_workflows_for_owner_in_channel( - &self.pool, + self.pg_pool()?, community_id, channel_id, owner_pubkey, @@ -3736,7 +3812,7 @@ impl Db { /// Delete a workflow and all its runs/approvals. pub async fn delete_workflow(&self, community_id: CommunityId, id: Uuid) -> Result<()> { - workflow::delete_workflow(&self.pool, community_id, id).await + workflow::delete_workflow(self.pg_pool()?, community_id, id).await } /// Delete a workflow only when it belongs to the provided owner. @@ -3747,7 +3823,7 @@ impl Db { id: Uuid, owner_pubkey: &[u8], ) -> Result> { - workflow::delete_workflow_for_owner(&self.pool, community_id, id, owner_pubkey).await + workflow::delete_workflow_for_owner(self.pg_pool()?, community_id, id, owner_pubkey).await } /// Find a workflow by owner pubkey and name within a community. Used for @@ -3758,7 +3834,7 @@ impl Db { owner_pubkey: &[u8], name: &str, ) -> Result> { - workflow::find_by_owner_and_name(&self.pool, community_id, owner_pubkey, name).await + workflow::find_by_owner_and_name(self.pg_pool()?, community_id, owner_pubkey, name).await } /// Create a new workflow run. @@ -3770,7 +3846,7 @@ impl Db { trigger_context: Option<&serde_json::Value>, ) -> Result { workflow::create_workflow_run( - &self.pool, + self.pg_pool()?, community_id, workflow_id, trigger_event_id, @@ -3785,7 +3861,7 @@ impl Db { community_id: CommunityId, id: Uuid, ) -> Result { - workflow::get_workflow_run(&self.pool, community_id, id).await + workflow::get_workflow_run(self.pg_pool()?, community_id, id).await } /// List runs for a workflow. @@ -3795,7 +3871,7 @@ impl Db { workflow_id: Uuid, limit: i64, ) -> Result> { - workflow::list_workflow_runs(&self.pool, community_id, workflow_id, limit).await + workflow::list_workflow_runs(self.pg_pool()?, community_id, workflow_id, limit).await } /// Update a workflow run's status. @@ -3809,7 +3885,7 @@ impl Db { error: Option<&str>, ) -> Result<()> { workflow::update_workflow_run( - &self.pool, + self.pg_pool()?, community_id, id, status, @@ -3822,7 +3898,7 @@ impl Db { /// Create an approval request. pub async fn create_approval(&self, params: workflow::CreateApprovalParams<'_>) -> Result<()> { - workflow::create_approval(&self.pool, params).await + workflow::create_approval(self.pg_pool()?, params).await } /// Fetch an approval by raw token. @@ -3831,7 +3907,7 @@ impl Db { community_id: CommunityId, token: &str, ) -> Result { - workflow::get_approval(&self.pool, community_id, token).await + workflow::get_approval(self.pg_pool()?, community_id, token).await } /// Fetch an approval by its already-hashed token (no re-hashing). @@ -3840,7 +3916,7 @@ impl Db { community_id: CommunityId, token_hash: &[u8], ) -> Result { - workflow::get_approval_by_stored_hash(&self.pool, community_id, token_hash).await + workflow::get_approval_by_stored_hash(self.pg_pool()?, community_id, token_hash).await } /// Fetch all approvals for a workflow run. @@ -3850,7 +3926,7 @@ impl Db { workflow_id: uuid::Uuid, run_id: uuid::Uuid, ) -> Result> { - workflow::get_run_approvals(&self.pool, community_id, workflow_id, run_id).await + workflow::get_run_approvals(self.pg_pool()?, community_id, workflow_id, run_id).await } /// Update an approval's status. @@ -3863,7 +3939,7 @@ impl Db { note: Option<&str>, ) -> Result { workflow::update_approval( - &self.pool, + self.pg_pool()?, community_id, token, status, @@ -3883,7 +3959,7 @@ impl Db { note: Option<&str>, ) -> Result { workflow::update_approval_by_stored_hash( - &self.pool, + self.pg_pool()?, community_id, token_hash, status, @@ -3895,7 +3971,7 @@ impl Db { /// Ensures monthly partitions exist for the next N months. pub async fn ensure_future_partitions(&self, months_ahead: u32) -> Result<()> { - partition::ensure_future_partitions(&self.pool, months_ahead).await + partition::ensure_future_partitions(self.pg_pool()?, months_ahead).await } /// Backfill `d_tag` for existing NIP-33 events (kind 30000–39999) that have `d_tag IS NULL`. @@ -3912,7 +3988,7 @@ impl Db { ) \ WHERE kind BETWEEN 30000 AND 39999 AND d_tag IS NULL", ) - .execute(&self.pool) + .execute(self.pg_pool()?) .await?; Ok(result.rows_affected()) } @@ -3924,7 +4000,7 @@ impl Db { ) .bind(community.as_uuid()) .bind(pubkey) - .fetch_one(&self.pool) + .fetch_one(self.pg_pool()?) .await?; let cnt: i64 = row.try_get("cnt")?; Ok(cnt > 0) @@ -3935,7 +4011,7 @@ impl Db { let row = sqlx::query("SELECT COUNT(*) as cnt FROM pubkey_allowlist WHERE community_id = $1") .bind(community.as_uuid()) - .fetch_one(&self.pool) + .fetch_one(self.pg_pool()?) .await?; let cnt: i64 = row.try_get("cnt")?; Ok(cnt > 0) @@ -3957,7 +4033,7 @@ impl Db { .bind(pubkey) .bind(added_by) .bind(note) - .execute(&self.pool) + .execute(self.pg_pool()?) .await?; Ok(result.rows_affected() > 0) } @@ -3972,7 +4048,7 @@ impl Db { sqlx::query("DELETE FROM pubkey_allowlist WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) .bind(pubkey) - .execute(&self.pool) + .execute(self.pg_pool()?) .await?; Ok(result.rows_affected() > 0) } @@ -3983,7 +4059,7 @@ impl Db { "SELECT pubkey, added_by, added_at, note FROM pubkey_allowlist WHERE community_id = $1 ORDER BY added_at DESC", ) .bind(community.as_uuid()) - .fetch_all(&self.pool) + .fetch_all(self.pg_pool()?) .await?; let mut out = Vec::with_capacity(rows.len()); @@ -4018,12 +4094,12 @@ impl Db { Err(e) => { tracing::warn!(path, "replica read failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); - relay_members::is_relay_member(&self.pool, community, pubkey).await + relay_members::is_relay_member(self.pg_pool()?, community, pubkey).await } } } RouteDecision::Writer => { - relay_members::is_relay_member(&self.pool, community, pubkey).await + relay_members::is_relay_member(self.pg_pool()?, community, pubkey).await } } } @@ -4034,7 +4110,7 @@ impl Db { community: CommunityId, pubkey: &str, ) -> Result> { - relay_members::get_relay_member(&self.pool, community, pubkey).await + relay_members::get_relay_member(self.pg_pool()?, community, pubkey).await } /// Returns all relay members of `community` ordered by `created_at` ascending. @@ -4042,7 +4118,7 @@ impl Db { &self, community: CommunityId, ) -> Result> { - relay_members::list_relay_members(&self.pool, community).await + relay_members::list_relay_members(self.pg_pool()?, community).await } /// Adds a new relay member to `community`. @@ -4056,7 +4132,7 @@ impl Db { role: &str, added_by: Option<&str>, ) -> Result { - relay_members::add_relay_member(&self.pool, community, pubkey, role, added_by).await + relay_members::add_relay_member(self.pg_pool()?, community, pubkey, role, added_by).await } /// Claims relay membership via an invite and atomically persists the @@ -4068,8 +4144,14 @@ impl Db { role: &str, policy_version: Option<&str>, ) -> Result { - relay_members::claim_relay_membership(&self.pool, community, pubkey, role, policy_version) - .await + relay_members::claim_relay_membership( + self.pg_pool()?, + community, + pubkey, + role, + policy_version, + ) + .await } /// Returns whether a member has persisted acceptance evidence for a policy version. @@ -4079,8 +4161,13 @@ impl Db { pubkey: &str, policy_version: &str, ) -> Result { - relay_members::has_join_policy_acceptance(&self.pool, community, pubkey, policy_version) - .await + relay_members::has_join_policy_acceptance( + self.pg_pool()?, + community, + pubkey, + policy_version, + ) + .await } /// Removes a relay member from `community` atomically, refusing to delete the owner. @@ -4089,7 +4176,7 @@ impl Db { community: CommunityId, pubkey: &str, ) -> Result { - relay_members::remove_relay_member(&self.pool, community, pubkey).await + relay_members::remove_relay_member(self.pg_pool()?, community, pubkey).await } /// Removes a relay member from `community` only if their current role matches `expected_role`. @@ -4102,8 +4189,13 @@ impl Db { pubkey: &str, expected_role: &str, ) -> Result { - relay_members::remove_relay_member_if_role(&self.pool, community, pubkey, expected_role) - .await + relay_members::remove_relay_member_if_role( + self.pg_pool()?, + community, + pubkey, + expected_role, + ) + .await } /// Updates the role of an existing relay member in `community`. Returns `true` if updated. @@ -4113,12 +4205,12 @@ impl Db { pubkey: &str, new_role: &str, ) -> Result { - relay_members::update_relay_member_role(&self.pool, community, pubkey, new_role).await + relay_members::update_relay_member_role(self.pg_pool()?, community, pubkey, new_role).await } /// Ensures the owner pubkey exists with role `"owner"` in `community`. Called at startup. pub async fn bootstrap_owner(&self, community: CommunityId, owner_pubkey: &str) -> Result<()> { - relay_members::bootstrap_owner(&self.pool, community, owner_pubkey).await + relay_members::bootstrap_owner(self.pg_pool()?, community, owner_pubkey).await } /// Returns `true` if any member of `community` holds the `admin` or @@ -4138,7 +4230,7 @@ impl Db { expected_owner_pubkey: &str, ) -> Result { relay_members::transfer_ownership( - &self.pool, + self.pg_pool()?, community, new_owner_pubkey, expected_owner_pubkey, @@ -4151,7 +4243,7 @@ impl Db { /// Idempotent — uses `ON CONFLICT DO NOTHING`. Returns the number of rows /// inserted, or 0 if the `pubkey_allowlist` table doesn't exist. pub async fn backfill_from_allowlist(&self, community: CommunityId) -> Result { - relay_members::backfill_from_allowlist(&self.pool, community).await + relay_members::backfill_from_allowlist(self.pg_pool()?, community).await } /// Mints a v2 use-limited relay invite. The plaintext code is returned @@ -4166,7 +4258,8 @@ impl Db { ttl_secs: u64, max_uses: Option, ) -> Result { - relay_invite::mint_relay_invite(&self.pool, community, created_by, ttl_secs, max_uses).await + relay_invite::mint_relay_invite(self.pg_pool()?, community, created_by, ttl_secs, max_uses) + .await } /// Delete one bounded batch of invites expired before `cutoff`. @@ -4174,7 +4267,7 @@ impl Db { &self, cutoff: chrono::DateTime, ) -> Result { - relay_invite::reap_expired_relay_invites(&self.pool, cutoff).await + relay_invite::reap_expired_relay_invites(self.pg_pool()?, cutoff).await } /// Atomically claims a v2 relay invite. The full redemption (membership @@ -4190,7 +4283,7 @@ impl Db { policy_version: Option<&str>, ) -> Result { relay_invite::claim_relay_invite( - &self.pool, + self.pg_pool()?, community, token_hash, claimer_pubkey, @@ -4205,7 +4298,7 @@ impl Db { community: CommunityId, feedback: product_feedback::NewProductFeedback<'_>, ) -> Result { - product_feedback::insert(&self.pool, community, feedback).await + product_feedback::insert(self.pg_pool()?, community, feedback).await } /// List product feedback across the deployment, newest first. @@ -4213,7 +4306,7 @@ impl Db { &self, limit: i64, ) -> Result> { - product_feedback::list(&self.pool, limit).await + product_feedback::list(self.pg_pool()?, limit).await } /// Insert a tenant-scoped NIP-56 report row, idempotent by report event id. @@ -4222,7 +4315,7 @@ impl Db { community: CommunityId, report: moderation::NewReport<'_>, ) -> Result { - moderation::insert_report(&self.pool, community, report).await + moderation::insert_report(self.pg_pool()?, community, report).await } /// List moderation reports for a community, newest first. @@ -4232,7 +4325,7 @@ impl Db { status: Option<&str>, limit: i64, ) -> Result> { - moderation::list_reports(&self.pool, community, status, limit).await + moderation::list_reports(self.pg_pool()?, community, status, limit).await } /// Fetch one moderation report by row id. @@ -4241,7 +4334,7 @@ impl Db { community: CommunityId, report_id: Uuid, ) -> Result> { - moderation::get_report(&self.pool, community, report_id).await + moderation::get_report(self.pg_pool()?, community, report_id).await } /// Fetch one moderation report by signed NIP-56 report event id. @@ -4250,7 +4343,7 @@ impl Db { community: CommunityId, report_event_id: &[u8], ) -> Result> { - moderation::get_report_by_event(&self.pool, community, report_event_id).await + moderation::get_report_by_event(self.pg_pool()?, community, report_event_id).await } /// Resolve, dismiss, or escalate an open moderation report. @@ -4263,7 +4356,7 @@ impl Db { action_id: Option, ) -> Result { moderation::resolve_report( - &self.pool, + self.pg_pool()?, community, report_id, status, @@ -4282,7 +4375,15 @@ impl Db { reason: Option<&str>, expires_at: Option>, ) -> Result<()> { - moderation::ban_member(&self.pool, community, pubkey, actor, reason, expires_at).await + moderation::ban_member( + self.pg_pool()?, + community, + pubkey, + actor, + reason, + expires_at, + ) + .await } /// Lift a community ban for a member pubkey. @@ -4292,7 +4393,7 @@ impl Db { pubkey: &[u8], actor: &[u8], ) -> Result { - moderation::unban_member(&self.pool, community, pubkey, actor).await + moderation::unban_member(self.pg_pool()?, community, pubkey, actor).await } /// Upsert a community timeout/write-block for a member pubkey. @@ -4304,7 +4405,15 @@ impl Db { muted_until: DateTime, reason: Option<&str>, ) -> Result<()> { - moderation::timeout_member(&self.pool, community, pubkey, actor, muted_until, reason).await + moderation::timeout_member( + self.pg_pool()?, + community, + pubkey, + actor, + muted_until, + reason, + ) + .await } /// Clear a community timeout/write-block for a member pubkey. @@ -4314,7 +4423,7 @@ impl Db { pubkey: &[u8], actor: &[u8], ) -> Result { - moderation::untimeout_member(&self.pool, community, pubkey, actor).await + moderation::untimeout_member(self.pg_pool()?, community, pubkey, actor).await } /// Fetch the active ban/timeout restriction state for enforcement hot paths. @@ -4323,7 +4432,7 @@ impl Db { community: CommunityId, pubkey: &[u8], ) -> Result { - moderation::restriction_state(&self.pool, community, pubkey).await + moderation::restriction_state(self.pg_pool()?, community, pubkey).await } /// Fetch the full ban/timeout row for a member pubkey. @@ -4332,7 +4441,7 @@ impl Db { community: CommunityId, pubkey: &[u8], ) -> Result> { - moderation::get_ban(&self.pool, community, pubkey).await + moderation::get_ban(self.pg_pool()?, community, pubkey).await } /// List currently restricted members in a community. @@ -4340,7 +4449,7 @@ impl Db { &self, community: CommunityId, ) -> Result> { - moderation::list_restricted(&self.pool, community).await + moderation::list_restricted(self.pg_pool()?, community).await } /// Insert a moderation audit action row. @@ -4349,7 +4458,7 @@ impl Db { community: CommunityId, action: moderation::NewAction<'_>, ) -> Result { - moderation::insert_action(&self.pool, community, action).await + moderation::insert_action(self.pg_pool()?, community, action).await } /// List moderation audit action rows, newest first. @@ -4358,7 +4467,7 @@ impl Db { community: CommunityId, limit: i64, ) -> Result> { - moderation::list_actions(&self.pool, community, limit).await + moderation::list_actions(self.pg_pool()?, community, limit).await } /// Return the current owner of git repo name `repo_id` in `community`, or @@ -4368,7 +4477,7 @@ impl Db { community: CommunityId, repo_id: &str, ) -> Result> { - git_repo::repo_name_owner(&self.pool, community, repo_id).await + git_repo::repo_name_owner(self.pg_pool()?, community, repo_id).await } /// Reserve a git repo name for `owner_pubkey` in `community` (NIP-34). @@ -4381,7 +4490,7 @@ impl Db { repo_id: &str, owner_pubkey: &str, ) -> Result { - git_repo::reserve_repo_name(&self.pool, community, repo_id, owner_pubkey).await + git_repo::reserve_repo_name(self.pg_pool()?, community, repo_id, owner_pubkey).await } /// Count git repos reserved by `owner_pubkey` in `community` (quota check). @@ -4390,7 +4499,7 @@ impl Db { community: CommunityId, owner_pubkey: &str, ) -> Result { - git_repo::count_repos_for_owner(&self.pool, community, owner_pubkey).await + git_repo::count_repos_for_owner(self.pg_pool()?, community, owner_pubkey).await } /// Release a git repo name reservation held by `owner_pubkey` (rollback). @@ -4402,12 +4511,12 @@ impl Db { repo_id: &str, owner_pubkey: &str, ) -> Result { - git_repo::release_repo_name(&self.pool, community, repo_id, owner_pubkey).await + git_repo::release_repo_name(self.pg_pool()?, community, repo_id, owner_pubkey).await } /// Returns `true` if `pubkey` (64-char hex) is archived in `community_id`. pub async fn is_archived(&self, community_id: CommunityId, pubkey: &str) -> Result { - archived_identities::is_archived(&self.pool, community_id, pubkey).await + archived_identities::is_archived(self.pg_pool()?, community_id, pubkey).await } /// Archives an identity in `community_id`. Returns `true` if inserted, `false` if already archived. @@ -4423,7 +4532,7 @@ impl Db { request_event_id: &str, ) -> Result { archived_identities::archive( - &self.pool, + self.pg_pool()?, community_id, pubkey, consent_path, @@ -4437,7 +4546,7 @@ impl Db { /// Unarchives an identity from `community_id`. Returns `true` if deleted, `false` if absent. pub async fn unarchive(&self, community_id: CommunityId, pubkey: &str) -> Result { - archived_identities::unarchive(&self.pool, community_id, pubkey).await + archived_identities::unarchive(self.pg_pool()?, community_id, pubkey).await } /// Returns all identities archived in `community_id`, ordered by archive time ascending. @@ -4445,7 +4554,7 @@ impl Db { &self, community_id: CommunityId, ) -> Result> { - archived_identities::list_archived(&self.pool, community_id).await + archived_identities::list_archived(self.pg_pool()?, community_id).await } /// Soft-delete NIP-29 discovery events for a channel created by a specific relay pubkey. @@ -4462,7 +4571,7 @@ impl Db { .bind(community_id.as_uuid()) .bind(channel_id) .bind(relay_pubkey) - .execute(&self.pool) + .execute(self.pg_pool()?) .await?; Ok(result.rows_affected()) } @@ -4494,7 +4603,7 @@ impl Db { channel_id.as_ref().map(|id| id.as_bytes().as_slice()), ); - let mut tx = self.pool.begin().await?; + let mut tx = self.pg_pool()?.begin().await?; // Serialize all writers for the same (kind, pubkey, channel_id) tuple. // Advisory lock is transaction-scoped — released on commit/rollback. @@ -4590,7 +4699,9 @@ impl Db { // Mentions are a denormalized index — safe outside the transaction. // insert_event() normally handles this, but we inlined the INSERT above. - if let Err(e) = crate::insert_mentions(&self.pool, community_id, event, channel_id).await { + if let Err(e) = + crate::insert_mentions(self.pg_pool()?, community_id, event, channel_id).await + { tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); } @@ -4669,7 +4780,7 @@ impl Db { let lock_key = event_replacement_lock_key(community_id, kind_i32, pubkey_bytes.as_slice(), None); - let mut tx = self.pool.begin().await?; + let mut tx = self.pg_pool()?.begin().await?; // Acquire the per-community snapshot lock BEFORE reading members. // This serializes the entire read-build-write cycle: a concurrent @@ -4764,7 +4875,7 @@ impl Db { tx.commit().await?; - if let Err(e) = crate::insert_mentions(&self.pool, community_id, &event, None).await { + if let Err(e) = crate::insert_mentions(self.pg_pool()?, community_id, &event, None).await { tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); } @@ -4816,7 +4927,7 @@ impl Db { Some(d_tag.as_bytes()), ); - let mut tx = self.pool.begin().await?; + let mut tx = self.pg_pool()?.begin().await?; sqlx::query("SELECT pg_advisory_xact_lock($1)") .bind(lock_key) @@ -5001,7 +5112,9 @@ impl Db { tx.commit().await?; // Mentions are a denormalized index — safe outside the transaction. - if let Err(e) = crate::insert_mentions(&self.pool, community_id, event, channel_id).await { + if let Err(e) = + crate::insert_mentions(self.pg_pool()?, community_id, event, channel_id).await + { tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); } @@ -6617,7 +6730,7 @@ mod tests { let db = Db::from_pool(pool); assert!(!db.has_read_pool()); assert!( - std::ptr::eq(db.read(), &db.pool), + std::ptr::eq(db.read().expect("Postgres test DB"), &db.pool), "read() must be the writer pool when no replica is configured" ); assert!(db.read_pool_stats().is_none()); From 189abc62ae665b820259589148a35d28b60642a4 Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 10:48:11 -0400 Subject: [PATCH 02/16] feat(db): add SQLite core collaboration slice Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- crates/buzz-db/src/lib.rs | 240 ++++++++-- crates/buzz-db/src/sqlite.rs | 835 +++++++++++++++++++++++++++++++++++ 2 files changed, 1037 insertions(+), 38 deletions(-) create mode 100644 crates/buzz-db/src/sqlite.rs diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 0475fb4d66..036015a699 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -45,6 +45,7 @@ pub mod relay_invite; pub mod relay_members; /// Replica freshness fence for keyset-cursor read routing. pub mod replica_fence; +mod sqlite; /// Thread metadata persistence. pub mod thread; /// Per-community usage rollup queries for Prometheus gauges. @@ -658,14 +659,36 @@ impl Db { } } - /// Returns the SQLite pool for the local profile, if selected. - fn sqlite_pool(&self) -> Option<&sqlx::SqlitePool> { + /// Returns the SQLite pool or a typed error for PostgreSQL handles. + fn sqlite_pool(&self) -> Result<&sqlx::SqlitePool> { match &self.backend { - DbBackend::SQLite(pool) => Some(pool), - DbBackend::Postgres => None, + DbBackend::SQLite(pool) => Ok(pool), + DbBackend::Postgres => Err(DbError::UnsupportedBackend( + "SQLite operation on PostgreSQL Db", + )), } } + /// Creates a local single-node database backed by SQLite. + /// + /// The embedded local schema is applied before this constructor returns. + pub async fn new_sqlite(database_url: &str) -> Result { + let sqlite = sqlite::connect(database_url).await?; + let pool = PgPoolOptions::new() + .max_connections(1) + .connect_lazy("postgres://unused:unused@127.0.0.1/unused")?; + Ok(Self { + backend: DbBackend::SQLite(sqlite), + pool, + max_connections: 0, + read_pool: None, + read_max_connections: 0, + fence: std::sync::Arc::new(replica_fence::ReplicaFence::new()), + replica_read_max_age: None, + reader_aurora_identity: std::sync::Arc::new(std::sync::OnceLock::new()), + }) + } + /// Creates a new `Db` by connecting a Postgres pool with the given config. /// /// When `config.read_database_url` is set, a second pool with the same @@ -1040,14 +1063,17 @@ impl Db { /// Run pending database migrations. pub async fn migrate(&self) -> Result<()> { - migration::run_migrations(self.pg_pool()?).await + match &self.backend { + DbBackend::Postgres => migration::run_migrations(self.pg_pool()?).await, + DbBackend::SQLite(_) => sqlite::migrate(self.sqlite_pool()?).await, + } } /// Returns `true` if the database is reachable (used by readiness probes). pub async fn ping(&self) -> bool { - match self.pg_pool() { - Ok(pool) => sqlx::query("SELECT 1").execute(pool).await.is_ok(), - Err(_) => false, + match &self.backend { + DbBackend::Postgres => sqlx::query("SELECT 1").execute(&self.pool).await.is_ok(), + DbBackend::SQLite(pool) => sqlx::query("SELECT 1").execute(pool).await.is_ok(), } } @@ -1239,6 +1265,9 @@ impl Db { &self, normalized_host: &str, ) -> Result> { + if matches!(&self.backend, DbBackend::SQLite(_)) { + return sqlite::lookup_community_by_host(self.sqlite_pool()?, normalized_host).await; + } let row = sqlx::query( r#" SELECT id, host @@ -1265,6 +1294,9 @@ impl Db { /// Returns whether a community id still exists in the active lifecycle state. pub async fn is_community_active(&self, community_id: CommunityId) -> Result { + if matches!(&self.backend, DbBackend::SQLite(_)) { + return sqlite::is_community_active(self.sqlite_pool()?, community_id).await; + } let active = sqlx::query_scalar::<_, bool>( "SELECT EXISTS(SELECT 1 FROM communities WHERE id = $1 AND archived_at IS NULL)", ) @@ -1342,6 +1374,9 @@ impl Db { /// community is authoritative; the host is read back for labelling only and /// is never used to re-derive the community. pub async fn lookup_community_host(&self, community_id: CommunityId) -> Result> { + if matches!(&self.backend, DbBackend::SQLite(_)) { + return sqlite::lookup_community_host(self.sqlite_pool()?, community_id).await; + } let row = sqlx::query( r#" SELECT host @@ -1413,6 +1448,9 @@ impl Db { &self, normalized_host: &str, ) -> Result { + if matches!(&self.backend, DbBackend::SQLite(_)) { + return sqlite::ensure_configured_community(self.sqlite_pool()?, normalized_host).await; + } let row = sqlx::query( r#" INSERT INTO communities (host) @@ -1664,6 +1702,9 @@ impl Db { event: &nostr::Event, channel_id: Option, ) -> Result<(StoredEvent, bool)> { + if let DbBackend::SQLite(pool) = &self.backend { + return sqlite::insert_event(pool, community_id, event, channel_id).await; + } let result = event::insert_event(self.pg_pool()?, community_id, event, channel_id).await?; if result.1 { if let Err(e) = insert_mentions(self.pg_pool()?, community_id, event, channel_id).await @@ -1682,7 +1723,10 @@ impl Db { /// [`Db::query_events_routed`] instead — converting a caller is an /// explicit, per-callsite decision, never a change to this method. pub async fn query_events(&self, q: &EventQuery) -> Result> { - event::query_events(self.pg_pool()?, q).await + match &self.backend { + DbBackend::SQLite(pool) => sqlite::query_events(pool, q).await, + DbBackend::Postgres => event::query_events(self.pg_pool()?, q).await, + } } /// [`Db::query_events`] with replica routing — the opt-in fast path for @@ -1837,7 +1881,12 @@ impl Db { community_id: CommunityId, id_bytes: &[u8], ) -> Result> { - event::get_event_by_id(self.pg_pool()?, community_id, id_bytes).await + match &self.backend { + DbBackend::SQLite(pool) => sqlite::get_event_by_id(pool, community_id, id_bytes).await, + DbBackend::Postgres => { + event::get_event_by_id(self.pg_pool()?, community_id, id_bytes).await + } + } } /// Fetches a single event by its raw ID bytes, **including soft-deleted rows**. @@ -2214,6 +2263,20 @@ impl Db { created_by: &[u8], ttl_seconds: Option, ) -> Result<(channel::ChannelRecord, bool)> { + if let DbBackend::SQLite(pool) = &self.backend { + return sqlite::create_channel_with_id( + pool, + community_id, + channel_id, + name, + channel_type, + visibility, + description, + created_by, + ttl_seconds, + ) + .await; + } channel::create_channel_with_id( self.pg_pool()?, community_id, @@ -2234,7 +2297,12 @@ impl Db { community_id: CommunityId, channel_id: Uuid, ) -> Result { - channel::get_channel(self.pg_pool()?, community_id, channel_id).await + match &self.backend { + DbBackend::SQLite(pool) => sqlite::get_channel(pool, community_id, channel_id).await, + DbBackend::Postgres => { + channel::get_channel(self.pg_pool()?, community_id, channel_id).await + } + } } /// Returns the canvas content for a channel, if any. @@ -2265,6 +2333,10 @@ impl Db { role: channel::MemberRole, invited_by: Option<&[u8]>, ) -> Result { + if let DbBackend::SQLite(pool) = &self.backend { + return sqlite::add_member(pool, community_id, channel_id, pubkey, role, invited_by) + .await; + } channel::add_member( self.pg_pool()?, community_id, @@ -2301,7 +2373,14 @@ impl Db { channel_id: Uuid, pubkey: &[u8], ) -> Result { - channel::is_member(self.pg_pool()?, community_id, channel_id, pubkey).await + match &self.backend { + DbBackend::SQLite(pool) => { + sqlite::is_member(pool, community_id, channel_id, pubkey).await + } + DbBackend::Postgres => { + channel::is_member(self.pg_pool()?, community_id, channel_id, pubkey).await + } + } } /// Return the active (channel, pubkey) membership pairs among the given @@ -2321,7 +2400,12 @@ impl Db { community_id: CommunityId, channel_id: Uuid, ) -> Result> { - channel::get_members(self.pg_pool()?, community_id, channel_id).await + match &self.backend { + DbBackend::SQLite(pool) => sqlite::get_members(pool, community_id, channel_id).await, + DbBackend::Postgres => { + channel::get_members(self.pg_pool()?, community_id, channel_id).await + } + } } /// Returns active members for multiple channels in a single query. @@ -2339,7 +2423,14 @@ impl Db { community_id: CommunityId, pubkey: &[u8], ) -> Result> { - channel::get_accessible_channel_ids(self.pg_pool()?, community_id, pubkey).await + match &self.backend { + DbBackend::SQLite(pool) => { + sqlite::get_accessible_channel_ids(pool, community_id, pubkey).await + } + DbBackend::Postgres => { + channel::get_accessible_channel_ids(self.pg_pool()?, community_id, pubkey).await + } + } } /// Lists channels, optionally filtered by visibility. @@ -2466,7 +2557,14 @@ impl Db { channel_id: Uuid, pubkey: &[u8], ) -> Result> { - channel::get_member_role(self.pg_pool()?, community_id, channel_id, pubkey).await + match &self.backend { + DbBackend::SQLite(pool) => { + sqlite::get_member_role(pool, community_id, channel_id, pubkey).await + } + DbBackend::Postgres => { + channel::get_member_role(self.pg_pool()?, community_id, channel_id, pubkey).await + } + } } /// Archive ephemeral channels whose TTL deadline has passed. @@ -2537,7 +2635,10 @@ impl Db { /// already existed. Callers use the `true` return to increment /// `buzz_users_created_total`. pub async fn ensure_user(&self, community_id: CommunityId, pubkey: &[u8]) -> Result { - user::ensure_user(self.pg_pool()?, community_id, pubkey).await + match &self.backend { + DbBackend::SQLite(pool) => sqlite::ensure_user(pool, community_id, pubkey).await, + DbBackend::Postgres => user::ensure_user(self.pg_pool()?, community_id, pubkey).await, + } } /// Get a single user record by pubkey. @@ -2546,7 +2647,10 @@ impl Db { community_id: CommunityId, pubkey: &[u8], ) -> Result> { - user::get_user(self.pg_pool()?, community_id, pubkey).await + match &self.backend { + DbBackend::SQLite(pool) => sqlite::get_user(pool, community_id, pubkey).await, + DbBackend::Postgres => user::get_user(self.pg_pool()?, community_id, pubkey).await, + } } /// Update a user's profile fields. @@ -2599,7 +2703,15 @@ impl Db { agent_pubkey: &[u8], owner_pubkey: &[u8], ) -> Result { - user::set_agent_owner(self.pg_pool()?, community_id, agent_pubkey, owner_pubkey).await + match &self.backend { + DbBackend::SQLite(pool) => { + sqlite::set_agent_owner(pool, community_id, agent_pubkey, owner_pubkey).await + } + DbBackend::Postgres => { + user::set_agent_owner(self.pg_pool()?, community_id, agent_pubkey, owner_pubkey) + .await + } + } } /// Get the channel_add_policy and agent_owner_pubkey for a user. @@ -2608,7 +2720,14 @@ impl Db { community_id: CommunityId, pubkey: &[u8], ) -> Result>)>> { - user::get_agent_channel_policy(self.pg_pool()?, community_id, pubkey).await + match &self.backend { + DbBackend::SQLite(pool) => { + sqlite::get_agent_channel_policy(pool, community_id, pubkey).await + } + DbBackend::Postgres => { + user::get_agent_channel_policy(self.pg_pool()?, community_id, pubkey).await + } + } } /// Check whether `actor_pubkey` is the agent owner of `target_pubkey`. @@ -2618,7 +2737,15 @@ impl Db { target_pubkey: &[u8], actor_pubkey: &[u8], ) -> Result { - user::is_agent_owner(self.pg_pool()?, community_id, target_pubkey, actor_pubkey).await + match &self.backend { + DbBackend::SQLite(pool) => { + sqlite::is_agent_owner(pool, community_id, target_pubkey, actor_pubkey).await + } + DbBackend::Postgres => { + user::is_agent_owner(self.pg_pool()?, community_id, target_pubkey, actor_pubkey) + .await + } + } } /// Set the channel_add_policy for a user. @@ -4083,24 +4210,33 @@ impl Db { /// [`Db::query_events_routed_bounded`]. Not precedent for routing other /// permission reads. pub async fn is_relay_member(&self, community: CommunityId, pubkey: &str) -> Result { - let path = "relay_membership"; - match self.route_read(path, RoutePredicate::Bounded).await { - RouteDecision::Replica(mut tx, _entry, reason) => { - match relay_members::is_relay_member_on(&mut tx, community, pubkey).await { - Ok(is_member) => { - Self::record_route(path, "replica", reason); - Ok(is_member) + match &self.backend { + DbBackend::SQLite(pool) => sqlite::is_relay_member(pool, community, pubkey).await, + DbBackend::Postgres => { + let path = "relay_membership"; + match self.route_read(path, RoutePredicate::Bounded).await { + RouteDecision::Replica(mut tx, _entry, reason) => { + match relay_members::is_relay_member_on(&mut tx, community, pubkey).await { + Ok(is_member) => { + Self::record_route(path, "replica", reason); + Ok(is_member) + } + Err(e) => { + tracing::warn!( + path, + "replica read failed; re-running on writer: {e}" + ); + Self::record_route(path, "writer", "replica_error"); + relay_members::is_relay_member(self.pg_pool()?, community, pubkey) + .await + } + } } - Err(e) => { - tracing::warn!(path, "replica read failed; re-running on writer: {e}"); - Self::record_route(path, "writer", "replica_error"); + RouteDecision::Writer => { relay_members::is_relay_member(self.pg_pool()?, community, pubkey).await } } } - RouteDecision::Writer => { - relay_members::is_relay_member(self.pg_pool()?, community, pubkey).await - } } } @@ -4110,7 +4246,12 @@ impl Db { community: CommunityId, pubkey: &str, ) -> Result> { - relay_members::get_relay_member(self.pg_pool()?, community, pubkey).await + match &self.backend { + DbBackend::SQLite(pool) => sqlite::get_relay_member(pool, community, pubkey).await, + DbBackend::Postgres => { + relay_members::get_relay_member(self.pg_pool()?, community, pubkey).await + } + } } /// Returns all relay members of `community` ordered by `created_at` ascending. @@ -4118,7 +4259,12 @@ impl Db { &self, community: CommunityId, ) -> Result> { - relay_members::list_relay_members(self.pg_pool()?, community).await + match &self.backend { + DbBackend::SQLite(pool) => sqlite::list_relay_members(pool, community).await, + DbBackend::Postgres => { + relay_members::list_relay_members(self.pg_pool()?, community).await + } + } } /// Adds a new relay member to `community`. @@ -4132,7 +4278,15 @@ impl Db { role: &str, added_by: Option<&str>, ) -> Result { - relay_members::add_relay_member(self.pg_pool()?, community, pubkey, role, added_by).await + match &self.backend { + DbBackend::SQLite(pool) => { + sqlite::add_relay_member(pool, community, pubkey, role, added_by).await + } + DbBackend::Postgres => { + relay_members::add_relay_member(self.pg_pool()?, community, pubkey, role, added_by) + .await + } + } } /// Claims relay membership via an invite and atomically persists the @@ -4210,7 +4364,12 @@ impl Db { /// Ensures the owner pubkey exists with role `"owner"` in `community`. Called at startup. pub async fn bootstrap_owner(&self, community: CommunityId, owner_pubkey: &str) -> Result<()> { - relay_members::bootstrap_owner(self.pg_pool()?, community, owner_pubkey).await + match &self.backend { + DbBackend::SQLite(pool) => sqlite::bootstrap_owner(pool, community, owner_pubkey).await, + DbBackend::Postgres => { + relay_members::bootstrap_owner(self.pg_pool()?, community, owner_pubkey).await + } + } } /// Returns `true` if any member of `community` holds the `admin` or @@ -4432,7 +4591,12 @@ impl Db { community: CommunityId, pubkey: &[u8], ) -> Result { - moderation::restriction_state(self.pg_pool()?, community, pubkey).await + match &self.backend { + DbBackend::SQLite(_) => Ok(moderation::RestrictionState::default()), + DbBackend::Postgres => { + moderation::restriction_state(self.pg_pool()?, community, pubkey).await + } + } } /// Fetch the full ban/timeout row for a member pubkey. diff --git a/crates/buzz-db/src/sqlite.rs b/crates/buzz-db/src/sqlite.rs new file mode 100644 index 0000000000..da2aa68e9b --- /dev/null +++ b/crates/buzz-db/src/sqlite.rs @@ -0,0 +1,835 @@ +//! SQLite storage for the single-node relay profile. +//! +//! This module deliberately contains a separate, minimal schema rather than +//! attempting to translate the production PostgreSQL migrations. + +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use sqlx::{Row, SqlitePool}; +use std::str::FromStr; +use uuid::Uuid; + +use buzz_core::CommunityId; + +use crate::{CommunityRecord, EnsuredCommunityRecord, Result}; + +const SCHEMA: &str = r#" +CREATE TABLE IF NOT EXISTS communities ( + id TEXT PRIMARY KEY NOT NULL, + host TEXT NOT NULL COLLATE NOCASE UNIQUE, + icon TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + archived_at INTEGER +); + +CREATE TABLE IF NOT EXISTS relay_members ( + community_id TEXT NOT NULL, + pubkey TEXT NOT NULL COLLATE NOCASE, + role TEXT NOT NULL, + added_by TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (community_id, pubkey), + FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS users ( + community_id TEXT NOT NULL, + pubkey BLOB NOT NULL, + display_name TEXT, + avatar_url TEXT, + about TEXT, + nip05_handle TEXT, + channel_add_policy TEXT NOT NULL DEFAULT 'anyone', + is_agent INTEGER NOT NULL DEFAULT 0, + agent_owner_pubkey BLOB, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (community_id, pubkey), + FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS channels ( + id TEXT PRIMARY KEY NOT NULL, + community_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + canvas TEXT, + channel_type TEXT NOT NULL, + visibility TEXT NOT NULL, + created_by BLOB NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + archived_at INTEGER, + deleted_at INTEGER, + nip29_group_id TEXT, + topic_required INTEGER NOT NULL DEFAULT 0, + max_members INTEGER, + topic TEXT, + topic_set_by BLOB, + topic_set_at INTEGER, + purpose TEXT, + purpose_set_by BLOB, + purpose_set_at INTEGER, + ttl_seconds INTEGER, + ttl_deadline INTEGER, + FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS channel_members ( + channel_id TEXT NOT NULL, + pubkey BLOB NOT NULL, + role TEXT NOT NULL, + joined_at INTEGER NOT NULL DEFAULT (unixepoch()), + invited_by BLOB, + removed_at INTEGER, + PRIMARY KEY (channel_id, pubkey), + FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS moderation_restrictions ( + community_id TEXT NOT NULL, + pubkey TEXT NOT NULL COLLATE NOCASE, + restriction_type TEXT NOT NULL, + expires_at INTEGER, + PRIMARY KEY (community_id, pubkey, restriction_type), + FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS events ( + community_id TEXT NOT NULL, + id BLOB NOT NULL, + pubkey BLOB NOT NULL, + created_at INTEGER NOT NULL, + kind INTEGER NOT NULL, + tags_json TEXT NOT NULL, + content TEXT NOT NULL, + sig BLOB NOT NULL, + channel_id TEXT, + received_at INTEGER NOT NULL, + event_json TEXT NOT NULL, + PRIMARY KEY (community_id, id), + FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_events_community_created + ON events (community_id, created_at DESC, id); +CREATE INDEX IF NOT EXISTS idx_events_community_kind + ON events (community_id, kind, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_events_channel_created + ON events (community_id, channel_id, created_at DESC); +"#; + +pub(crate) async fn connect(path_or_url: &str) -> Result { + let database_url = if path_or_url.starts_with("sqlite:") { + path_or_url.to_owned() + } else { + format!("sqlite://{path_or_url}") + }; + let options = SqliteConnectOptions::from_str(&database_url)? + .create_if_missing(true) + .foreign_keys(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await?; + migrate(&pool).await?; + Ok(pool) +} + +pub(crate) async fn migrate(pool: &SqlitePool) -> Result<()> { + for statement in SCHEMA.split(';').map(str::trim).filter(|s| !s.is_empty()) { + sqlx::query(statement).execute(pool).await?; + } + Ok(()) +} + +pub(crate) async fn lookup_community_by_host( + pool: &SqlitePool, + normalized_host: &str, +) -> Result> { + let row = sqlx::query( + "SELECT id, host FROM communities WHERE host = ?1 COLLATE NOCASE AND archived_at IS NULL", + ) + .bind(normalized_host) + .fetch_optional(pool) + .await?; + row.map(community_record).transpose() +} + +pub(crate) async fn lookup_community_host( + pool: &SqlitePool, + community_id: CommunityId, +) -> Result> { + Ok( + sqlx::query_scalar("SELECT host FROM communities WHERE id = ?1 AND archived_at IS NULL") + .bind(community_id.as_uuid().to_string()) + .fetch_optional(pool) + .await?, + ) +} + +pub(crate) async fn is_community_active( + pool: &SqlitePool, + community_id: CommunityId, +) -> Result { + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM communities WHERE id = ?1 AND archived_at IS NULL", + ) + .bind(community_id.as_uuid().to_string()) + .fetch_one(pool) + .await?; + Ok(count != 0) +} + +pub(crate) async fn ensure_configured_community( + pool: &SqlitePool, + normalized_host: &str, +) -> Result { + let id = Uuid::new_v4(); + let inserted = + sqlx::query("INSERT INTO communities (id, host) VALUES (?1, ?2) ON CONFLICT DO NOTHING") + .bind(id.to_string()) + .bind(normalized_host) + .execute(pool) + .await? + .rows_affected() + == 1; + + let row = sqlx::query("SELECT id, host FROM communities WHERE host = ?1 COLLATE NOCASE") + .bind(normalized_host) + .fetch_one(pool) + .await?; + let record = community_record(row)?; + Ok(EnsuredCommunityRecord { + id: record.id, + host: record.host, + created: inserted, + }) +} + +pub(crate) async fn insert_event( + pool: &SqlitePool, + community: CommunityId, + event: &nostr::Event, + channel_id: Option, +) -> Result<(buzz_core::StoredEvent, bool)> { + let kind = u32::from(event.kind.as_u16()); + if kind == buzz_core::kind::KIND_AUTH { + return Err(crate::DbError::AuthEventRejected); + } + if buzz_core::kind::is_ephemeral(kind) { + return Err(crate::DbError::EphemeralEventRejected(event.kind.as_u16())); + } + let received_at = chrono::Utc::now(); + let inserted = sqlx::query("INSERT INTO events (community_id, id, pubkey, created_at, kind, tags_json, content, sig, channel_id, received_at, event_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) ON CONFLICT DO NOTHING") + .bind(community.as_uuid().to_string()).bind(event.id.as_bytes().as_slice()).bind(event.pubkey.to_bytes().as_slice()) + .bind(event.created_at.as_secs() as i64).bind(event.kind.as_u16() as i32).bind(serde_json::to_string(&event.tags)?) + .bind(&event.content).bind(event.sig.serialize().as_slice()).bind(channel_id.map(|id| id.to_string())) + .bind(received_at.timestamp()).bind(serde_json::to_string(event)?).execute(pool).await?.rows_affected() == 1; + Ok(( + buzz_core::StoredEvent::with_received_at(event.clone(), received_at, channel_id, true), + inserted, + )) +} + +pub(crate) async fn get_event_by_id( + pool: &SqlitePool, + community: CommunityId, + id: &[u8], +) -> Result> { + let row = sqlx::query("SELECT event_json, received_at, channel_id FROM events WHERE community_id = ?1 AND id = ?2") + .bind(community.as_uuid().to_string()).bind(id).fetch_optional(pool).await?; + row.map(stored_event).transpose() +} + +pub(crate) async fn query_events( + pool: &SqlitePool, + q: &crate::EventQuery, +) -> Result> { + if q.before_id.is_some() && q.until.is_none() { + return Err(crate::DbError::InvalidData( + "before_id requires until to be set".into(), + )); + } + if q.global_only && q.channel_id.is_some() { + return Err(crate::DbError::InvalidData( + "global_only and channel_id are mutually exclusive".into(), + )); + } + if q.kinds.as_ref().is_some_and(Vec::is_empty) + || q.authors.as_ref().is_some_and(Vec::is_empty) + || q.ids.as_ref().is_some_and(Vec::is_empty) + || q.e_tags.as_ref().is_some_and(Vec::is_empty) + { + return Ok(vec![]); + } + let rows = sqlx::query("SELECT event_json, received_at, channel_id FROM events WHERE community_id = ?1 ORDER BY created_at DESC, id ASC") + .bind(q.community_id.as_uuid().to_string()).fetch_all(pool).await?; + let mut events = Vec::new(); + for row in rows { + let stored = stored_event(row)?; + let event = &stored.event; + let created = event.created_at.as_secs() as i64; + let id = event.id.as_bytes().as_slice(); + let tags: Vec> = event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect(); + let has_tag = |name: &str, value: &str| { + tags.iter().any(|tag| { + tag.first().is_some_and(|v| v == name) && tag.get(1).is_some_and(|v| v == value) + }) + }; + if q.channel_id.is_some_and(|ch| stored.channel_id != Some(ch)) + || (q.global_only && stored.channel_id.is_some()) + { + continue; + } + if q.channel_ids + .as_ref() + .is_some_and(|ids| stored.channel_id.is_some_and(|id| !ids.contains(&id))) + { + continue; + } + if q.kinds + .as_ref() + .is_some_and(|ks| !ks.contains(&(event.kind.as_u16() as i32))) + { + continue; + } + if q.pubkey + .as_ref() + .is_some_and(|pk| pk.as_slice() != event.pubkey.to_bytes().as_slice()) + { + continue; + } + if q.authors.as_ref().is_some_and(|authors| { + !authors + .iter() + .any(|pk| pk.as_slice() == event.pubkey.to_bytes().as_slice()) + }) { + continue; + } + if q.ids + .as_ref() + .is_some_and(|ids| !ids.iter().any(|candidate| candidate.as_slice() == id)) + { + continue; + } + if q.since.is_some_and(|since| created < since.timestamp()) + || q.until.is_some_and(|until| created > until.timestamp()) + { + continue; + } + if q.before_id.as_ref().is_some_and(|before| { + q.until.is_some_and(|until| created == until.timestamp()) && id <= before.as_slice() + }) { + continue; + } + if q.p_tag_hex + .as_ref() + .is_some_and(|p| !has_tag("p", &p.to_ascii_lowercase())) + { + continue; + } + if q.e_tags + .as_ref() + .is_some_and(|values| !values.iter().any(|value| has_tag("e", value))) + { + continue; + } + let d_tag = tags + .iter() + .find(|tag| tag.first().is_some_and(|v| v == "d")) + .and_then(|tag| tag.get(1)); + if q.d_tag.as_ref().is_some_and(|d| d_tag != Some(d)) { + continue; + } + if q.d_tags + .as_ref() + .is_some_and(|ds| !d_tag.is_some_and(|d| ds.contains(d))) + { + continue; + } + if q.shared_gated_reader.as_ref().is_some_and(|reader| { + buzz_core::kind::SHARED_GATED_KINDS.contains(&(event.kind.as_u16() as u32)) + && reader.as_slice() != event.pubkey.to_bytes().as_slice() + && !has_tag("shared", "true") + }) { + continue; + } + events.push(stored); + } + let offset = q.offset.unwrap_or(0).max(0) as usize; + let limit = q + .limit + .unwrap_or(100) + .min(q.max_limit.unwrap_or(crate::DEFAULT_MAX_PAGE_LIMIT)) + .max(0) as usize; + Ok(events.into_iter().skip(offset).take(limit).collect()) +} + +fn stored_event(row: sqlx::sqlite::SqliteRow) -> Result { + let json: String = row.try_get("event_json")?; + let event: nostr::Event = serde_json::from_str(&json)?; + let received: i64 = row.try_get("received_at")?; + let channel: Option = row.try_get("channel_id")?; + let channel_id = channel + .map(|id| { + Uuid::parse_str(&id) + .map_err(|e| crate::DbError::InvalidData(format!("invalid SQLite channel id: {e}"))) + }) + .transpose()?; + Ok(buzz_core::StoredEvent::with_received_at( + event, + timestamp(received)?, + channel_id, + true, + )) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn create_channel_with_id( + pool: &SqlitePool, + community: CommunityId, + channel_id: Uuid, + name: &str, + channel_type: crate::channel::ChannelType, + visibility: crate::channel::ChannelVisibility, + description: Option<&str>, + created_by: &[u8], + ttl_seconds: Option, +) -> Result<(crate::channel::ChannelRecord, bool)> { + if created_by.len() != 32 { + return Err(crate::DbError::InvalidData(format!( + "pubkey must be 32 bytes, got {}", + created_by.len() + ))); + } + if channel_id.is_nil() { + return Err(crate::DbError::InvalidData( + "channel_id must not be nil (reserved for global fan-out)".into(), + )); + } + let name = buzz_core::channel::canonical_channel_name(name); + if name.trim().is_empty() { + return Err(crate::DbError::InvalidData( + "channel name is required".into(), + )); + } + let mut tx = pool.begin().await?; + let inserted = sqlx::query( + "INSERT INTO channels (id, community_id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, CASE WHEN ?8 IS NULL THEN NULL ELSE unixepoch() + ?8 END) ON CONFLICT DO NOTHING", + ) + .bind(channel_id.to_string()).bind(community.as_uuid().to_string()).bind(&name) + .bind(channel_type.as_str()).bind(visibility.as_str()).bind(description).bind(created_by) + .bind(ttl_seconds).execute(&mut *tx).await?.rows_affected() == 1; + if inserted { + sqlx::query("INSERT INTO channel_members (channel_id, pubkey, role, invited_by) VALUES (?1, ?2, 'owner', ?2)") + .bind(channel_id.to_string()).bind(created_by).execute(&mut *tx).await?; + } + tx.commit().await?; + Ok((get_channel(pool, community, channel_id).await?, inserted)) +} + +pub(crate) async fn get_channel( + pool: &SqlitePool, + community: CommunityId, + channel_id: Uuid, +) -> Result { + let row = sqlx::query( + "SELECT * FROM channels WHERE community_id = ?1 AND id = ?2 AND deleted_at IS NULL", + ) + .bind(community.as_uuid().to_string()) + .bind(channel_id.to_string()) + .fetch_optional(pool) + .await? + .ok_or(crate::DbError::ChannelNotFound(channel_id))?; + channel_record(row) +} + +pub(crate) async fn add_member( + pool: &SqlitePool, + community: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + role: crate::channel::MemberRole, + invited_by: Option<&[u8]>, +) -> Result { + get_channel(pool, community, channel_id).await?; + sqlx::query("INSERT INTO channel_members (channel_id, pubkey, role, invited_by) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(channel_id, pubkey) DO UPDATE SET role = excluded.role, invited_by = excluded.invited_by, removed_at = NULL") + .bind(channel_id.to_string()).bind(pubkey).bind(role.as_str()).bind(invited_by).execute(pool).await?; + member_record(sqlx::query("SELECT channel_id, pubkey, role, joined_at, invited_by, removed_at FROM channel_members WHERE channel_id = ?1 AND pubkey = ?2") + .bind(channel_id.to_string()).bind(pubkey).fetch_one(pool).await?) +} + +pub(crate) async fn is_member( + pool: &SqlitePool, + community: CommunityId, + channel_id: Uuid, + pubkey: &[u8], +) -> Result { + Ok(sqlx::query_scalar::<_, i64>("SELECT count(*) FROM channel_members cm JOIN channels c ON c.id = cm.channel_id WHERE c.community_id = ?1 AND c.id = ?2 AND cm.pubkey = ?3 AND cm.removed_at IS NULL AND c.deleted_at IS NULL") + .bind(community.as_uuid().to_string()).bind(channel_id.to_string()).bind(pubkey).fetch_one(pool).await? != 0) +} + +pub(crate) async fn get_members( + pool: &SqlitePool, + community: CommunityId, + channel_id: Uuid, +) -> Result> { + get_channel(pool, community, channel_id).await?; + sqlx::query("SELECT channel_id, pubkey, role, joined_at, invited_by, removed_at FROM channel_members WHERE channel_id = ?1 AND removed_at IS NULL ORDER BY joined_at, pubkey") + .bind(channel_id.to_string()).fetch_all(pool).await?.into_iter().map(member_record).collect() +} + +pub(crate) async fn get_accessible_channel_ids( + pool: &SqlitePool, + community: CommunityId, + pubkey: &[u8], +) -> Result> { + let rows = sqlx::query_scalar::<_, String>("SELECT c.id FROM channels c JOIN channel_members cm ON cm.channel_id = c.id WHERE c.community_id = ?1 AND cm.pubkey = ?2 AND cm.removed_at IS NULL AND c.deleted_at IS NULL ORDER BY c.created_at, c.id") + .bind(community.as_uuid().to_string()).bind(pubkey).fetch_all(pool).await?; + rows.into_iter() + .map(|id| { + Uuid::parse_str(&id) + .map_err(|e| crate::DbError::InvalidData(format!("invalid SQLite channel id: {e}"))) + }) + .collect() +} + +pub(crate) async fn get_member_role( + pool: &SqlitePool, + community: CommunityId, + channel_id: Uuid, + pubkey: &[u8], +) -> Result> { + Ok(sqlx::query_scalar("SELECT cm.role FROM channel_members cm JOIN channels c ON c.id = cm.channel_id WHERE c.community_id = ?1 AND c.id = ?2 AND cm.pubkey = ?3 AND cm.removed_at IS NULL AND c.deleted_at IS NULL") + .bind(community.as_uuid().to_string()).bind(channel_id.to_string()).bind(pubkey).fetch_optional(pool).await?) +} + +fn timestamp(value: i64) -> Result> { + chrono::DateTime::from_timestamp(value, 0).ok_or(crate::DbError::InvalidTimestamp(value)) +} +fn optional_timestamp(value: Option) -> Result>> { + value.map(timestamp).transpose() +} +fn channel_record(row: sqlx::sqlite::SqliteRow) -> Result { + let id: String = row.try_get("id")?; + Ok(crate::channel::ChannelRecord { + id: Uuid::parse_str(&id) + .map_err(|e| crate::DbError::InvalidData(format!("invalid SQLite channel id: {e}")))?, + name: row.try_get("name")?, + channel_type: row.try_get("channel_type")?, + visibility: row.try_get("visibility")?, + description: row.try_get("description")?, + canvas: row.try_get("canvas")?, + created_by: row.try_get("created_by")?, + created_at: timestamp(row.try_get("created_at")?)?, + updated_at: timestamp(row.try_get("updated_at")?)?, + archived_at: optional_timestamp(row.try_get("archived_at")?)?, + deleted_at: optional_timestamp(row.try_get("deleted_at")?)?, + nip29_group_id: row.try_get("nip29_group_id")?, + topic_required: row.try_get::("topic_required")? != 0, + max_members: row.try_get("max_members")?, + topic: row.try_get("topic")?, + topic_set_by: row.try_get("topic_set_by")?, + topic_set_at: optional_timestamp(row.try_get("topic_set_at")?)?, + purpose: row.try_get("purpose")?, + purpose_set_by: row.try_get("purpose_set_by")?, + purpose_set_at: optional_timestamp(row.try_get("purpose_set_at")?)?, + ttl_seconds: row.try_get("ttl_seconds")?, + ttl_deadline: optional_timestamp(row.try_get("ttl_deadline")?)?, + }) +} +fn member_record(row: sqlx::sqlite::SqliteRow) -> Result { + let id: String = row.try_get("channel_id")?; + Ok(crate::channel::MemberRecord { + channel_id: Uuid::parse_str(&id) + .map_err(|e| crate::DbError::InvalidData(format!("invalid SQLite channel id: {e}")))?, + pubkey: row.try_get("pubkey")?, + role: row.try_get("role")?, + joined_at: timestamp(row.try_get("joined_at")?)?, + invited_by: row.try_get("invited_by")?, + removed_at: optional_timestamp(row.try_get("removed_at")?)?, + }) +} + +pub(crate) async fn ensure_user( + pool: &SqlitePool, + community: CommunityId, + pubkey: &[u8], +) -> Result { + Ok(sqlx::query( + "INSERT INTO users (community_id, pubkey) VALUES (?1, ?2) ON CONFLICT DO NOTHING", + ) + .bind(community.as_uuid().to_string()) + .bind(pubkey) + .execute(pool) + .await? + .rows_affected() + == 1) +} + +pub(crate) async fn get_user( + pool: &SqlitePool, + community: CommunityId, + pubkey: &[u8], +) -> Result> { + let row = sqlx::query( + "SELECT pubkey, display_name, avatar_url, about, nip05_handle FROM users WHERE community_id = ?1 AND pubkey = ?2", + ) + .bind(community.as_uuid().to_string()) + .bind(pubkey) + .fetch_optional(pool) + .await?; + row.map(|row| { + Ok(crate::user::UserProfile { + pubkey: row.try_get("pubkey")?, + display_name: row.try_get("display_name")?, + avatar_url: row.try_get("avatar_url")?, + about: row.try_get("about")?, + nip05_handle: row.try_get("nip05_handle")?, + }) + }) + .transpose() +} + +pub(crate) async fn set_agent_owner( + pool: &SqlitePool, + community: CommunityId, + agent_pubkey: &[u8], + owner_pubkey: &[u8], +) -> Result { + Ok(sqlx::query( + "UPDATE users SET agent_owner_pubkey = ?3, is_agent = 1 WHERE community_id = ?1 AND pubkey = ?2 AND agent_owner_pubkey IS NULL", + ) + .bind(community.as_uuid().to_string()) + .bind(agent_pubkey) + .bind(owner_pubkey) + .execute(pool) + .await? + .rows_affected() == 1) +} + +pub(crate) async fn get_agent_channel_policy( + pool: &SqlitePool, + community: CommunityId, + pubkey: &[u8], +) -> Result>)>> { + Ok(sqlx::query_as( + "SELECT channel_add_policy, agent_owner_pubkey FROM users WHERE community_id = ?1 AND pubkey = ?2", + ) + .bind(community.as_uuid().to_string()) + .bind(pubkey) + .fetch_optional(pool) + .await?) +} + +pub(crate) async fn is_agent_owner( + pool: &SqlitePool, + community: CommunityId, + target_pubkey: &[u8], + actor_pubkey: &[u8], +) -> Result { + Ok(sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM users WHERE community_id = ?1 AND pubkey = ?2 AND agent_owner_pubkey = ?3", + ) + .bind(community.as_uuid().to_string()) + .bind(target_pubkey) + .bind(actor_pubkey) + .fetch_one(pool) + .await? != 0) +} + +pub(crate) async fn is_relay_member( + pool: &SqlitePool, + community: CommunityId, + pubkey: &str, +) -> Result { + Ok(sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM relay_members WHERE community_id = ?1 AND pubkey = ?2 COLLATE NOCASE", + ) + .bind(community.as_uuid().to_string()) + .bind(pubkey) + .fetch_one(pool) + .await? + != 0) +} + +pub(crate) async fn get_relay_member( + pool: &SqlitePool, + community: CommunityId, + pubkey: &str, +) -> Result> { + let row = sqlx::query( + "SELECT pubkey, role, added_by, created_at, updated_at FROM relay_members WHERE community_id = ?1 AND pubkey = ?2 COLLATE NOCASE", + ) + .bind(community.as_uuid().to_string()) + .bind(pubkey) + .fetch_optional(pool) + .await?; + row.map(relay_member).transpose() +} + +pub(crate) async fn list_relay_members( + pool: &SqlitePool, + community: CommunityId, +) -> Result> { + sqlx::query( + "SELECT pubkey, role, added_by, created_at, updated_at FROM relay_members WHERE community_id = ?1 ORDER BY created_at ASC, pubkey ASC", + ) + .bind(community.as_uuid().to_string()) + .fetch_all(pool) + .await? + .into_iter() + .map(relay_member) + .collect() +} + +pub(crate) async fn add_relay_member( + pool: &SqlitePool, + community: CommunityId, + pubkey: &str, + role: &str, + added_by: Option<&str>, +) -> Result { + Ok(sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role, added_by) VALUES (?1, lower(?2), ?3, ?4) ON CONFLICT DO NOTHING", + ) + .bind(community.as_uuid().to_string()) + .bind(pubkey) + .bind(role) + .bind(added_by) + .execute(pool) + .await? + .rows_affected() == 1) +} + +pub(crate) async fn bootstrap_owner( + pool: &SqlitePool, + community: CommunityId, + owner_pubkey: &str, +) -> Result<()> { + let mut tx = pool.begin().await?; + sqlx::query( + "UPDATE relay_members SET role = 'admin', updated_at = unixepoch() WHERE community_id = ?1 AND role = 'owner' AND pubkey <> ?2 COLLATE NOCASE", + ) + .bind(community.as_uuid().to_string()) + .bind(owner_pubkey) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role) VALUES (?1, lower(?2), 'owner') ON CONFLICT(community_id, pubkey) DO UPDATE SET role = 'owner', updated_at = unixepoch()", + ) + .bind(community.as_uuid().to_string()) + .bind(owner_pubkey) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) +} + +fn relay_member(row: sqlx::sqlite::SqliteRow) -> Result { + let created_at: i64 = row.try_get("created_at")?; + let updated_at: i64 = row.try_get("updated_at")?; + Ok(crate::relay_members::RelayMember { + pubkey: row.try_get("pubkey")?, + role: row.try_get("role")?, + added_by: row.try_get("added_by")?, + created_at: chrono::DateTime::from_timestamp(created_at, 0) + .ok_or(crate::DbError::InvalidTimestamp(created_at))?, + updated_at: chrono::DateTime::from_timestamp(updated_at, 0) + .ok_or(crate::DbError::InvalidTimestamp(updated_at))?, + }) +} + +fn community_record(row: sqlx::sqlite::SqliteRow) -> Result { + let id: String = row.try_get("id")?; + let id = Uuid::parse_str(&id) + .map_err(|e| crate::DbError::InvalidData(format!("invalid SQLite community id: {e}")))?; + Ok(CommunityRecord { + id: CommunityId::from_uuid(id), + host: row.try_get("host")?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind}; + + #[tokio::test] + async fn core_slice_survives_temporary_file_reopen() { + let path = std::env::temp_dir().join(format!("buzz-db-{}.sqlite", Uuid::new_v4())); + let path_string = path.to_string_lossy().into_owned(); + let owner = Keys::generate(); + let owner_bytes = owner.public_key().to_bytes(); + let owner_hex = owner.public_key().to_hex(); + let channel_id = Uuid::new_v4(); + let event = EventBuilder::new(Kind::Custom(9), "durable message") + .sign_with_keys(&owner) + .unwrap(); + + let pool = connect(&path_string).await.unwrap(); + let ensured = ensure_configured_community(&pool, "Local.Buzz") + .await + .unwrap(); + bootstrap_owner(&pool, ensured.id, &owner_hex) + .await + .unwrap(); + assert!(ensure_user(&pool, ensured.id, &owner_bytes).await.unwrap()); + let (channel, created) = create_channel_with_id( + &pool, + ensured.id, + channel_id, + "general", + crate::channel::ChannelType::Stream, + crate::channel::ChannelVisibility::Private, + None, + &owner_bytes, + None, + ) + .await + .unwrap(); + assert!(created); + assert_eq!(channel.id, channel_id); + assert!(is_member(&pool, ensured.id, channel_id, &owner_bytes) + .await + .unwrap()); + assert!( + insert_event(&pool, ensured.id, &event, Some(channel_id)) + .await + .unwrap() + .1 + ); + pool.close().await; + + let reopened = connect(&path_string).await.unwrap(); + let found = lookup_community_by_host(&reopened, "local.buzz") + .await + .unwrap() + .unwrap(); + assert_eq!(found.id, ensured.id); + assert!(is_community_active(&reopened, ensured.id).await.unwrap()); + assert!(is_relay_member(&reopened, ensured.id, &owner_hex) + .await + .unwrap()); + assert_eq!( + get_accessible_channel_ids(&reopened, ensured.id, &owner_bytes) + .await + .unwrap(), + vec![channel_id] + ); + let stored = get_event_by_id(&reopened, ensured.id, event.id.as_bytes()) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.event.content, "durable message"); + let mut query = crate::EventQuery::for_community(ensured.id); + query.channel_id = Some(channel_id); + query.kinds = Some(vec![9]); + assert_eq!(query_events(&reopened, &query).await.unwrap().len(), 1); + reopened.close().await; + std::fs::remove_file(path).unwrap(); + } +} From 0c85626194068aa2f9c4e1b419d7c8caff00ede4 Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 12:18:07 -0400 Subject: [PATCH 03/16] feat(relay): complete Phase 1 single-node slice Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- PHASE1C_BREAKAGE_CATALOGUE.md | 75 +++ crates/buzz-db/src/lib.rs | 208 ++++++-- crates/buzz-db/src/sqlite.rs | 473 ++++++++++++++++++ crates/buzz-pubsub/src/lib.rs | 2 +- crates/buzz-pubsub/src/nip98_replay.rs | 93 ++++ crates/buzz-relay/src/api/bridge.rs | 8 +- .../src/handlers/command_executor.rs | 67 ++- crates/buzz-relay/src/handlers/event.rs | 3 +- .../buzz-relay/src/handlers/side_effects.rs | 14 + crates/buzz-relay/src/main.rs | 115 ++++- 10 files changed, 996 insertions(+), 62 deletions(-) create mode 100644 PHASE1C_BREAKAGE_CATALOGUE.md diff --git a/PHASE1C_BREAKAGE_CATALOGUE.md b/PHASE1C_BREAKAGE_CATALOGUE.md new file mode 100644 index 0000000000..06fd2f3365 --- /dev/null +++ b/PHASE1C_BREAKAGE_CATALOGUE.md @@ -0,0 +1,75 @@ +# Phase 1c single-node breakage catalogue + +Branch: `local-mode/phase-1`, based on committed SQLite core slice `88b9644` and the Phase 0 seam stack through `a680665`. This catalogue describes the production-router single-node slice, not the retired Phase 0 alternate router. + +## Result + +`BUZZ_PROFILE=single-node` enters the normal production router through `crates/buzz-relay/src/main.rs:164,1208-1305`, rejects non-loopback binding (`main.rs:1209-1214`), and constructs SQLite, in-process pub/sub and replay fencing, filesystem media, unsupported search, and permissive admission without opening PostgreSQL, Redis, or S3 connections (`main.rs:1231-1278`). The readiness log enumerates intentionally disabled background/service families (`main.rs:1295-1296`). + +The fresh WP-B matrix passed profiles, public relay-member admission (kind 9030), channel creation/membership, owner↔agent chat, thread/reply/mention, reaction add/get/remove/get, DM open and sends in both directions, feeds, explicit search denial, canonical filesystem media byte round-trip, NIP-42/history/live fan-out, and restart durability. Evidence is recorded below. + +## Worked as-is + +| Surface | File:line evidence | Result | +|---|---|---| +| Production protocol/router and NIP-42 | `crates/buzz-relay/src/main.rs:164,1284-1294`; `crates/buzz-test-client/src/bin/local_mode_smoke.rs:7-54` | Normal router and client protocol; no local-only wire protocol. | +| PostgreSQL production profile | `crates/buzz-db/src/lib.rs:172-221`; backend matches such as `lib.rs:1730-1734` | Existing PostgreSQL construction and paths remain the default backend arms. | +| Search failure shape | `crates/buzz-relay/src/api/bridge.rs:1732-1741` | Local unsupported search returns explicit HTTP 501 `unsupported_feature`, not 500 or false success. | +| Loopback safety fence | `crates/buzz-relay/src/main.rs:1209-1214` | Single-node startup fails closed on a non-loopback bind address. | + +## Worked with local backend/shim + +| Surface | File:line evidence | Fidelity / limitation | +|---|---|---| +| Startup/service aggregate | `crates/buzz-relay/src/main.rs:1208-1305` | Production router with a local backend bundle; production-only workers are not started. | +| Durable SQLite core | `crates/buzz-db/src/sqlite.rs:137-458`; dispatch at `crates/buzz-db/src/lib.rs:1710-1812,1891-1961,2187-2256,4848-4850,5176-5178` | Real normalized community/channel/member/event/reaction/DM tables and SQL filtering. Thread ancestry remains canonical NIP-10 tags; denormalized thread metadata/counters are deferred. | +| Conformance row projection | `crates/buzz-db/src/lib.rs:1670-1699`; `crates/buzz-db/src/sqlite.rs:824-844` | SQLite now resolves each returned row's channel community, removing the PG-only warning while retaining the non-interference trace seam. | +| Pub/sub + presence | `crates/buzz-pubsub/src/lib.rs:133-160,994-1057`; startup `main.rs:1252,1286-1292` | Process-local, community-scoped fan-out and expiring presence leases; no cross-process Redis semantics. | +| NIP-98 replay | `crates/buzz-pubsub/src/nip98_replay.rs:1-213`; startup `main.rs:1270,1277` | Bounded in-process replay fence; restart does not retain replay history. | +| Media | `crates/buzz-media/src/storage.rs:89-98,104-313`; startup `main.rs:1275` | Filesystem CAS path supports canonical upload/download, sidecars, ranges and pages; no S3 replication. | +| Profiles/users/channels/membership | SQLite methods `crates/buzz-db/src/sqlite.rs:715-844,903-1079`; dispatch `crates/buzz-db/src/lib.rs:2299-2467,2672-2779,4334-4467` | Startup owner bootstrap plus public relay-admin and channel membership flows work locally. | +| Feed | `crates/buzz-db/src/sqlite.rs:461-530`; routed dispatch `crates/buzz-db/src/lib.rs:3444-3470,3532-3558,3618-3642` | WP-B mentions/needs-action/activity query shapes work; advanced non-routed feed methods remain PostgreSQL-only. | +| Reactions | `crates/buzz-db/src/sqlite.rs:533-599`; routed writes/removal `crates/buzz-db/src/lib.rs:2213-2256,3302-3344` | Atomic reaction/event insertion, dedupe, and removal used by CLI matrix. Aggregate/direct helper family is not fully ported. | +| DM open | `crates/buzz-db/src/sqlite.rs:601-713`; command seam `crates/buzz-db/src/lib.rs:2845-2858`; `crates/buzz-relay/src/handlers/command_executor.rs:373-413` | DM open and its command idempotency execute transactionally in SQLite. Other command kinds are rejected before persistence; broader DM list/find/create public methods remain PostgreSQL-only. | +| Workflow dispatch gate | `crates/buzz-relay/src/handlers/event.rs:520-559` | Local profile does not invoke the PostgreSQL workflow engine, matching startup's disabled declaration. | + +## Stubbed / deliberately permissive + +| Surface | File:line evidence | Local behavior | +|---|---|---| +| Search | `crates/buzz-relay/src/main.rs:1274`; `api/bridge.rs:1732-1741` | Service is unavailable and explicitly returns 501; FTS5 is deferred. | +| Git, push, workflows, reapers, usage, replica tasks | `crates/buzz-relay/src/main.rs:1295-1296`; Git route gate `crates/buzz-relay/src/router.rs:49-52` | Not started/exposed in the single-node profile. | +| Admission rate limiting | `crates/buzz-pubsub/src/rate_limiter.rs:97-103`; startup `main.rs:1278` | Explicit permissive in-process policy, not Redis quotas. | +| Audit | `crates/buzz-relay/src/main.rs:1280-1282` | No audit service/worker in single-node startup. | +| Relay signing identity | `crates/buzz-relay/src/main.rs:1245-1251` | If no key is configured, local mode uses a public deterministic development key; safe only with the enforced loopback bind and unsuitable for shared/production use. | +| Unsupported command families | `crates/buzz-relay/src/handlers/command_executor.rs:120-128` | Commands other than the dedicated atomic DM-open path are explicitly rejected before an idempotency event can be persisted. | +| Thread summaries and NIP-43 publications | `crates/buzz-relay/src/handlers/side_effects.rs` (`emit_live_thread_summary`, `publish_nip43_membership_list`, `publish_nip43_delta`) | PostgreSQL-backed thread/list snapshots are omitted locally; globally scoped NIP-43 deltas are also suppressed to avoid leaking the loopback roster. Durable NIP-10 tags and the SQLite relay-members table remain authoritative. | + +## Blocked PostgreSQL-only public DB methods + +These calls fail with typed `DbError::UnsupportedBackend` if reached on SQLite; they are not silently emulated. The list is representative of the remaining families rather than a claim that every `Db` method is ported. + +| Family | File:line evidence | Consequence | +|---|---|---| +| Direct deletion helpers | `crates/buzz-db/src/lib.rs:1919-1937` | Standard event deletion uses the routed atomic helper, but callers of these direct methods remain blocked. | +| Channel policy | `crates/buzz-db/src/lib.rs:2785-2793` | Channel add-policy mutation is unavailable locally. | +| Direct DM helpers/list | `crates/buzz-db/src/lib.rs:2795-2823` | WP-B DM open uses the SQLite command seam; separate find/create/list APIs remain blocked. | +| Thread counters | `crates/buzz-db/src/lib.rs:3263-3277` | No denormalized decrement; tag-based ancestry only. | +| Direct/aggregate reaction helpers | `crates/buzz-db/src/lib.rs:3280-3299,3347-3416` | Ingest/removal tracer path works; direct add, active-record lookup, and aggregate reads are not routed. | +| Non-routed feed helpers | `crates/buzz-db/src/lib.rs:3418-3436,3510-3528,3598-3614` | HTTP/CLI routed feed works; callers choosing writer-only helpers remain blocked. | + +## Verification artifacts + +Fresh runtime evidence is under `.scratch/wp-b-matrix/` (ephemeral, not committed): + +```text +PASS wp_b_cli_matrix +PASS media_byte_comparison +PASS kind_9030_admission +PASS nip42,event_insert,req_history,live_fanout +PASS sqlite_restart_history +PASS no_postgres_workflow_conformance_leakage +PASS no_global_nip43_publications +``` + +The matrix covers profiles, channel creation/membership, owner↔agent chat, reply/thread/mention, reactions through removal, DM open plus both-direction sends, owner/agent feeds, explicit search 501, and canonical media upload/download byte comparison. A scan of fresh first-boot and restart logs found no `Workflow trigger failed`, `conformance row-community lookup failed`, or `PostgreSQL operation on SQLite` lines; the fresh SQLite file contains zero kind 8000/8001/13534 global NIP-43 publication rows. Package test counts and the exact verified commit are reported with the final commit because line numbers above describe the formatted final working tree. diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 036015a699..9b9a2d32ac 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -1674,25 +1674,30 @@ impl Db { if channel_ids.is_empty() { return Ok(std::collections::HashMap::new()); } - let rows = sqlx::query( - r#" + match &self.backend { + DbBackend::SQLite(pool) => sqlite::communities_of_channels(pool, channel_ids).await, + DbBackend::Postgres => { + let rows = sqlx::query( + r#" SELECT id, community_id FROM channels WHERE id = ANY($1) AND deleted_at IS NULL "#, - ) - .bind(channel_ids) - .fetch_all(self.pg_pool()?) - .await?; + ) + .bind(channel_ids) + .fetch_all(self.pg_pool()?) + .await?; - let mut out = std::collections::HashMap::with_capacity(rows.len()); - for row in rows { - let ch: Uuid = row.try_get("id")?; - let cm: Uuid = row.try_get("community_id")?; - out.insert(ch, CommunityId::from_uuid(cm)); + let mut out = std::collections::HashMap::with_capacity(rows.len()); + for row in rows { + let ch: Uuid = row.try_get("id")?; + let cm: Uuid = row.try_get("community_id")?; + out.insert(ch, CommunityId::from_uuid(cm)); + } + Ok(out) + } } - Ok(out) } /// Inserts an event. Returns `(StoredEvent, was_inserted)` — `false` on duplicate. @@ -1750,6 +1755,9 @@ impl Db { path: &'static str, q: &EventQuery, ) -> Result> { + if let DbBackend::SQLite(pool) = &self.backend { + return sqlite::query_events(pool, q).await; + } let predicate = RoutePredicate::for_query(q, self.replica_read_max_age.is_some()); match self.route_read(path, predicate).await { RouteDecision::Replica(mut tx, _entry, reason) => { @@ -1784,6 +1792,9 @@ impl Db { path: &'static str, q: &EventQuery, ) -> Result> { + if let DbBackend::SQLite(pool) = &self.backend { + return sqlite::query_events(pool, q).await; + } match self.route_read(path, RoutePredicate::Bounded).await { RouteDecision::Replica(mut tx, _entry, reason) => { match event::query_events_on(&mut tx, q).await { @@ -1895,7 +1906,13 @@ impl Db { community_id: CommunityId, id_bytes: &[u8], ) -> Result> { - event::get_event_by_id_including_deleted(self.pg_pool()?, community_id, id_bytes).await + match &self.backend { + DbBackend::SQLite(pool) => sqlite::get_event_by_id(pool, community_id, id_bytes).await, + DbBackend::Postgres => { + event::get_event_by_id_including_deleted(self.pg_pool()?, community_id, id_bytes) + .await + } + } } /// Soft-deletes an event. Returns `Ok(true)` if deleted, `Ok(false)` if already deleted. @@ -1938,14 +1955,21 @@ impl Db { parent_event_id: Option<&[u8]>, root_event_id: Option<&[u8]>, ) -> Result { - event::soft_delete_event_and_update_thread( - self.pg_pool()?, - community_id, - event_id, - parent_event_id, - root_event_id, - ) - .await + match &self.backend { + DbBackend::SQLite(pool) => { + sqlite::soft_delete_event(pool, community_id, event_id).await + } + DbBackend::Postgres => { + event::soft_delete_event_and_update_thread( + self.pg_pool()?, + community_id, + event_id, + parent_event_id, + root_event_id, + ) + .await + } + } } /// Returns the most recent `created_at` for a channel. @@ -2171,6 +2195,13 @@ impl Db { channel_id: Option, thread_meta: Option>, ) -> Result<(StoredEvent, bool)> { + if let DbBackend::SQLite(pool) = &self.backend { + // SQLite Phase 1 persists the canonical event; thread relationships + // remain durable in the event's NIP-10 tags until Phase 2 ports the + // denormalized thread metadata tables. + let _ = thread_meta; + return sqlite::insert_event(pool, community_id, event, channel_id).await; + } let result = event::insert_event_with_thread_metadata( self.pg_pool()?, community_id, @@ -2200,6 +2231,19 @@ impl Db { actor_pubkey: &[u8], emoji: &str, ) -> Result { + if let DbBackend::SQLite(pool) = &self.backend { + let _ = thread_meta; + return sqlite::insert_reaction_event( + pool, + community_id, + event, + channel_id, + target_event_id, + actor_pubkey, + emoji, + ) + .await; + } let outcome = event::insert_reaction_event_with_thread_metadata( self.pg_pool()?, community_id, @@ -2663,16 +2707,32 @@ impl Db { about: Option<&str>, nip05_handle: Option<&str>, ) -> Result<()> { - user::update_user_profile( - self.pg_pool()?, - community_id, - pubkey, - display_name, - avatar_url, - about, - nip05_handle, - ) - .await + match &self.backend { + DbBackend::SQLite(pool) => { + sqlite::update_user_profile( + pool, + community_id, + pubkey, + display_name, + avatar_url, + about, + nip05_handle, + ) + .await + } + DbBackend::Postgres => { + user::update_user_profile( + self.pg_pool()?, + community_id, + pubkey, + display_name, + avatar_url, + about, + nip05_handle, + ) + .await + } + } } /// Look up a user by NIP-05 handle. @@ -2795,7 +2855,33 @@ impl Db { pubkeys: &[&[u8]], created_by: &[u8], ) -> Result<(channel::ChannelRecord, bool)> { - dm::open_dm(self.pg_pool()?, community_id, pubkeys, created_by).await + match &self.backend { + DbBackend::SQLite(pool) => { + sqlite::open_dm(pool, community_id, pubkeys, created_by, None) + .await? + .ok_or_else(|| DbError::InvalidData("unexpected duplicate DM open".into())) + } + DbBackend::Postgres => { + dm::open_dm(self.pg_pool()?, community_id, pubkeys, created_by).await + } + } + } + + /// Atomically persist a SQLite DM-open command and its channel mutation. + /// `None` means the command event was already processed. + pub async fn open_dm_sqlite_command( + &self, + community_id: CommunityId, + pubkeys: &[&[u8]], + created_by: &[u8], + event: &nostr::Event, + ) -> Result> { + match &self.backend { + DbBackend::SQLite(pool) => { + sqlite::open_dm(pool, community_id, pubkeys, created_by, Some(event)).await + } + DbBackend::Postgres => Ok(None), + } } /// Hide a DM channel for a specific user. @@ -3177,6 +3263,11 @@ impl Db { community_id: CommunityId, event_id: &[u8], ) -> Result> { + if matches!(&self.backend, DbBackend::SQLite(_)) { + // SQLite Phase 1 derives ancestry from durable NIP-10 event tags; + // denormalized counters and metadata arrive with the Phase 2 port. + return Ok(None); + } thread::get_thread_metadata_by_event(self.pg_pool()?, community_id, event_id).await } @@ -3227,6 +3318,17 @@ impl Db { pubkey: &[u8], emoji: &str, ) -> Result { + if let DbBackend::SQLite(pool) = &self.backend { + return sqlite::remove_reaction( + pool, + community, + event_id, + event_created_at, + pubkey, + emoji, + ) + .await; + } reaction::remove_reaction( self.pg_pool()?, community, @@ -3244,6 +3346,10 @@ impl Db { community: CommunityId, reaction_event_id: &[u8], ) -> Result { + if let DbBackend::SQLite(pool) = &self.backend { + return sqlite::remove_reaction_by_source_event_id(pool, community, reaction_event_id) + .await; + } reaction::remove_reaction_by_source_event_id(self.pg_pool()?, community, reaction_event_id) .await } @@ -3355,6 +3461,17 @@ impl Db { since: Option>, limit: i64, ) -> Result> { + if let DbBackend::SQLite(pool) = &self.backend { + return sqlite::query_feed_mentions( + pool, + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ) + .await; + } match self.route_read(path, RoutePredicate::Bounded).await { RouteDecision::Replica(mut tx, _entry, reason) => { match feed::query_mentions_on( @@ -3432,6 +3549,17 @@ impl Db { since: Option>, limit: i64, ) -> Result> { + if let DbBackend::SQLite(pool) = &self.backend { + return sqlite::query_feed_needs_action( + pool, + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ) + .await; + } match self.route_read(path, RoutePredicate::Bounded).await { RouteDecision::Replica(mut tx, _entry, reason) => { match feed::query_needs_action_on( @@ -3506,6 +3634,16 @@ impl Db { since: Option>, limit: i64, ) -> Result> { + if let DbBackend::SQLite(pool) = &self.backend { + return sqlite::query_feed_activity( + pool, + community, + accessible_channel_ids, + since, + limit, + ) + .await; + } match self.route_read(path, RoutePredicate::Bounded).await { RouteDecision::Replica(mut tx, _entry, reason) => { match feed::query_activity_on( @@ -4753,6 +4891,9 @@ impl Db { event: &nostr::Event, channel_id: Option, ) -> Result<(StoredEvent, bool)> { + if let DbBackend::SQLite(pool) = &self.backend { + return sqlite::replace_event(pool, community_id, event, channel_id, None).await; + } 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; @@ -5078,6 +5219,9 @@ impl Db { d_tag: &str, channel_id: Option, ) -> Result<(StoredEvent, bool)> { + if let DbBackend::SQLite(pool) = &self.backend { + return sqlite::replace_event(pool, community_id, event, channel_id, Some(d_tag)).await; + } 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; diff --git a/crates/buzz-db/src/sqlite.rs b/crates/buzz-db/src/sqlite.rs index da2aa68e9b..922657ad11 100644 --- a/crates/buzz-db/src/sqlite.rs +++ b/crates/buzz-db/src/sqlite.rs @@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS channels ( canvas TEXT, channel_type TEXT NOT NULL, visibility TEXT NOT NULL, + participant_hash BLOB, created_by BLOB NOT NULL, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()), @@ -80,6 +81,7 @@ CREATE TABLE IF NOT EXISTS channel_members ( role TEXT NOT NULL, joined_at INTEGER NOT NULL DEFAULT (unixepoch()), invited_by BLOB, + hidden_at INTEGER, removed_at INTEGER, PRIMARY KEY (channel_id, pubkey), FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE @@ -115,6 +117,21 @@ CREATE INDEX IF NOT EXISTS idx_events_community_kind ON events (community_id, kind, created_at DESC); CREATE INDEX IF NOT EXISTS idx_events_channel_created ON events (community_id, channel_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS reactions ( + community_id TEXT NOT NULL, + event_created_at INTEGER NOT NULL, + event_id BLOB NOT NULL, + pubkey BLOB NOT NULL, + emoji TEXT NOT NULL, + reaction_event_id BLOB, + removed_at INTEGER, + PRIMARY KEY (community_id, event_created_at, event_id, pubkey, emoji), + FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_reactions_source_event + ON reactions (community_id, reaction_event_id) + WHERE reaction_event_id IS NOT NULL; "#; pub(crate) async fn connect(path_or_url: &str) -> Result { @@ -135,9 +152,78 @@ pub(crate) async fn connect(path_or_url: &str) -> Result { } pub(crate) async fn migrate(pool: &SqlitePool) -> Result<()> { + let had_application_schema = sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'channels'", + ) + .fetch_one(pool) + .await? + != 0; + for statement in SCHEMA.split(';').map(str::trim).filter(|s| !s.is_empty()) { sqlx::query(statement).execute(pool).await?; } + sqlx::query( + "CREATE TABLE IF NOT EXISTS schema_version (singleton INTEGER PRIMARY KEY CHECK (singleton = 1), version INTEGER NOT NULL)", + ) + .execute(pool) + .await?; + let initial_version = if had_application_schema { 1_i64 } else { 2_i64 }; + sqlx::query( + "INSERT INTO schema_version (singleton, version) VALUES (1, ?1) ON CONFLICT(singleton) DO NOTHING", + ) + .bind(initial_version) + .execute(pool) + .await?; + + let mut version = + sqlx::query_scalar::<_, i64>("SELECT version FROM schema_version WHERE singleton = 1") + .fetch_one(pool) + .await?; + if version < 2 { + let mut tx = pool.begin().await?; + ensure_column_on( + &mut tx, + "channels", + "participant_hash", + "ALTER TABLE channels ADD COLUMN participant_hash BLOB", + ) + .await?; + ensure_column_on( + &mut tx, + "channel_members", + "hidden_at", + "ALTER TABLE channel_members ADD COLUMN hidden_at INTEGER", + ) + .await?; + sqlx::query("UPDATE schema_version SET version = 2 WHERE singleton = 1") + .execute(&mut *tx) + .await?; + tx.commit().await?; + version = 2; + } + if version != 2 { + return Err(crate::DbError::InvalidData(format!( + "unsupported SQLite schema version {version}" + ))); + } + Ok(()) +} + +async fn ensure_column_on( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + table: &str, + column: &str, + alter_sql: &'static str, +) -> Result<()> { + let pragma = format!("PRAGMA table_info({table})"); + let exists = sqlx::query(sqlx::AssertSqlSafe(pragma)) + .fetch_all(&mut **tx) + .await? + .iter() + .any(|row| row.get::("name") == column); + if !exists { + sqlx::query(alter_sql).execute(&mut **tx).await?; + } Ok(()) } @@ -230,6 +316,79 @@ pub(crate) async fn insert_event( )) } +pub(crate) async fn replace_event( + pool: &SqlitePool, + community: CommunityId, + event: &nostr::Event, + channel_id: Option, + d_tag: Option<&str>, +) -> Result<(buzz_core::StoredEvent, bool)> { + let mut tx = pool.begin().await?; + let rows = sqlx::query("SELECT id, event_json FROM events WHERE community_id = ?1 AND kind = ?2 AND pubkey = ?3 AND channel_id IS ?4") + .bind(community.as_uuid().to_string()) + .bind(event.kind.as_u16() as i32) + .bind(event.pubkey.to_bytes().as_slice()) + .bind(channel_id.map(|id| id.to_string())) + .fetch_all(&mut *tx) + .await?; + let mut replaced_ids = Vec::new(); + for row in rows { + let existing: nostr::Event = serde_json::from_str(row.try_get("event_json")?)?; + let existing_d = crate::event::extract_d_tag(&existing).unwrap_or_default(); + if d_tag.is_some_and(|expected| existing_d != expected) { + continue; + } + if event.created_at < existing.created_at + || (event.created_at == existing.created_at && event.id <= existing.id) + { + return Ok(( + buzz_core::StoredEvent::with_received_at( + existing, + chrono::Utc::now(), + channel_id, + true, + ), + false, + )); + } + replaced_ids.push(row.try_get::, _>("id")?); + } + for id in replaced_ids { + sqlx::query("DELETE FROM events WHERE community_id = ?1 AND id = ?2") + .bind(community.as_uuid().to_string()) + .bind(id) + .execute(&mut *tx) + .await?; + } + let received_at = chrono::Utc::now(); + sqlx::query("INSERT INTO events (community_id, id, pubkey, created_at, kind, tags_json, content, sig, channel_id, received_at, event_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)") + .bind(community.as_uuid().to_string()).bind(event.id.as_bytes().as_slice()).bind(event.pubkey.to_bytes().as_slice()) + .bind(event.created_at.as_secs() as i64).bind(event.kind.as_u16() as i32).bind(serde_json::to_string(&event.tags)?) + .bind(&event.content).bind(event.sig.serialize().as_slice()).bind(channel_id.map(|id| id.to_string())) + .bind(received_at.timestamp()).bind(serde_json::to_string(event)?).execute(&mut *tx).await?; + tx.commit().await?; + Ok(( + buzz_core::StoredEvent::with_received_at(event.clone(), received_at, channel_id, true), + true, + )) +} + +pub(crate) async fn soft_delete_event( + pool: &SqlitePool, + community: CommunityId, + event_id: &[u8], +) -> Result { + Ok( + sqlx::query("DELETE FROM events WHERE community_id = ?1 AND id = ?2") + .bind(community.as_uuid().to_string()) + .bind(event_id) + .execute(pool) + .await? + .rows_affected() + != 0, + ) +} + pub(crate) async fn get_event_by_id( pool: &SqlitePool, community: CommunityId, @@ -368,6 +527,231 @@ pub(crate) async fn query_events( Ok(events.into_iter().skip(offset).take(limit).collect()) } +pub(crate) async fn query_feed_mentions( + pool: &SqlitePool, + community: CommunityId, + pubkey_bytes: &[u8], + accessible_channel_ids: &[Uuid], + since: Option>, + limit: i64, +) -> Result> { + let mut query = crate::EventQuery::for_community(community); + query.kinds = Some(vec![ + buzz_core::kind::KIND_STREAM_MESSAGE as i32, + buzz_core::kind::KIND_STREAM_MESSAGE_V2 as i32, + buzz_core::kind::KIND_TEXT_NOTE as i32, + buzz_core::kind::KIND_FORUM_POST as i32, + buzz_core::kind::KIND_FORUM_COMMENT as i32, + buzz_core::kind::KIND_GIT_PULL_REQUEST as i32, + buzz_core::kind::KIND_GIT_PR_UPDATE as i32, + buzz_core::kind::KIND_GIT_ISSUE as i32, + buzz_core::kind::KIND_GIT_STATUS_OPEN as i32, + buzz_core::kind::KIND_GIT_STATUS_MERGED as i32, + buzz_core::kind::KIND_GIT_STATUS_CLOSED as i32, + buzz_core::kind::KIND_GIT_STATUS_DRAFT as i32, + ]); + query.p_tag_hex = Some(hex::encode(pubkey_bytes)); + query.channel_ids = Some(accessible_channel_ids.to_vec()); + query.since = since; + query.limit = Some(limit.min(crate::feed::FEED_MAX_LIMIT)); + query_events(pool, &query).await +} + +pub(crate) async fn query_feed_needs_action( + pool: &SqlitePool, + community: CommunityId, + pubkey_bytes: &[u8], + accessible_channel_ids: &[Uuid], + since: Option>, + limit: i64, +) -> Result> { + let mut query = crate::EventQuery::for_community(community); + query.kinds = Some(vec![ + buzz_core::kind::KIND_WORKFLOW_APPROVAL_REQUESTED as i32, + buzz_core::kind::KIND_STREAM_REMINDER as i32, + ]); + query.p_tag_hex = Some(hex::encode(pubkey_bytes)); + query.channel_ids = Some(accessible_channel_ids.to_vec()); + query.since = since; + query.limit = Some(limit.min(crate::feed::FEED_MAX_LIMIT)); + query_events(pool, &query).await +} + +pub(crate) async fn query_feed_activity( + pool: &SqlitePool, + community: CommunityId, + accessible_channel_ids: &[Uuid], + since: Option>, + limit: i64, +) -> Result> { + let mut query = crate::EventQuery::for_community(community); + query.kinds = Some(vec![ + buzz_core::kind::KIND_STREAM_MESSAGE as i32, + buzz_core::kind::KIND_STREAM_MESSAGE_V2 as i32, + buzz_core::kind::KIND_FORUM_POST as i32, + buzz_core::kind::KIND_JOB_REQUEST as i32, + buzz_core::kind::KIND_JOB_PROGRESS as i32, + buzz_core::kind::KIND_JOB_RESULT as i32, + ]); + query.channel_ids = Some(accessible_channel_ids.to_vec()); + query.since = since; + query.limit = Some(limit.min(crate::feed::FEED_MAX_LIMIT)); + query_events(pool, &query).await +} + +pub(crate) async fn insert_reaction_event( + pool: &SqlitePool, + community: CommunityId, + reaction_event: &nostr::Event, + channel_id: Option, + target_event_id: &[u8], + actor_pubkey: &[u8], + emoji: &str, +) -> Result { + let mut tx = pool.begin().await?; + let target_created_at: Option = sqlx::query_scalar( + "SELECT created_at FROM events WHERE community_id = ?1 AND id = ?2 LIMIT 1", + ) + .bind(community.as_uuid().to_string()) + .bind(target_event_id) + .fetch_optional(&mut *tx) + .await?; + let Some(target_created_at) = target_created_at else { + return Ok(crate::event::ReactionEventInsertOutcome::TargetMissing); + }; + let changed = sqlx::query("INSERT INTO reactions (community_id, event_created_at, event_id, pubkey, emoji, reaction_event_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT (community_id, event_created_at, event_id, pubkey, emoji) DO UPDATE SET removed_at = NULL, reaction_event_id = excluded.reaction_event_id WHERE reactions.removed_at IS NOT NULL") + .bind(community.as_uuid().to_string()).bind(target_created_at).bind(target_event_id) + .bind(actor_pubkey).bind(emoji).bind(reaction_event.id.as_bytes().as_slice()) + .execute(&mut *tx).await?.rows_affected() != 0; + if !changed { + return Ok(crate::event::ReactionEventInsertOutcome::Duplicate); + } + let received_at = chrono::Utc::now(); + let inserted = sqlx::query("INSERT INTO events (community_id, id, pubkey, created_at, kind, tags_json, content, sig, channel_id, received_at, event_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) ON CONFLICT DO NOTHING") + .bind(community.as_uuid().to_string()).bind(reaction_event.id.as_bytes().as_slice()).bind(reaction_event.pubkey.to_bytes().as_slice()) + .bind(reaction_event.created_at.as_secs() as i64).bind(reaction_event.kind.as_u16() as i32).bind(serde_json::to_string(&reaction_event.tags)?) + .bind(&reaction_event.content).bind(reaction_event.sig.serialize().as_slice()).bind(channel_id.map(|id| id.to_string())) + .bind(received_at.timestamp()).bind(serde_json::to_string(reaction_event)?).execute(&mut *tx).await?.rows_affected() == 1; + tx.commit().await?; + Ok(crate::event::ReactionEventInsertOutcome::Inserted { + stored_event: Box::new(buzz_core::StoredEvent::with_received_at( + reaction_event.clone(), + received_at, + channel_id, + true, + )), + was_inserted: inserted, + }) +} + +pub(crate) async fn remove_reaction( + pool: &SqlitePool, + community: CommunityId, + event_id: &[u8], + event_created_at: chrono::DateTime, + pubkey: &[u8], + emoji: &str, +) -> Result { + Ok(sqlx::query("UPDATE reactions SET removed_at = unixepoch() WHERE community_id = ?1 AND event_created_at = ?2 AND event_id = ?3 AND pubkey = ?4 AND emoji = ?5 AND removed_at IS NULL") + .bind(community.as_uuid().to_string()).bind(event_created_at.timestamp()).bind(event_id).bind(pubkey).bind(emoji) + .execute(pool).await?.rows_affected() != 0) +} + +pub(crate) async fn remove_reaction_by_source_event_id( + pool: &SqlitePool, + community: CommunityId, + reaction_event_id: &[u8], +) -> Result { + Ok(sqlx::query("UPDATE reactions SET removed_at = unixepoch() WHERE community_id = ?1 AND reaction_event_id = ?2 AND removed_at IS NULL") + .bind(community.as_uuid().to_string()).bind(reaction_event_id) + .execute(pool).await?.rows_affected() != 0) +} + +pub(crate) async fn open_dm( + pool: &SqlitePool, + community: CommunityId, + pubkeys: &[&[u8]], + created_by: &[u8], + command_event: Option<&nostr::Event>, +) -> Result> { + let mut participants = pubkeys.to_vec(); + if !participants.contains(&created_by) { + participants.push(created_by); + } + participants.sort_unstable(); + participants.dedup(); + if !(2..=9).contains(&participants.len()) { + return Err(crate::DbError::InvalidData( + "DM requires 2-9 unique participants".into(), + )); + } + if participants.iter().any(|pubkey| pubkey.len() != 32) { + return Err(crate::DbError::InvalidData( + "DM participant pubkeys must be 32 bytes".into(), + )); + } + let hash = crate::dm::compute_participant_hash(&participants); + let mut tx = pool.begin().await?; + if let Some(event) = command_event { + let received_at = chrono::Utc::now(); + let inserted = sqlx::query("INSERT INTO events (community_id, id, pubkey, created_at, kind, tags_json, content, sig, channel_id, received_at, event_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, ?9, ?10) ON CONFLICT DO NOTHING") + .bind(community.as_uuid().to_string()).bind(event.id.as_bytes().as_slice()).bind(event.pubkey.to_bytes().as_slice()) + .bind(event.created_at.as_secs() as i64).bind(event.kind.as_u16() as i32).bind(serde_json::to_string(&event.tags)?) + .bind(&event.content).bind(event.sig.serialize().as_slice()).bind(received_at.timestamp()).bind(serde_json::to_string(event)?) + .execute(&mut *tx).await?.rows_affected() != 0; + if !inserted { + return Ok(None); + } + } + let select = "SELECT id, name, channel_type, visibility, description, canvas, created_by, created_at, updated_at, archived_at, deleted_at, nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, ttl_seconds, ttl_deadline FROM channels WHERE community_id = ?1 AND participant_hash = ?2 AND channel_type = 'dm' AND deleted_at IS NULL LIMIT 1"; + if let Some(row) = sqlx::query(select) + .bind(community.as_uuid().to_string()) + .bind(hash.as_slice()) + .fetch_optional(&mut *tx) + .await? + { + sqlx::query("UPDATE channel_members SET hidden_at = NULL WHERE channel_id = ?1 AND pubkey = ?2 AND removed_at IS NULL") + .bind(row.try_get::("id")?) + .bind(created_by) + .execute(&mut *tx) + .await?; + let record = channel_record(row)?; + tx.commit().await?; + return Ok(Some((record, false))); + } + + let id = Uuid::new_v4(); + let name = if participants.len() == 2 { + "DM".to_owned() + } else { + format!("Group DM ({})", participants.len()) + }; + sqlx::query("INSERT INTO channels (id, community_id, name, channel_type, visibility, participant_hash, created_by) VALUES (?1, ?2, ?3, 'dm', 'private', ?4, ?5)") + .bind(id.to_string()) + .bind(community.as_uuid().to_string()) + .bind(name) + .bind(hash.as_slice()) + .bind(created_by) + .execute(&mut *tx) + .await?; + for participant in participants { + sqlx::query("INSERT INTO channel_members (channel_id, pubkey, role, invited_by) VALUES (?1, ?2, 'member', ?3)") + .bind(id.to_string()) + .bind(participant) + .bind(created_by) + .execute(&mut *tx) + .await?; + } + let row = sqlx::query(select) + .bind(community.as_uuid().to_string()) + .bind(hash.as_slice()) + .fetch_one(&mut *tx) + .await?; + let record = channel_record(row)?; + tx.commit().await?; + Ok(Some((record, true))) +} + fn stored_event(row: sqlx::sqlite::SqliteRow) -> Result { let json: String = row.try_get("event_json")?; let event: nostr::Event = serde_json::from_str(&json)?; @@ -497,6 +881,28 @@ pub(crate) async fn get_accessible_channel_ids( .collect() } +pub(crate) async fn communities_of_channels( + pool: &SqlitePool, + channel_ids: &[Uuid], +) -> Result> { + let mut out = std::collections::HashMap::with_capacity(channel_ids.len()); + for channel_id in channel_ids { + let row = sqlx::query_scalar::<_, String>( + "SELECT community_id FROM channels WHERE id = ?1 AND deleted_at IS NULL", + ) + .bind(channel_id.to_string()) + .fetch_optional(pool) + .await?; + if let Some(community_id) = row { + let community_id = Uuid::parse_str(&community_id).map_err(|e| { + crate::DbError::InvalidData(format!("invalid SQLite community id: {e}")) + })?; + out.insert(*channel_id, CommunityId::from_uuid(community_id)); + } + } + Ok(out) +} + pub(crate) async fn get_member_role( pool: &SqlitePool, community: CommunityId, @@ -570,6 +976,29 @@ pub(crate) async fn ensure_user( == 1) } +pub(crate) async fn update_user_profile( + pool: &SqlitePool, + community: CommunityId, + pubkey: &[u8], + display_name: Option<&str>, + avatar_url: Option<&str>, + about: Option<&str>, + nip05_handle: Option<&str>, +) -> Result<()> { + sqlx::query( + "UPDATE users SET display_name = COALESCE(?3, display_name), avatar_url = COALESCE(?4, avatar_url), about = COALESCE(?5, about), nip05_handle = COALESCE(?6, nip05_handle) WHERE community_id = ?1 AND pubkey = ?2", + ) + .bind(community.as_uuid().to_string()) + .bind(pubkey) + .bind(display_name) + .bind(avatar_url) + .bind(about) + .bind(nip05_handle) + .execute(pool) + .await?; + Ok(()) +} + pub(crate) async fn get_user( pool: &SqlitePool, community: CommunityId, @@ -758,6 +1187,50 @@ mod tests { use super::*; use nostr::{EventBuilder, Keys, Kind}; + #[tokio::test] + async fn upgrades_phase_1_core_schema_before_dm_use() { + let path = std::env::temp_dir().join(format!("buzz-db-upgrade-{}.sqlite", Uuid::new_v4())); + let options = SqliteConnectOptions::from_str(&format!("sqlite://{}", path.display())) + .unwrap() + .create_if_missing(true) + .foreign_keys(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .unwrap(); + sqlx::query("CREATE TABLE communities (id TEXT PRIMARY KEY NOT NULL, host TEXT NOT NULL COLLATE NOCASE UNIQUE, icon TEXT, created_at INTEGER NOT NULL DEFAULT (unixepoch()), archived_at INTEGER)") + .execute(&pool).await.unwrap(); + sqlx::query("CREATE TABLE channels (id TEXT PRIMARY KEY NOT NULL, community_id TEXT NOT NULL, name TEXT NOT NULL, description TEXT, canvas TEXT, channel_type TEXT NOT NULL, visibility TEXT NOT NULL, created_by BLOB NOT NULL, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()), archived_at INTEGER, deleted_at INTEGER, nip29_group_id TEXT, topic_required INTEGER NOT NULL DEFAULT 0, max_members INTEGER, topic TEXT, topic_set_by BLOB, topic_set_at INTEGER, purpose TEXT, purpose_set_by BLOB, purpose_set_at INTEGER, ttl_seconds INTEGER, ttl_deadline INTEGER)") + .execute(&pool).await.unwrap(); + sqlx::query("CREATE TABLE channel_members (channel_id TEXT NOT NULL, pubkey BLOB NOT NULL, role TEXT NOT NULL, joined_at INTEGER NOT NULL DEFAULT (unixepoch()), invited_by BLOB, removed_at INTEGER, PRIMARY KEY (channel_id, pubkey))") + .execute(&pool).await.unwrap(); + pool.close().await; + + let upgraded = connect(path.to_str().unwrap()).await.unwrap(); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT version FROM schema_version WHERE singleton = 1") + .fetch_one(&upgraded) + .await + .unwrap(), + 2 + ); + for (table, column) in [ + ("channels", "participant_hash"), + ("channel_members", "hidden_at"), + ] { + let pragma = format!("PRAGMA table_info({table})"); + assert!(sqlx::query(sqlx::AssertSqlSafe(pragma)) + .fetch_all(&upgraded) + .await + .unwrap() + .iter() + .any(|row| row.get::("name") == column)); + } + upgraded.close().await; + std::fs::remove_file(path).unwrap(); + } + #[tokio::test] async fn core_slice_survives_temporary_file_reopen() { let path = std::env::temp_dir().join(format!("buzz-db-{}.sqlite", Uuid::new_v4())); diff --git a/crates/buzz-pubsub/src/lib.rs b/crates/buzz-pubsub/src/lib.rs index 323b7b949f..27605568f2 100644 --- a/crates/buzz-pubsub/src/lib.rs +++ b/crates/buzz-pubsub/src/lib.rs @@ -29,7 +29,7 @@ pub mod conn_control; pub mod error; /// Redis-backed NIP-98 replay seen-set. pub mod nip98_replay; -pub use nip98_replay::RedisNip98ReplayGuard; +pub use nip98_replay::{InProcessNip98ReplayGuard, RedisNip98ReplayGuard}; /// Online/offline presence tracking in Redis. pub mod presence; /// Redis PUBLISH for channel event fan-out. diff --git a/crates/buzz-pubsub/src/nip98_replay.rs b/crates/buzz-pubsub/src/nip98_replay.rs index 858f0f5ace..99baa76a77 100644 --- a/crates/buzz-pubsub/src/nip98_replay.rs +++ b/crates/buzz-pubsub/src/nip98_replay.rs @@ -12,6 +12,76 @@ use buzz_auth::{ }; use nostr::EventId; +/// Process-local NIP-98 replay seen-set for a single relay process. +pub struct InProcessNip98ReplayGuard { + seen: dashmap::DashMap, + max_entries: usize, +} + +impl InProcessNip98ReplayGuard { + /// Create a bounded, expiring process-local replay guard. + pub fn new() -> Self { + Self { + seen: dashmap::DashMap::new(), + max_entries: 100_000, + } + } + + #[cfg(test)] + fn with_capacity(max_entries: usize) -> Self { + Self { + seen: dashmap::DashMap::new(), + max_entries, + } + } +} + +impl Default for InProcessNip98ReplayGuard { + fn default() -> Self { + Self::new() + } +} + +impl Nip98ReplayGuard for InProcessNip98ReplayGuard { + fn try_mark_in_scope<'a>( + &'a self, + scope: &'a str, + event_id: &'a EventId, + ttl_secs: u64, + ) -> std::pin::Pin> + Send + 'a>> + { + Box::pin(async move { + let ttl = std::time::Duration::from_secs( + ttl_secs.clamp(DEFAULT_REPLAY_TTL_SECS, MAX_REPLAY_TTL_SECS), + ); + let now = std::time::Instant::now(); + let key = nip98_replay_key_for_scope(scope, event_id); + if self.seen.len() >= self.max_entries && !self.seen.contains_key(&key) { + self.seen + .retain(|_, marked| now.duration_since(*marked) < ttl); + if self.seen.len() >= self.max_entries { + return Err(AuthError::Internal( + "process-local replay guard capacity exhausted".to_string(), + )); + } + } + match self.seen.entry(key) { + dashmap::mapref::entry::Entry::Vacant(entry) => { + entry.insert(now); + Ok(true) + } + dashmap::mapref::entry::Entry::Occupied(mut entry) + if now.duration_since(*entry.get()) >= ttl => + { + entry.insert(now); + Ok(true) + } + dashmap::mapref::entry::Entry::Occupied(_) => Ok(false), + } + }) + } +} + /// Redis-backed NIP-98 replay seen-set. /// /// Each `try_mark(ctx, event_id, ttl)` issues a single @@ -124,6 +194,29 @@ mod tests { .id } + #[tokio::test] + async fn in_process_guard_rejects_replay_and_fails_closed_at_capacity() { + let guard = InProcessNip98ReplayGuard::with_capacity(1); + let ctx = fresh_ctx(); + let first = fresh_event_id(); + assert!(guard + .try_mark(&ctx, &first, DEFAULT_REPLAY_TTL_SECS) + .await + .expect("first mark")); + assert!(!guard + .try_mark(&ctx, &first, DEFAULT_REPLAY_TTL_SECS) + .await + .expect("replay")); + + let second = fresh_event_id(); + assert!(guard + .try_mark(&ctx, &second, DEFAULT_REPLAY_TTL_SECS) + .await + .expect_err("full guard must fail closed") + .to_string() + .contains("capacity exhausted")); + } + #[tokio::test] #[ignore = "requires Redis"] async fn first_claim_succeeds_replay_fails() { diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index a118ff453f..4981845b17 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1732,7 +1732,13 @@ async fn handle_bridge_search( .search .search(&search_query) .await - .map_err(|e| internal_error(&format!("search error: {e}")))?; + .map_err(|e| match e { + buzz_search::SearchError::Unsupported => api_error( + StatusCode::NOT_IMPLEMENTED, + "unsupported_feature: search is unavailable in this runtime profile", + ), + other => internal_error(&format!("search error: {other}")), + })?; // Fetch full events from DB by ID. Hit ids are already raw 32-byte // arrays from the FTS layer — no hex decode. diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index 2d82736807..8f0cd0a250 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -80,8 +80,23 @@ pub async fn handle_command( enum PersistResult { /// Event was already processed — return idempotent success. Duplicate, - /// Event inserted — transaction is open, handler must commit after mutations. - Inserted(sqlx::Transaction<'static, sqlx::Postgres>), + /// Event inserted — PostgreSQL remains open until mutations finish; SQLite + /// committed the idempotency row atomically before the local mutation. + Inserted(CommandTransaction), +} + +enum CommandTransaction { + Sqlite, + Postgres(sqlx::Transaction<'static, sqlx::Postgres>), +} + +impl CommandTransaction { + async fn commit(self) -> Result<(), sqlx::Error> { + match self { + Self::Sqlite => Ok(()), + Self::Postgres(tx) => tx.commit().await, + } + } } /// Persist a command event inside a transaction. Returns the OPEN transaction @@ -105,6 +120,12 @@ async fn persist_command_event( ) -> Result { let channel_id = channel_id_override.or_else(|| extract_channel_id(event)); + if state.config.profile.is_single_node() { + return Err(IngestError::Rejected( + "unsupported_feature: this command is unavailable in the single-node profile".into(), + )); + } + let mut tx = state .db .begin_transaction() @@ -227,7 +248,7 @@ async fn persist_command_event( // Duplicate — rollback (implicit on drop) and signal idempotent success. Ok(PersistResult::Duplicate) } else { - Ok(PersistResult::Inserted(tx)) + Ok(PersistResult::Inserted(CommandTransaction::Postgres(tx))) } } @@ -345,27 +366,35 @@ async fn handle_dm_open( } } - // Persist the command event (idempotency) — returns open transaction - let tx = match persist_command_event(state, tenant, event, None).await? { - PersistResult::Duplicate => { - return Ok(IngestResult { - event_id: event.id.to_hex(), - accepted: true, - message: "duplicate: already processed".into(), - }); - } - PersistResult::Inserted(tx) => tx, - }; - - // 4. Execute: open_dm + // SQLite couples the command idempotency row and DM mutation in one + // transaction. PostgreSQL retains the existing open-transaction path. let all_refs: Vec<&[u8]> = all_bytes.iter().map(|b| b.as_slice()).collect(); - let (channel, was_created) = state + let sqlite_result = state .db - .open_dm(tenant.community(), &all_refs, &self_bytes) + .open_dm_sqlite_command(tenant.community(), &all_refs, &self_bytes, event) .await .map_err(|e| IngestError::Internal(format!("error: db open_dm: {e}")))?; + let (channel, was_created, tx) = if let Some(result) = sqlite_result { + (result.0, result.1, CommandTransaction::Sqlite) + } else { + let tx = match persist_command_event(state, tenant, event, None).await? { + PersistResult::Duplicate => { + return Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message: "duplicate: already processed".into(), + }); + } + PersistResult::Inserted(tx) => tx, + }; + let (channel, was_created) = state + .db + .open_dm(tenant.community(), &all_refs, &self_bytes) + .await + .map_err(|e| IngestError::Internal(format!("error: db open_dm: {e}")))?; + (channel, was_created, tx) + }; - // Commit: event + mutation succeeded atomically. tx.commit() .await .map_err(|e| IngestError::Internal(format!("error: commit transaction: {e}")))?; diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index a67797385b..cf295bdfa3 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -525,7 +525,8 @@ async fn dispatch_persistent_event_inner( .iter() .any(|t| t.as_slice().first().map(|s| s.as_str()) == Some("buzz:workflow")); - if !buzz_core::kind::is_workflow_execution_kind(kind_u32) + if !state.config.profile.is_single_node() + && !buzz_core::kind::is_workflow_execution_kind(kind_u32) && !buzz_core::kind::is_command_kind(kind_u32) && !is_relay_workflow_msg && kind_u32 != KIND_GIFT_WRAP diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 88a9f0c731..ac24e74c23 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -809,6 +809,9 @@ pub fn emit_live_thread_summary( channel_id: Uuid, root_id: Vec, ) { + if state.config.profile.is_single_node() { + return; + } let tenant = tenant.clone(); let state = Arc::clone(state); tokio::spawn(async move { @@ -2932,6 +2935,12 @@ pub async fn publish_nip43_membership_list( tenant: &TenantContext, state: &Arc, ) -> anyhow::Result<()> { + // The production implementation relies on a PostgreSQL advisory lock for + // snapshot serialization. Single-node still emits the durable per-member + // delta above, but deliberately omits this denormalized list snapshot. + if state.config.profile.is_single_node() { + return Ok(()); + } let started_at = std::time::Instant::now(); metrics::counter!("buzz_nip43_membership_publications_total", "result" => "attempted") .increment(1); @@ -2990,6 +2999,11 @@ async fn publish_nip43_delta( target_pubkey_hex: &str, label: &str, ) -> anyhow::Result<()> { + // These are globally scoped events. Keep local relay membership private to + // the loopback process rather than exposing deltas to every subscriber. + if state.config.profile.is_single_node() { + return Ok(()); + } let relay_pubkey_hex = state.relay_keypair.public_key().to_hex(); let tags = vec![ diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 6c70f0b565..fb5aacc152 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -11,16 +11,17 @@ fn log_env_filter(rust_log: Option<&str>) -> EnvFilter { use uuid::Uuid; use buzz_audit::AuditService; +use buzz_auth::nip98_replay::Nip98ReplayGuard; use buzz_auth::AuthService; use buzz_core::CommunityId; use buzz_db::{Db, DbConfig}; -use buzz_pubsub::PubSubManager; +use buzz_pubsub::{rate_limiter::AdmissionRateLimiter, InProcessNip98ReplayGuard, PubSubManager}; use buzz_search::SearchService; use buzz_relay::config::{Config, MAX_DRAIN_JITTER_MS}; use buzz_relay::metrics as relay_metrics; use buzz_relay::router::{build_health_router, build_router}; -use buzz_relay::state::AppState; +use buzz_relay::state::{AppBackends, AppState}; use buzz_relay::storage_sweep; use buzz_relay::telemetry; use buzz_workflow::WorkflowEngine; @@ -153,13 +154,8 @@ async fn main() -> anyhow::Result<()> { "Config loaded" ); - // Phase 1 constructor fence: never fall through to production's eager - // Postgres/Redis/S3 graph for a single-node profile. The local backend - // constructor replaces this explicit denial as its implementations land. if config.profile.is_single_node() { - return Err(anyhow::anyhow!( - "BUZZ_PROFILE=single-node local backend bundle is not installed yet" - )); + return run_single_node(config, tracer_init).await; } let usage_interval_secs = usage_metrics_interval_secs(); @@ -1202,6 +1198,109 @@ async fn run_periodic_until_cancelled( /// │ SIGTERM → shutting_down=true → readiness 503 │ /// │ → graceful drain (30s) → exit │ /// └─────────────────────────────────────────────────────────┘ +/// Start the production router with only embedded/process-local backends. +async fn run_single_node(config: Config, tracer_init: telemetry::TracerInit) -> anyhow::Result<()> { + if !config.bind_addr.ip().is_loopback() { + return Err(anyhow::anyhow!( + "BUZZ_PROFILE=single-node requires a loopback BUZZ_BIND_ADDR, got {}", + config.bind_addr + )); + } + if config.require_relay_membership && config.relay_owner_pubkey.is_none() { + return Err(anyhow::anyhow!( + "RELAY_OWNER_PUBKEY required when BUZZ_REQUIRE_RELAY_MEMBERSHIP=true" + )); + } + if config.require_relay_membership && config.relay_private_key.is_none() { + return Err(anyhow::anyhow!( + "BUZZ_RELAY_PRIVATE_KEY is required when BUZZ_REQUIRE_RELAY_MEMBERSHIP=true" + )); + } + + relay_metrics::install(config.metrics_port, usage_metrics_idle_timeout_secs(60)); + let db_path = + std::env::var("BUZZ_LOCAL_DB").unwrap_or_else(|_| "buzz-local.sqlite".to_string()); + let media_root = + std::env::var("BUZZ_LOCAL_MEDIA_DIR").unwrap_or_else(|_| "buzz-local-media".to_string()); + let db = Db::new_sqlite(&db_path) + .await + .map_err(|e| anyhow::anyhow!("SQLite initialization failed: {e}"))?; + let host = buzz_relay::tenant::relay_url_authority(&config.relay_url); + if host.is_empty() { + return Err(anyhow::anyhow!( + "Cannot derive community host from BUZZ_RELAY_URL" + )); + } + let community = db.ensure_configured_community(&host).await?.id; + if let Some(owner) = config.relay_owner_pubkey.as_deref() { + db.bootstrap_owner(community, owner).await?; + } + + let relay_keypair = if let Some(hex) = &config.relay_private_key { + nostr::Keys::parse(hex) + .map_err(|e| anyhow::anyhow!("invalid BUZZ_RELAY_PRIVATE_KEY: {e}"))? + } else { + nostr::Keys::parse("0000000000000000000000000000000000000000000000000000000000000001") + .expect("hardcoded development key") + }; + let pubsub = Arc::new(PubSubManager::in_process()); + let workflow_engine = Arc::new(WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + // Git routes and probes are disabled by the profile. A never-used store + // satisfies the concrete state field until GitStore gains a local backend. + let git_store = buzz_relay::api::git::store::GitStore::new( + "http://127.0.0.1:1", + "local", + "local", + "disabled", + "local", + buzz_media::S3AddressingStyle::Path, + )?; + let replay: Arc = Arc::new(InProcessNip98ReplayGuard::new()); + let (app_state, audit_shutdown) = AppState::new_with_backends( + config, + AppBackends { + db, + redis_pool: None, + pubsub, + search: SearchService::unsupported(), + media_storage: buzz_media::MediaStorage::filesystem(&media_root), + git_store, + nip98_replay: replay, + admission_rate_limiter: Arc::new(AdmissionRateLimiter::permissive()), + }, + None::, + AuthService::new(buzz_auth::AuthConfig::default()), + workflow_engine, + relay_keypair, + ); + let state = Arc::new(app_state); + + let state_for_fanout = Arc::clone(&state); + let mut events = state.pubsub.subscribe_local(); + tokio::spawn(async move { + while let Ok(event) = events.recv().await { + buzz_relay::handlers::event::fan_out_pubsub_event(&state_for_fanout, event).await; + } + }); + + info!(database = %db_path, media = %media_root, community = %community, + "single-node profile ready; search, Git, push, workflows, reapers, usage and replica tasks disabled"); + let router = build_router(Arc::clone(&state)); + let health_router = build_health_router(Arc::clone(&state)); + serve(router, health_router, Arc::clone(&state)).await?; + state.community_revalidator_cancel.cancel(); + audit_shutdown + .drain(std::time::Duration::from_secs(5)) + .await; + if let telemetry::TracerInit::Enabled(tp) = tracer_init { + let _ = tp.shutdown(); + } + Ok(()) +} + /// ``` /// /// ## Shutdown budget From 76769f962e0f212df368a6ba232b500ead4b9214 Mon Sep 17 00:00:00 2001 From: npub1z3hmzc9ryehxzedl5wzlvpyvja0d483peaja5zt6pd0209f9x2jspe2dxh <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 12:58:32 -0400 Subject: [PATCH 04/16] Add desktop local relay sidecar flow Signed-off-by: npub1z3hmzc9ryehxzedl5wzlvpyvja0d483peaja5zt6pd0209f9x2jspe2dxh <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> --- Justfile | 7 +- desktop/src-tauri/src/commands/identity.rs | 7 +- desktop/src-tauri/src/commands/workspace.rs | 27 +- desktop/src-tauri/src/lib.rs | 4 + desktop/src-tauri/src/local_relay.rs | 323 ++++++++++++++++++ desktop/src-tauri/tauri.conf.json | 3 +- desktop/src/app/App.tsx | 11 +- .../communities/communityStorage.test.mjs | 14 + .../features/communities/communityStorage.ts | 12 +- desktop/src/features/communities/types.ts | 2 + .../features/communities/ui/WelcomeSetup.tsx | 26 ++ .../features/communities/useCommunityInit.ts | 11 +- .../onboarding/communityOnboarding.tsx | 2 + scripts/bundle-sidecars.sh | 6 +- 14 files changed, 437 insertions(+), 18 deletions(-) create mode 100644 desktop/src-tauri/src/local_relay.rs diff --git a/Justfile b/Justfile index 0a43249d5f..bff1c48fc4 100644 --- a/Justfile +++ b/Justfile @@ -155,7 +155,7 @@ _ensure-sidecar-stubs: set -euo pipefail TARGET=$(rustc -vV | sed -n 's|host: ||p') mkdir -p desktop/src-tauri/binaries - SIDECARS=(buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz) + SIDECARS=(buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz buzz-relay) if [[ "$TARGET" != *windows* ]]; then SIDECARS+=(buzz-backend-kubernetes) fi @@ -258,6 +258,7 @@ desktop-release-build target="aarch64-apple-darwin": touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET" touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" touch "desktop/src-tauri/binaries/buzz-$TARGET" + touch "desktop/src-tauri/binaries/buzz-relay-$TARGET" pnpm install cd {{desktop_dir}} && pnpm tauri build --features mesh-llm --target {{target}} @@ -503,10 +504,10 @@ desktop-standalone *ARGS: _ensure-sidecar-stubs #!/usr/bin/env bash set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" - cargo build -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr + cargo build -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay TARGET=$(rustc -vV | sed -n 's|host: ||p') TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory") - for bin in buzz-acp buzz-agent buzz-backend-kubernetes buzz-dev-mcp git-credential-nostr buzz; do + for bin in buzz-acp buzz-agent buzz-backend-kubernetes buzz-dev-mcp git-credential-nostr buzz buzz-relay; do cp "${TARGET_DIR}/debug/${bin}" "desktop/src-tauri/binaries/${bin}-${TARGET}" chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}" done diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index ec2357b85e..d6350a7ee8 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -51,8 +51,11 @@ pub fn get_identity(state: State<'_, AppState>) -> Result } #[tauri::command] -pub fn get_default_relay_url() -> String { - relay::relay_ws_url() +pub fn get_default_relay_url(state: State<'_, AppState>) -> Result { + // A hosted default remains just that: resolving it never spawns an unused + // local sidecar. The explicit local workspace sentinel starts its sidecar + // during `apply_workspace`. + Ok(relay::relay_ws_url_with_override(&state)) } #[tauri::command] diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index aa88bfe39a..f7b022d04f 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -135,7 +135,9 @@ pub async fn apply_workspace( tokio::task::spawn_blocking(move || { let state = app.state::(); - // ── Validate before mutating ────────────────────────────────────────── + // Validate credentials before starting a local sidecar: an onboarding + // import may replace the in-memory identity, and the relay's durable + // nest/owner must match the identity that this workspace will use. let parsed_keys = match nsec.as_deref().map(str::trim).filter(|s| !s.is_empty()) { Some(nsec_trimmed) => { Some(Keys::parse(nsec_trimmed).map_err(|e| format!("invalid nsec: {e}"))?) @@ -143,6 +145,18 @@ pub async fn apply_workspace( None => None, }; + if crate::local_relay::is_local_relay_url(&relay_url) { + // Only the UI-created local sentinel can launch the bundled relay. + // An arbitrary loopback URL remains a normal remote community. + let keys = parsed_keys.clone().unwrap_or(state.signing_keys()?); + let runtime = app.state::(); + crate::local_relay::ensure_started(&app, &runtime, &keys, None)?; + } else { + // A desktop-owned relay is private to the active local workspace; + // do not leave it serving after a switch to any remote workspace. + crate::local_relay::stop(&app.state::()); + } + // Decide the effective repos_dir from the candidate. A bad path does NOT // reject — it is treated as if no override were set: relay/keys still // apply, the bad value is not persisted, and a `repos-dir-error` surfaces @@ -165,8 +179,17 @@ pub async fn apply_workspace( // ── Apply all state changes (nothing below can fail) ────────────────── { + // The local sentinel persists across restarts while the sidecar + // endpoint is deliberately ephemeral. Resolve it only after + // `ensure_started` above, before any agent can be restored. + let effective_relay_url = if crate::local_relay::is_local_relay_url(&relay_url) { + crate::local_relay::relay_url(&app.state::()) + .ok_or("local relay did not start")? + } else { + relay_url + }; let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?; - *override_guard = Some(relay_url); + *override_guard = Some(effective_relay_url); } // Reset the Rust-side admission gate when switching workspace/community, // matching `resetRateLimitGate()` on the TS side (useCommunityInit.ts:38). diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7aa954ce8e..b3444601b7 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -14,6 +14,7 @@ mod initial_window; mod key_backup; mod link_preview_tags; mod linux_media; +mod local_relay; #[cfg(target_os = "macos")] mod macos_notifications; mod managed_agents; @@ -301,6 +302,7 @@ pub fn run() { }); }) .manage(build_app_state()) + .manage(local_relay::RuntimeState::new(None)) .manage(ClipboardState::new()) .manage(PendingCommunityDeepLinks::default()) .manage(BuilderlabSession::default()) @@ -968,9 +970,11 @@ pub fn run() { if is_restart_request(code) { restart_requested.store(true, Ordering::SeqCst); } + local_relay::stop(&app_handle.state::()); shut_down_app(app_handle, &run_shutdown_done); } RunEvent::Exit => { + local_relay::stop(&app_handle.state::()); shut_down_app(app_handle, &run_shutdown_done); app_handle.state::().release(); diff --git a/desktop/src-tauri/src/local_relay.rs b/desktop/src-tauri/src/local_relay.rs new file mode 100644 index 0000000000..c7d69e44ce --- /dev/null +++ b/desktop/src-tauri/src/local_relay.rs @@ -0,0 +1,323 @@ +//! Lifecycle management for the embedded single-node relay. +//! +//! The relay remains the source of truth for local-mode behavior. Desktop only +//! chooses private loopback ports, supplies its identity and durable paths, and +//! supervises the bundled `buzz-relay` binary. + +use std::{ + net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + sync::Mutex, + time::{Duration, Instant}, +}; + +use nostr::Keys; +use tauri::{AppHandle, Manager}; + +const STARTUP_TIMEOUT: Duration = Duration::from_secs(15); +const POLL_INTERVAL: Duration = Duration::from_millis(100); + +pub(crate) struct LocalRelayRuntime { + child: Child, + url: String, + owner_pubkey: String, +} + +pub(crate) type RuntimeState = Mutex>; + +#[derive(Debug, PartialEq, Eq)] +struct LocalRelayConfig { + relay_addr: SocketAddr, + health_port: u16, + metrics_port: u16, + data_dir: PathBuf, + owner_pubkey: String, + relay_private_key: String, +} + +impl LocalRelayConfig { + fn url(&self) -> String { + format!("ws://{}", self.relay_addr) + } + + fn environment(&self) -> Vec<(&'static str, String)> { + let db_path = self.data_dir.join("relay.sqlite3"); + let media_dir = self.data_dir.join("media"); + vec![ + ("BUZZ_PROFILE", "single-node".to_string()), + ("BUZZ_BIND_ADDR", self.relay_addr.to_string()), + ("BUZZ_HEALTH_PORT", self.health_port.to_string()), + ("BUZZ_METRICS_PORT", self.metrics_port.to_string()), + ("RELAY_URL", self.url()), + ("BUZZ_LOCAL_DB", sqlite_url(&db_path)), + ("BUZZ_LOCAL_MEDIA_DIR", media_dir.display().to_string()), + ("BUZZ_REQUIRE_RELAY_MEMBERSHIP", "true".to_string()), + ("RELAY_OWNER_PUBKEY", self.owner_pubkey.clone()), + ("BUZZ_RELAY_PRIVATE_KEY", self.relay_private_key.clone()), + ] + } +} + +impl LocalRelayRuntime { + pub(crate) fn url(&self) -> &str { + &self.url + } + + fn stop(&mut self) { + // The relay handles SIGTERM with a WebSocket drain. It is our child, so + // waiting here prevents a restart from inheriting a stale listener. + #[cfg(unix)] + unsafe { + libc::kill(self.child.id() as i32, libc::SIGTERM); + } + #[cfg(windows)] + let _ = self.child.kill(); + + let deadline = Instant::now() + Duration::from_secs(8); + while Instant::now() < deadline { + if self.child.try_wait().ok().flatten().is_some() { + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// Starts a relay for this desktop identity and waits for the public relay-info +/// endpoint. `Db::new_sqlite` in the relay applies its versioned SQLite +/// migrations before this endpoint can become available. +pub(crate) fn start( + app: &AppHandle, + keys: &Keys, + requested_port: Option, +) -> Result { + let relay_port = requested_port.unwrap_or(free_loopback_port()?); + let owner_pubkey = keys.public_key().to_hex(); + let config = LocalRelayConfig { + relay_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), relay_port), + health_port: free_loopback_port()?, + metrics_port: free_loopback_port()?, + data_dir: local_data_dir(app, &owner_pubkey)?, + owner_pubkey, + relay_private_key: keys.secret_key().to_secret_hex(), + }; + let url = config.url(); + let media_dir = config.data_dir.join("media"); + std::fs::create_dir_all(&media_dir) + .map_err(|e| format!("create local relay media dir: {e}"))?; + + let binary = relay_binary()?; + let mut command = Command::new(&binary); + command + .envs(config.environment()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut child = command + .spawn() + .map_err(|e| format!("start bundled buzz-relay at {}: {e}", binary.display()))?; + + let info_url = format!("http://{}/info", config.relay_addr); + let deadline = Instant::now() + STARTUP_TIMEOUT; + while Instant::now() < deadline { + if child + .try_wait() + .map_err(|e| format!("inspect local relay: {e}"))? + .is_some() + { + return Err("bundled buzz-relay exited before becoming ready".to_string()); + } + if reqwest::blocking::Client::new() + .get(&info_url) + .header("Accept", "application/nostr+json") + .send() + .is_ok_and(|response| response.status().is_success()) + { + return Ok(LocalRelayRuntime { + child, + url, + owner_pubkey: config.owner_pubkey, + }); + } + std::thread::sleep(POLL_INTERVAL); + } + + let _ = child.kill(); + let _ = child.wait(); + Err("bundled buzz-relay did not become ready within 15 seconds".to_string()) +} + +pub(crate) fn ensure_started( + app: &AppHandle, + runtime: &RuntimeState, + keys: &Keys, + requested_port: Option, +) -> Result { + let mut runtime = runtime.lock().map_err(|error| error.to_string())?; + if let Some(running) = runtime.as_ref() { + let same_port = requested_port.is_none_or(|port| running.url == local_relay_url(port)); + let same_owner = running.owner_pubkey == keys.public_key().to_hex(); + if same_port && same_owner { + return Ok(running.url().to_string()); + } + if !same_owner { + // An onboarding identity import changed the owner. Its local relay + // must use a separate identity-scoped nest, not retain the prior + // identity's private sidecar. + if let Some(mut previous) = runtime.take() { + previous.stop(); + } + } else { + return Err("a different local relay port is already running".to_string()); + } + } + let started = start(app, keys, requested_port)?; + let url = started.url().to_string(); + *runtime = Some(started); + Ok(url) +} + +pub(crate) fn stop(runtime: &RuntimeState) { + if let Ok(mut runtime) = runtime.lock() { + if let Some(mut runtime) = runtime.take() { + runtime.stop(); + } + } +} + +/// The persisted sentinel for the desktop-owned local workspace. It is never a +/// network endpoint: `apply_workspace` resolves it to the current supervised +/// loopback URL before any client or managed agent is restored. +pub(crate) const LOCAL_RELAY_SENTINEL: &str = "buzz-local://on-this-device"; + +pub(crate) fn is_local_relay_url(url: &str) -> bool { + url == LOCAL_RELAY_SENTINEL +} + +fn local_relay_url(port: u16) -> String { + format!("ws://127.0.0.1:{port}") +} + +pub(crate) fn relay_url(runtime: &RuntimeState) -> Option { + runtime + .lock() + .ok() + .and_then(|runtime| runtime.as_ref().map(|runtime| runtime.url().to_string())) +} + +fn local_data_dir(app: &AppHandle, owner_pubkey: &str) -> Result { + // Relay state is both durable and identity-scoped. A different desktop + // identity must never inherit this identity's SQLite membership or media. + app.path() + .app_data_dir() + .map(|path| path.join("local-relay").join(owner_pubkey)) + .map_err(|e| format!("resolve app data directory for local relay: {e}")) +} + +fn sqlite_url(path: &Path) -> String { + // sqlx accepts an absolute sqlite URL with three slashes. Path display is + // intentional: app-data paths come from the OS, not user relay input. + format!("sqlite://{}", path.display()) +} + +fn free_loopback_port() -> Result { + let listener = + TcpListener::bind("127.0.0.1:0").map_err(|e| format!("pick local relay port: {e}"))?; + listener + .local_addr() + .map(|address| address.port()) + .map_err(|e| format!("read local relay port: {e}")) +} + +fn relay_binary() -> Result { + let exe = std::env::current_exe().map_err(|e| format!("resolve desktop executable: {e}"))?; + let parent = exe + .parent() + .ok_or("desktop executable has no parent directory")?; + let name = if cfg!(windows) { + "buzz-relay.exe" + } else { + "buzz-relay" + }; + let binary = parent.join(name); + if binary.is_file() { + Ok(binary) + } else { + Err(format!( + "bundled buzz-relay is missing at {}", + binary.display() + )) + } +} + +#[cfg(test)] +mod tests { + use super::{ + is_local_relay_url, local_relay_url, sqlite_url, LocalRelayConfig, LOCAL_RELAY_SENTINEL, + }; + use std::{ + collections::HashMap, + net::{IpAddr, Ipv4Addr, SocketAddr}, + path::{Path, PathBuf}, + }; + + #[test] + fn sqlite_url_is_absolute() { + assert_eq!( + sqlite_url(Path::new("/tmp/buzz.sqlite3")), + "sqlite:///tmp/buzz.sqlite3" + ); + } + + #[test] + fn local_relay_sentinel_is_the_only_managed_workspace_address() { + assert_eq!(local_relay_url(4317), "ws://127.0.0.1:4317"); + assert!(is_local_relay_url(LOCAL_RELAY_SENTINEL)); + assert!(!is_local_relay_url("ws://127.0.0.1:4317")); + assert!(!is_local_relay_url("ws://localhost:4317")); + assert!(!is_local_relay_url("wss://127.0.0.1:4317")); + } + + #[test] + fn local_relay_environment_uses_only_the_single_node_contract() { + let config = LocalRelayConfig { + relay_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4317), + health_port: 4318, + metrics_port: 4319, + data_dir: PathBuf::from("/tmp/buzz/local-relay"), + owner_pubkey: "owner".to_string(), + relay_private_key: "private".to_string(), + }; + let env: HashMap<_, _> = config.environment().into_iter().collect(); + + assert_eq!(env.get("BUZZ_PROFILE"), Some(&"single-node".to_string())); + assert_eq!( + env.get("BUZZ_BIND_ADDR"), + Some(&"127.0.0.1:4317".to_string()) + ); + assert_eq!( + env.get("RELAY_URL"), + Some(&"ws://127.0.0.1:4317".to_string()) + ); + assert_eq!( + env.get("BUZZ_LOCAL_DB"), + Some(&"sqlite:///tmp/buzz/local-relay/relay.sqlite3".to_string()) + ); + assert_eq!( + env.get("BUZZ_LOCAL_MEDIA_DIR"), + Some(&"/tmp/buzz/local-relay/media".to_string()) + ); + assert_eq!( + env.get("BUZZ_REQUIRE_RELAY_MEMBERSHIP"), + Some(&"true".to_string()) + ); + assert_eq!(env.get("RELAY_OWNER_PUBKEY"), Some(&"owner".to_string())); + assert_eq!( + env.get("BUZZ_RELAY_PRIVATE_KEY"), + Some(&"private".to_string()) + ); + } +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 3f332ddbf1..75d7c7c594 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -58,7 +58,8 @@ "binaries/buzz-backend-kubernetes", "binaries/buzz-dev-mcp", "binaries/git-credential-nostr", - "binaries/buzz" + "binaries/buzz", + "binaries/buzz-relay" ], "icon": [ "icons/32x32.png", diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 0f311f3a65..528e397f36 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -42,7 +42,10 @@ import { PendingInviteGate } from "@/features/onboarding/ui/PendingInviteGate"; import { KeyringLockedScreen } from "@/features/onboarding/ui/KeyringLockedScreen"; import { RelaunchRequiredScreen } from "@/features/onboarding/ui/RelaunchRequiredScreen"; import { ResetFailedScreen } from "@/features/onboarding/ui/ResetFailedScreen"; -import { loadCommunityDiscoveryAfterLeave } from "@/features/communities/communityStorage"; +import { + isLocalCommunityRelayUrl, + loadCommunityDiscoveryAfterLeave, +} from "@/features/communities/communityStorage"; import { useCommunityInit } from "@/features/communities/useCommunityInit"; import { useNestNotifications } from "@/features/communities/useNestNotifications"; import { useCommunities } from "@/features/communities/useCommunities"; @@ -387,13 +390,17 @@ function CommunityApp({ return; } const previousCommunityId = activeCommunity?.id; + const isLocal = isLocalCommunityRelayUrl(transaction.relayUrl); const relayAlreadyExists = communities.some( - (community) => community.relayUrl === transaction.relayUrl, + (community) => + community.relayUrl === transaction.relayUrl || + (isLocal && community.local === true), ); const id = addCommunity({ id: crypto.randomUUID(), name: transaction.communityName, relayUrl: transaction.relayUrl, + local: isLocal, token: transaction.token, reposDir: transaction.reposDir, pubkey: currentPubkey ?? undefined, diff --git a/desktop/src/features/communities/communityStorage.test.mjs b/desktop/src/features/communities/communityStorage.test.mjs index fca1909137..94e21cd84c 100644 --- a/desktop/src/features/communities/communityStorage.test.mjs +++ b/desktop/src/features/communities/communityStorage.test.mjs @@ -3,9 +3,13 @@ import test from "node:test"; import { clearCommunityStorage, + deriveCommunityName, initFirstCommunity, + isLocalCommunityRelayUrl, loadCommunities, loadCommunityDiscoveryAfterLeave, + LOCAL_COMMUNITY_NAME, + LOCAL_COMMUNITY_RELAY_URL, markCommunityDiscoveryAfterLeave, migrateLegacyCommunityStorage, saveCommunities, @@ -53,6 +57,16 @@ test("migrateLegacyCommunityStorage does not overwrite new community state", () assert.equal(storage.getItem("buzz-active-community-id"), "new"); }); +test("only the local sentinel identifies the desktop-managed workspace", () => { + assert.equal(isLocalCommunityRelayUrl(LOCAL_COMMUNITY_RELAY_URL), true); + assert.equal( + deriveCommunityName(LOCAL_COMMUNITY_RELAY_URL), + LOCAL_COMMUNITY_NAME, + ); + assert.equal(isLocalCommunityRelayUrl("ws://127.0.0.1:4317"), false); + assert.equal(isLocalCommunityRelayUrl("ws://localhost:4317"), false); +}); + test("signed-build relay defaults auto-connect during first-run onboarding", () => { assert.equal( shouldAutoConnectDefaultRelay("wss://buzz.block.builderlab.xyz"), diff --git a/desktop/src/features/communities/communityStorage.ts b/desktop/src/features/communities/communityStorage.ts index 6f99ce8d33..1cf2033be9 100644 --- a/desktop/src/features/communities/communityStorage.ts +++ b/desktop/src/features/communities/communityStorage.ts @@ -10,6 +10,13 @@ const LEGACY_ACTIVE_WORKSPACE_KEY = "buzz-active-workspace-id"; const COMMUNITY_DISCOVERY_AFTER_LEAVE_KEY = "buzz-community-discovery-after-leave"; +export const LOCAL_COMMUNITY_RELAY_URL = "buzz-local://on-this-device"; +export const LOCAL_COMMUNITY_NAME = "On this device"; + +export function isLocalCommunityRelayUrl(relayUrl: string): boolean { + return relayUrl === LOCAL_COMMUNITY_RELAY_URL; +} + /** * Expand a leading `~` to the user's home directory. The backend rejects * `~`-prefixed paths (`std::fs` does not expand the shell tilde), so the UI @@ -186,13 +193,16 @@ export function shouldAutoConnectDefaultRelay(relayUrl: string): boolean { } export function deriveCommunityName(relayUrl: string): string { + if (isLocalCommunityRelayUrl(relayUrl)) { + return LOCAL_COMMUNITY_NAME; + } try { const url = new URL( relayUrl.replace("ws://", "http://").replace("wss://", "https://"), ); const host = url.hostname; if (isLocalRelayHost(host)) { - return "Local Dev"; + return "On this device"; } const parts = host.split("."); // Detect staging environments (e.g. buzz-oss.stage.blox.sqprod.co) diff --git a/desktop/src/features/communities/types.ts b/desktop/src/features/communities/types.ts index 7087ca7ce3..620b84056d 100644 --- a/desktop/src/features/communities/types.ts +++ b/desktop/src/features/communities/types.ts @@ -17,6 +17,8 @@ export type Community = { * `REPOS` directory inside the nest. */ reposDir?: string; + /** True only for the relay supervised by this desktop instance. */ + local?: boolean; /** * @deprecated Never read. Kept on the type so old localStorage entries * deserialise without errors. New entries never set this field, and diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx index 530933acc2..21859c653f 100644 --- a/desktop/src/features/communities/ui/WelcomeSetup.tsx +++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx @@ -1,6 +1,10 @@ import * as React from "react"; import { Check, Copy } from "lucide-react"; +import { + LOCAL_COMMUNITY_NAME, + LOCAL_COMMUNITY_RELAY_URL, +} from "@/features/communities/communityStorage"; import { HostedCommunityOnboarding } from "@/features/communities/ui/HostedCommunityOnboarding"; import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding"; import { InviteRedeemForm } from "@/features/onboarding/ui/InviteRedeemForm"; @@ -92,6 +96,15 @@ export function WelcomeSetup({ [communityOnboarding, page], ); + const startLocalCommunity = React.useCallback(() => { + communityOnboarding.start({ + source: "first-community", + firstCommunityPage: "join", + communityName: LOCAL_COMMUNITY_NAME, + relayUrl: LOCAL_COMMUNITY_RELAY_URL, + }); + }, [communityOnboarding]); + const transitionDirection = transitionMode === "backward" ? "backward" : "forward"; const welcomeEffect = @@ -124,6 +137,19 @@ export function WelcomeSetup({

+ + + Date: Sat, 1 Aug 2026 13:24:27 -0400 Subject: [PATCH 05/16] fix: restart exited desktop relay sidecar Signed-off-by: npub1z3hmzc9ryehxzedl5wzlvpyvja0d483peaja5zt6pd0209f9x2jspe2dxh <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/local_relay.rs | 63 ++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/src/local_relay.rs b/desktop/src-tauri/src/local_relay.rs index c7d69e44ce..dc33243f9a 100644 --- a/desktop/src-tauri/src/local_relay.rs +++ b/desktop/src-tauri/src/local_relay.rs @@ -64,6 +64,13 @@ impl LocalRelayRuntime { &self.url } + fn is_running(&mut self) -> Result { + self.child + .try_wait() + .map(|status| status.is_none()) + .map_err(|error| format!("inspect local relay: {error}")) + } + fn stop(&mut self) { // The relay handles SIGTERM with a WebSocket drain. It is our child, so // waiting here prevents a restart from inheriting a stale listener. @@ -157,19 +164,27 @@ pub(crate) fn ensure_started( requested_port: Option, ) -> Result { let mut runtime = runtime.lock().map_err(|error| error.to_string())?; - if let Some(running) = runtime.as_ref() { + if let Some(running) = runtime.as_mut() { let same_port = requested_port.is_none_or(|port| running.url == local_relay_url(port)); let same_owner = running.owner_pubkey == keys.public_key().to_hex(); - if same_port && same_owner { + let is_running = running.is_running()?; + if same_port && same_owner && is_running { return Ok(running.url().to_string()); } if !same_owner { // An onboarding identity import changed the owner. Its local relay // must use a separate identity-scoped nest, not retain the prior - // identity's private sidecar. + // identity's private sidecar. `try_wait` above already reaped an + // exited child, so never signal its potentially recycled PID. if let Some(mut previous) = runtime.take() { - previous.stop(); + if is_running { + previous.stop(); + } } + } else if !is_running { + // `try_wait` reaped the crashed child. Drop its stale handle so the + // replacement below receives a fresh loopback port and child. + runtime.take(); } else { return Err("a different local relay port is already running".to_string()); } @@ -262,6 +277,7 @@ mod tests { collections::HashMap, net::{IpAddr, Ipv4Addr, SocketAddr}, path::{Path, PathBuf}, + process::{Command, Stdio}, }; #[test] @@ -281,6 +297,45 @@ mod tests { assert!(!is_local_relay_url("wss://127.0.0.1:4317")); } + #[test] + fn exited_relay_is_not_running() { + let child = Command::new(std::env::current_exe().expect("test executable")) + .arg("--help") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("start test child"); + let mut runtime = super::LocalRelayRuntime { + child, + url: "ws://127.0.0.1:4317".to_string(), + owner_pubkey: "owner".to_string(), + }; + + runtime.child.wait().expect("wait for test child"); + assert!(!runtime.is_running().expect("inspect exited child")); + } + + #[test] + fn relay_service_key_is_durable_and_distinct_from_owner_identity() { + let directory = tempfile::tempdir().expect("temp data dir"); + let owner = nostr::Keys::generate(); + let first = load_or_create_relay_keys(directory.path()).expect("create service key"); + let second = load_or_create_relay_keys(directory.path()).expect("reload service key"); + + assert_ne!(first.public_key(), owner.public_key()); + assert_eq!(first.public_key(), second.public_key()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(directory.path().join("relay-service.key")) + .expect("service key metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + } + } + #[test] fn local_relay_environment_uses_only_the_single_node_contract() { let config = LocalRelayConfig { From 89e0350bbf7af9ee107f0bf952c8c53f2799fe09 Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Mon, 3 Aug 2026 08:32:15 -0400 Subject: [PATCH 06/16] Fix local relay auth and community persistence Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- crates/buzz-db/src/lib.rs | 21 +++++++++++ crates/buzz-relay/src/main.rs | 9 ++++- desktop/src-tauri/src/commands/agents.rs | 40 +++++++++++++++++++++ desktop/src-tauri/src/commands/workspace.rs | 9 ++++- desktop/src-tauri/src/local_relay.rs | 2 ++ 5 files changed, 79 insertions(+), 2 deletions(-) diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 9b9a2d32ac..cf4aaf94e7 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -1439,6 +1439,27 @@ impl Db { Ok(()) } + /// Rebind the durable loopback community to this single-node process's + /// current ephemeral authority while preserving its stable UUID and data. + /// SQLite-only: production deployments never rewrite tenant host mappings. + pub async fn rebind_single_node_community_host( + &self, + normalized_host: &str, + owner_pubkey: &str, + ) -> Result> { + if matches!(&self.backend, DbBackend::SQLite(_)) { + return sqlite::rebind_single_node_community_host( + self.sqlite_pool()?, + normalized_host, + owner_pubkey, + ) + .await; + } + Err(DbError::UnsupportedBackend( + "single-node community rebind requires SQLite", + )) + } + /// Ensure a configured community host exists and return its row. /// /// This is the startup/config seeding path for N=1 deployments. Migrations diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index fb5aacc152..f972caaff4 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -1231,7 +1231,14 @@ async fn run_single_node(config: Config, tracer_init: telemetry::TracerInit) -> "Cannot derive community host from BUZZ_RELAY_URL" )); } - let community = db.ensure_configured_community(&host).await?.id; + let community = if let Some(owner) = config.relay_owner_pubkey.as_deref() { + match db.rebind_single_node_community_host(&host, owner).await? { + Some(record) => record.id, + None => db.ensure_configured_community(&host).await?.id, + } + } else { + db.ensure_configured_community(&host).await?.id + }; if let Some(owner) = config.relay_owner_pubkey.as_deref() { db.bootstrap_owner(community, owner).await?; } diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index dd61fc9398..db9562e8d1 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -18,6 +18,46 @@ use crate::{ util::now_iso, }; +pub(super) fn backfill_missing_agent_auth_tags( + app: &AppHandle, + state: &AppState, +) -> Result { + let owner_keys = state.signing_keys()?; + let compat_owner = nostr::Keys::parse(&owner_keys.secret_key().to_secret_hex()) + .map_err(|e| format!("failed to bridge owner keys: {e}"))?; + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(app)?; + let mut changed = 0; + for record in &mut records { + if record.auth_tag.is_some() + || record + .pubkey + .eq_ignore_ascii_case(&owner_keys.public_key().to_hex()) + { + continue; + } + let agent = nostr::PublicKey::from_hex(&record.pubkey) + .map_err(|e| format!("invalid managed agent pubkey {}: {e}", record.pubkey))?; + record.auth_tag = Some( + buzz_sdk_pkg::nip_oa::compute_auth_tag(&compat_owner, &agent, "").map_err(|e| { + format!( + "failed to compute NIP-OA auth tag for {}: {e}", + record.pubkey + ) + })?, + ); + record.updated_at = now_iso(); + changed += 1; + } + if changed > 0 { + save_managed_agents(app, &records)?; + } + Ok(changed) +} + /// Read the workspace owner pubkey without holding the lock. Used to populate `BUZZ_ACP_AGENT_OWNER` /// as a fallback for legacy agent records that have no NIP-OA `auth_tag`. pub(super) fn workspace_owner_hex(state: &AppState) -> Result { diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index f7b022d04f..32f041cda0 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -145,7 +145,8 @@ pub async fn apply_workspace( None => None, }; - if crate::local_relay::is_local_relay_url(&relay_url) { + let is_local_workspace = crate::local_relay::is_local_relay_url(&relay_url); + if is_local_workspace { // Only the UI-created local sentinel can launch the bundled relay. // An arbitrary loopback URL remains a normal remote community. let keys = parsed_keys.clone().unwrap_or(state.signing_keys()?); @@ -228,6 +229,12 @@ pub async fn apply_workspace( } try_regenerate_nest(&app); + if is_local_workspace { + let changed = crate::commands::agents::backfill_missing_agent_auth_tags(&app, &state)?; + if changed > 0 { + eprintln!("buzz-desktop: backfilled NIP-OA auth tags for {changed} local agent(s)"); + } + } Ok::<(), String>(()) }) diff --git a/desktop/src-tauri/src/local_relay.rs b/desktop/src-tauri/src/local_relay.rs index dc33243f9a..7a70d8155c 100644 --- a/desktop/src-tauri/src/local_relay.rs +++ b/desktop/src-tauri/src/local_relay.rs @@ -53,6 +53,7 @@ impl LocalRelayConfig { ("BUZZ_LOCAL_DB", sqlite_url(&db_path)), ("BUZZ_LOCAL_MEDIA_DIR", media_dir.display().to_string()), ("BUZZ_REQUIRE_RELAY_MEMBERSHIP", "true".to_string()), + ("BUZZ_ALLOW_NIP_OA_AUTH", "true".to_string()), ("RELAY_OWNER_PUBKEY", self.owner_pubkey.clone()), ("BUZZ_RELAY_PRIVATE_KEY", self.relay_private_key.clone()), ] @@ -369,6 +370,7 @@ mod tests { env.get("BUZZ_REQUIRE_RELAY_MEMBERSHIP"), Some(&"true".to_string()) ); + assert_eq!(env.get("BUZZ_ALLOW_NIP_OA_AUTH"), Some(&"true".to_string())); assert_eq!(env.get("RELAY_OWNER_PUBKEY"), Some(&"owner".to_string())); assert_eq!( env.get("BUZZ_RELAY_PRIVATE_KEY"), From 9d95f8758ea1d571fe42a64272256b8142056a02 Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Mon, 3 Aug 2026 09:18:40 -0400 Subject: [PATCH 07/16] Relocate local community creation into create flows Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../communities/ui/AddCommunityDialog.tsx | 58 ++++++++-- .../ui/CommunityCreationChoice.tsx | 105 ++++++++++++++++++ .../features/communities/ui/WelcomeSetup.tsx | 56 +++++++--- .../e2e/add-community-screenshots.spec.ts | 1 + desktop/tests/e2e/onboarding.spec.ts | 21 +++- desktop/tests/e2e/sidebar.spec.ts | 94 ++++++++++++++++ 6 files changed, 307 insertions(+), 28 deletions(-) create mode 100644 desktop/src/features/communities/ui/CommunityCreationChoice.tsx diff --git a/desktop/src/features/communities/ui/AddCommunityDialog.tsx b/desktop/src/features/communities/ui/AddCommunityDialog.tsx index a83456652c..108ba4c357 100644 --- a/desktop/src/features/communities/ui/AddCommunityDialog.tsx +++ b/desktop/src/features/communities/ui/AddCommunityDialog.tsx @@ -2,6 +2,12 @@ import * as React from "react"; import { ArrowLeft, ChevronRight, Link2, Plus } from "lucide-react"; import type { AddCommunityPrefillRequest } from "@/features/communities/addCommunityPrefill"; +import { + LOCAL_COMMUNITY_NAME, + LOCAL_COMMUNITY_RELAY_URL, +} from "@/features/communities/communityStorage"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { CommunityCreationChoice } from "@/features/communities/ui/CommunityCreationChoice"; import { HostedCommunityCreateFlow } from "@/features/communities/ui/HostedCommunityCreateFlow"; import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding"; import { InviteRedeemForm } from "@/features/onboarding/ui/InviteRedeemForm"; @@ -22,7 +28,7 @@ type AddCommunityDialogProps = { onOpenChange: (open: boolean) => void; }; -type AddCommunityMode = "choose" | "create" | "join"; +type AddCommunityMode = "choose" | "create-choose" | "create-hosted" | "join"; const OPTION_CLASS = "flex w-full items-center gap-3 rounded-xl border border-border/70 bg-muted/30 px-4 py-4 text-left transition-colors duration-150 ease-out hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; @@ -33,6 +39,8 @@ export function AddCommunityDialog({ onOpenChange, }: AddCommunityDialogProps) { const communityOnboarding = useCommunityOnboarding(); + const { communities } = useCommunities(); + const hasLocalCommunity = communities.some((community) => community.local); const [mode, setMode] = React.useState("choose"); const [joinError, setJoinError] = React.useState(null); const appliedPrefillId = React.useRef(null); @@ -78,19 +86,38 @@ export function AddCommunityDialog({ [communityOnboarding, handleClose, prefill?.name], ); + const startLocalCommunity = React.useCallback(() => { + const started = communityOnboarding.start({ + source: "add-community", + communityName: LOCAL_COMMUNITY_NAME, + relayUrl: LOCAL_COMMUNITY_RELAY_URL, + }); + if (!started) { + setJoinError( + "Finish connecting the community already in progress, then try again.", + ); + return; + } + handleClose(); + }, [communityOnboarding, handleClose]); + const title = - mode === "create" + mode === "create-choose" ? "Create a new community" - : mode === "join" - ? "Join an existing community" - : "Add community"; + : mode === "create-hosted" + ? "Host a community online" + : mode === "join" + ? "Join an existing community" + : "Add community"; const description = - mode === "create" - ? "Opens Builderlab in your browser." - : mode === "join" - ? "Use the community URL or invite link you received." - : "Create a new community or join one you already have."; + mode === "create-choose" + ? "Choose where your community will live." + : mode === "create-hosted" + ? "Opens Builderlab in your browser." + : mode === "join" + ? "Use the community URL or invite link you received." + : "Create a new community or join one you already have."; return ( {title}
{description} @@ -135,7 +162,7 @@ export function AddCommunityDialog({ + + + + + + ); + } + + return ( +
+ + +
+ ); +} diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx index 21859c653f..e309b27679 100644 --- a/desktop/src/features/communities/ui/WelcomeSetup.tsx +++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx @@ -5,6 +5,7 @@ import { LOCAL_COMMUNITY_NAME, LOCAL_COMMUNITY_RELAY_URL, } from "@/features/communities/communityStorage"; +import { CommunityCreationChoice } from "@/features/communities/ui/CommunityCreationChoice"; import { HostedCommunityOnboarding } from "@/features/communities/ui/HostedCommunityOnboarding"; import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding"; import { InviteRedeemForm } from "@/features/onboarding/ui/InviteRedeemForm"; @@ -25,7 +26,13 @@ import { Button } from "@/shared/ui/button"; import { Card } from "@/shared/ui/card"; import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; -type WelcomeSetupPage = "welcome" | "existing" | "join" | "member" | "owned"; +type WelcomeSetupPage = + | "welcome" + | "create" + | "existing" + | "join" + | "member" + | "owned"; type WelcomeTransitionMode = "initial" | OnboardingTransitionDirection; type WelcomeSetupProps = { @@ -137,19 +144,6 @@ export function WelcomeSetup({

- - - + + ) : page === "existing" ? ( { test("capture: create a new community", async ({ page }) => { await page.getByTestId("add-community-create").click(); + await page.getByTestId("community-create-hosted").click(); const dialog = page.getByTestId("add-community-dialog"); await page.getByLabel("Community address").waitFor(); await waitForAnimations(page); diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 403edbcda1..3f5733b2eb 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -1087,6 +1087,15 @@ test("first-community choices route join, create, owner, and member intents", as await expect( page.getByRole("button", { name: /Create a community/ }), ).toBeVisible(); + await expect(page.getByTestId("community-choice-local")).toHaveCount(0); + await page.getByTestId("community-choice-create").click(); + await expect(page.getByTestId("community-create-hosted")).toContainText( + "Host it online", + ); + await expect(page.getByTestId("community-choice-local")).toContainText( + "Keep it on this device", + ); + await page.getByTestId("community-create-back").click(); const existing = page.getByRole("button", { name: /I already have a community/, }); @@ -1169,6 +1178,7 @@ test("first-community owner can connect an existing hosted community", async ({ await page.goto("/"); await page.getByTestId("community-choice-create").click(); + await page.getByTestId("community-create-hosted").click(); await expect(page.getByText("North Star")).toBeVisible(); await page.getByRole("button", { name: "Connect", exact: true }).click(); await expect( @@ -1227,6 +1237,7 @@ test("first-community owner can create and connect a hosted community", async ({ await page.goto("/"); await page.getByTestId("community-choice-create").click(); + await page.getByTestId("community-create-hosted").click(); await page.getByRole("button", { name: "Sign in to continue" }).click(); await expect( page.getByRole("heading", { name: "Finish connecting Buzz" }), @@ -1303,6 +1314,7 @@ test("hosted community address line stays within the card for a long name", asyn await page.goto("/"); await page.getByTestId("community-choice-create").click(); + await page.getByTestId("community-create-hosted").click(); await page.getByRole("button", { name: "Sign in to continue" }).click(); await expect( page.getByRole("heading", { name: "Finish connecting Buzz" }), @@ -1372,6 +1384,7 @@ test("first-community reports a created community without a relay address", asyn await page.goto("/"); await page.getByTestId("community-choice-create").click(); + await page.getByTestId("community-create-hosted").click(); await page.getByRole("textbox", { name: "Community name" }).fill("bee-lab"); await expect(page.getByText("That address is available.")).toBeVisible(); await page.getByRole("button", { name: "Next" }).click(); @@ -1403,6 +1416,7 @@ test("first-community X cancels a pending sign-in", async ({ page }) => { await page.goto("/"); await page.getByTestId("community-choice-create").click(); + await page.getByTestId("community-create-hosted").click(); await page.getByRole("button", { name: "Sign in to continue" }).click(); await expect(page.getByText("Waiting for your browser…")).toBeVisible(); await expect( @@ -1410,8 +1424,9 @@ test("first-community X cancels a pending sign-in", async ({ page }) => { ).toHaveCount(0); await page.getByRole("button", { name: "Close" }).click(); await expect( - page.getByRole("button", { name: /Create a community/ }), + page.getByRole("heading", { name: "Create a community" }), ).toBeVisible(); + await expect(page.getByTestId("community-create-hosted")).toBeVisible(); await expect .poll(() => page.evaluate(() => window.__BUZZ_E2E_COMMANDS__ ?? [])) .toEqual(expect.arrayContaining(["cancel_builderlab_login"])); @@ -1445,6 +1460,7 @@ test("first-community owner can replace a mismatched account identity", async ({ await page.goto("/"); await page.getByTestId("community-choice-create").click(); + await page.getByTestId("community-create-hosted").click(); await expect( page.getByRole("heading", { name: "This account uses a different Buzz identity", @@ -1495,6 +1511,7 @@ test("first-community explains when the local identity belongs to another accoun await page.goto("/"); await page.getByTestId("community-choice-create").click(); + await page.getByTestId("community-create-hosted").click(); await page .getByRole("button", { name: "Use this device's identity" }) .click(); @@ -1536,8 +1553,10 @@ test("back clears Builderlab auth before returning to first-community choices", await page.goto("/"); await page.getByTestId("community-choice-create").click(); + await page.getByTestId("community-create-hosted").click(); await page.getByRole("button", { name: "Back" }).click(); await page.getByTestId("community-choice-create").click(); + await page.getByTestId("community-create-hosted").click(); await expect(page.getByRole("button", { name: "Continue" })).toBeVisible(); }); diff --git a/desktop/tests/e2e/sidebar.spec.ts b/desktop/tests/e2e/sidebar.spec.ts index 9dce5e7598..be97ba4e01 100644 --- a/desktop/tests/e2e/sidebar.spec.ts +++ b/desktop/tests/e2e/sidebar.spec.ts @@ -103,11 +103,105 @@ test("add community starts with create and join choices", async ({ page }) => { await expect( page.getByRole("heading", { name: "Create a new community" }), ).toBeVisible(); + await expect(page.getByTestId("community-create-hosted")).toContainText( + "Host it online", + ); + await expect(page.getByTestId("community-choice-local")).toContainText( + "Keep it on this device", + ); + await page.getByTestId("community-create-hosted").click(); await page.getByRole("button", { name: "Continue to Builderlab" }).click(); await page.getByRole("button", { name: "Connect and continue" }).click(); await expect(page.getByLabel("Community address")).toBeVisible(); }); +test("add community can start an on-this-device community", async ({ + page, +}) => { + await installMockBridge(page, { applyCommunityDelayMs: 1_000 }); + await page.goto("/"); + + await openAddCommunityDialog(page); + await page.getByTestId("add-community-create").click(); + await page.getByTestId("community-choice-local").click(); + + await expect + .poll(() => + page.evaluate((key) => { + const raw = window.localStorage.getItem(key); + if (!raw) return null; + const transaction = JSON.parse(raw) as { + source?: string; + communityName?: string; + relayUrl?: string; + }; + return { + source: transaction.source, + communityName: transaction.communityName, + relayUrl: transaction.relayUrl, + }; + }, COMMUNITY_ONBOARDING_STORAGE_KEY), + ) + .toEqual({ + source: "add-community", + communityName: "On this device", + relayUrl: "buzz-local://on-this-device", + }); +}); + +test("create choice offers to open an existing on-this-device community", async ({ + page, +}) => { + await page.addInitScript(() => { + const communities = [ + { + id: "hosted", + name: "Hosted", + relayUrl: "wss://hosted.example.com", + addedAt: "2026-01-01T00:00:00.000Z", + }, + { + id: "local", + name: "On this device", + relayUrl: "buzz-local://on-this-device", + local: true, + addedAt: "2026-01-02T00:00:00.000Z", + }, + ]; + window.localStorage.setItem( + "buzz-communities", + JSON.stringify(communities), + ); + window.localStorage.setItem("buzz-active-community-id", "hosted"); + }); + await installMockBridge( + page, + { applyCommunityDelayMs: 1_000 }, + { skipCommunitySeed: true }, + ); + await page.goto("/"); + + await openAddCommunityDialog(page); + await page.getByTestId("add-community-create").click(); + await expect(page.getByTestId("community-choice-local")).toContainText( + "Open your on-this-device community", + ); + await page.getByTestId("community-choice-local").click(); + await expect + .poll(() => + page.evaluate(() => localStorage.getItem("buzz-active-community-id")), + ) + .toBe("local"); + await expect + .poll(() => + page.evaluate(() => { + const raw = localStorage.getItem("buzz-communities"); + return raw ? JSON.parse(raw).length : 0; + }), + ) + .toBe(2); +}); + test("automatically shows community join requirements near the community URL", async ({ page, }) => { From a9898cd973c8a276515dd65dadd7b21afcc89d26 Mon Sep 17 00:00:00 2001 From: npub1z3hmzc9ryehxzedl5wzlvpyvja0d483peaja5zt6pd0209f9x2jspe2dxh <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Date: Mon, 3 Aug 2026 14:03:15 -0400 Subject: [PATCH 08/16] feat(experiments): gate local communities Signed-off-by: npub1z3hmzc9ryehxzedl5wzlvpyvja0d483peaja5zt6pd0209f9x2jspe2dxh <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> --- .../communities/ui/AddCommunityDialog.tsx | 3 + .../ui/CommunityCreationChoice.tsx | 64 ++++++++++--------- .../features/communities/ui/WelcomeSetup.tsx | 3 + desktop/tests/e2e/onboarding.spec.ts | 60 +++++++++++++++++ desktop/tests/e2e/sidebar.spec.ts | 50 ++++++++++++++- preview-features.json | 6 ++ 6 files changed, 155 insertions(+), 31 deletions(-) diff --git a/desktop/src/features/communities/ui/AddCommunityDialog.tsx b/desktop/src/features/communities/ui/AddCommunityDialog.tsx index 108ba4c357..b636aaf31c 100644 --- a/desktop/src/features/communities/ui/AddCommunityDialog.tsx +++ b/desktop/src/features/communities/ui/AddCommunityDialog.tsx @@ -11,6 +11,7 @@ import { CommunityCreationChoice } from "@/features/communities/ui/CommunityCrea import { HostedCommunityCreateFlow } from "@/features/communities/ui/HostedCommunityCreateFlow"; import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding"; import { InviteRedeemForm } from "@/features/onboarding/ui/InviteRedeemForm"; +import { useFeatureEnabled } from "@/shared/features"; import { Dialog, DialogContent, @@ -39,6 +40,7 @@ export function AddCommunityDialog({ onOpenChange, }: AddCommunityDialogProps) { const communityOnboarding = useCommunityOnboarding(); + const localCommunitiesEnabled = useFeatureEnabled("localCommunities"); const { communities } = useCommunities(); const hasLocalCommunity = communities.some((community) => community.local); const [mode, setMode] = React.useState("choose"); @@ -220,6 +222,7 @@ export function AddCommunityDialog({ hasLocalCommunity={hasLocalCommunity} onChooseHosted={() => setMode("create-hosted")} onChooseLocal={startLocalCommunity} + showLocalOption={localCommunitiesEnabled} variant="dialog" /> ) : ( diff --git a/desktop/src/features/communities/ui/CommunityCreationChoice.tsx b/desktop/src/features/communities/ui/CommunityCreationChoice.tsx index 307db69580..e640c398ed 100644 --- a/desktop/src/features/communities/ui/CommunityCreationChoice.tsx +++ b/desktop/src/features/communities/ui/CommunityCreationChoice.tsx @@ -16,6 +16,7 @@ type CommunityCreationChoiceProps = { hasLocalCommunity?: boolean; onChooseHosted: () => void; onChooseLocal: () => void; + showLocalOption: boolean; variant: "onboarding" | "dialog"; }; @@ -23,6 +24,7 @@ export function CommunityCreationChoice({ hasLocalCommunity = false, onChooseHosted, onChooseLocal, + showLocalOption, variant, }: CommunityCreationChoiceProps) { const localLabel = hasLocalCommunity @@ -44,18 +46,20 @@ export function CommunityCreationChoice({ - - - + {showLocalOption ? ( + + + + ) : null}
); } @@ -81,25 +85,27 @@ export function CommunityCreationChoice({ - + + + ) : null} ); } diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx index e309b27679..3b00fa823f 100644 --- a/desktop/src/features/communities/ui/WelcomeSetup.tsx +++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx @@ -19,6 +19,7 @@ import { OnboardingSlideTransition, } from "@/features/onboarding/ui/OnboardingSlideTransition"; import { useIdentityQuery } from "@/shared/api/hooks"; +import { useFeatureEnabled } from "@/shared/features"; import { writeTextToClipboard } from "@/shared/lib/clipboard"; import { pubkeyToNpub } from "@/shared/lib/nostrUtils"; import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme"; @@ -58,6 +59,7 @@ export function WelcomeSetup({ const [isHostedSignInOpen, setIsHostedSignInOpen] = React.useState(false); const [copiedNpub, setCopiedNpub] = React.useState(false); const communityOnboarding = useCommunityOnboarding(); + const localCommunitiesEnabled = useFeatureEnabled("localCommunities"); const identityQuery = useIdentityQuery(); const systemColorScheme = useSystemColorScheme(); const npub = identityQuery.data?.pubkey @@ -215,6 +217,7 @@ export function WelcomeSetup({ setIsHostedSignInOpen(true)} onChooseLocal={startLocalCommunity} + showLocalOption={localCommunitiesEnabled} variant="onboarding" /> diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 3f5733b2eb..d652675c02 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -3,6 +3,7 @@ import { expect, test, type Page } from "@playwright/test"; import { nsecEncode } from "nostr-tools/nip19"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; +import { FEATURE_OVERRIDES_STORAGE_KEY } from "../helpers/features"; import { installFakeCamera } from "../helpers/fakeCamera"; import { E2E_IDENTITY_OVERRIDE_STORAGE_KEY, @@ -80,6 +81,18 @@ const FIRST_RUN_ALICE = { username: "", }; +async function setLocalCommunitiesFeature(page: Page, enabled: boolean) { + await page.addInitScript( + ({ key, enabled }) => { + window.localStorage.setItem( + key, + JSON.stringify({ localCommunities: enabled }), + ); + }, + { key: FEATURE_OVERRIDES_STORAGE_KEY, enabled }, + ); +} + async function seedOnboardingCompletion(page: Page, pubkey: string) { await page.addInitScript( ({ storageKey }) => { @@ -1064,6 +1077,53 @@ test("non-local default auto-connects when the release flag is enabled", async ( }); }); +test("first-community hides the local choice by default", async ({ page }) => { + await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); + await page.addInitScript((pubkey) => { + window.localStorage.setItem( + `buzz-machine-onboarding-complete.v2:${pubkey}`, + "true", + ); + }, BLANK_TYLER_IDENTITY.pubkey); + await installMockBridge(page, undefined, { + relayWsUrl: "ws://localhost:3000", + seedPreviewFeatures: false, + skipOnboardingSeed: true, + skipCommunitySeed: true, + }); + await setLocalCommunitiesFeature(page, false); + await page.goto("/"); + + await page.getByTestId("community-choice-create").click(); + await expect(page.getByTestId("community-create-hosted")).toBeVisible(); + await expect(page.getByTestId("community-choice-local")).toHaveCount(0); +}); + +test("first-community shows the local choice when its preview is enabled", async ({ + page, +}) => { + await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); + await setLocalCommunitiesFeature(page, true); + await page.addInitScript((pubkey) => { + window.localStorage.setItem( + `buzz-machine-onboarding-complete.v2:${pubkey}`, + "true", + ); + }, BLANK_TYLER_IDENTITY.pubkey); + await installMockBridge(page, undefined, { + relayWsUrl: "ws://localhost:3000", + seedPreviewFeatures: false, + skipOnboardingSeed: true, + skipCommunitySeed: true, + }); + await page.goto("/"); + + await page.getByTestId("community-choice-create").click(); + await expect(page.getByTestId("community-choice-local")).toContainText( + "Keep it on this device", + ); +}); + test("first-community choices route join, create, owner, and member intents", async ({ page, }) => { diff --git a/desktop/tests/e2e/sidebar.spec.ts b/desktop/tests/e2e/sidebar.spec.ts index be97ba4e01..b11ef5ed59 100644 --- a/desktop/tests/e2e/sidebar.spec.ts +++ b/desktop/tests/e2e/sidebar.spec.ts @@ -1,6 +1,7 @@ import { expect, test, type Page } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; +import { FEATURE_OVERRIDES_STORAGE_KEY } from "../helpers/features"; import { openSettings } from "../helpers/settings"; const SIDEBAR_WIDTH_STORAGE_KEY = "buzz-sidebar-width"; @@ -39,6 +40,18 @@ async function openAddCommunityDialog(page: Page) { await page.getByRole("menuitem", { name: "Add a community" }).click(); } +async function setLocalCommunitiesFeature(page: Page, enabled: boolean) { + await page.addInitScript( + ({ key, enabled }) => { + window.localStorage.setItem( + key, + JSON.stringify({ localCommunities: enabled }), + ); + }, + { key: FEATURE_OVERRIDES_STORAGE_KEY, enabled }, + ); +} + // Regression guard for the "Leave channel" lockup: with two bundled copies of // @radix-ui/react-dismissable-layer, opening a modal AlertDialog from a modal // Radix ContextMenu left `pointer-events: none` stuck on after the @@ -74,6 +87,33 @@ async function dragSidebarRail(page: Page, deltaX: number) { await page.mouse.up(); } +test("add community hides the local choice by default", async ({ page }) => { + await installMockBridge(page, {}, { seedPreviewFeatures: false }); + await setLocalCommunitiesFeature(page, false); + await page.goto("/"); + + await openAddCommunityDialog(page); + await page.getByTestId("add-community-create").click(); + + await expect(page.getByTestId("community-create-hosted")).toBeVisible(); + await expect(page.getByTestId("community-choice-local")).toHaveCount(0); +}); + +test("add community shows the local choice when its preview is enabled", async ({ + page, +}) => { + await setLocalCommunitiesFeature(page, true); + await installMockBridge(page, {}, { seedPreviewFeatures: false }); + await page.goto("/"); + + await openAddCommunityDialog(page); + await page.getByTestId("add-community-create").click(); + + await expect(page.getByTestId("community-choice-local")).toContainText( + "Keep it on this device", + ); +}); + test("add community starts with create and join choices", async ({ page }) => { await installMockBridge(page, {}); await page.goto("/"); @@ -118,7 +158,12 @@ test("add community starts with create and join choices", async ({ page }) => { test("add community can start an on-this-device community", async ({ page, }) => { - await installMockBridge(page, { applyCommunityDelayMs: 1_000 }); + await setLocalCommunitiesFeature(page, true); + await installMockBridge( + page, + { applyCommunityDelayMs: 1_000 }, + { seedPreviewFeatures: false }, + ); await page.goto("/"); await openAddCommunityDialog(page); @@ -174,10 +219,11 @@ test("create choice offers to open an existing on-this-device community", async ); window.localStorage.setItem("buzz-active-community-id", "hosted"); }); + await setLocalCommunitiesFeature(page, true); await installMockBridge( page, { applyCommunityDelayMs: 1_000 }, - { skipCommunitySeed: true }, + { seedPreviewFeatures: false, skipCommunitySeed: true }, ); await page.goto("/"); diff --git a/preview-features.json b/preview-features.json index 388f1c39b0..de991d93cd 100644 --- a/preview-features.json +++ b/preview-features.json @@ -30,6 +30,12 @@ "name": "Agent-managed profiles", "description": "Let agents manage their own relay name and avatar instead of restoring the desktop copy", "platforms": ["desktop"] + }, + { + "id": "localCommunities", + "name": "Local communities", + "description": "Create a community that lives entirely on this device (local relay)", + "platforms": ["desktop"] } ] } From 4ae8bf88f2a788737b25f1e58646ccb9bc963ce1 Mon Sep 17 00:00:00 2001 From: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Date: Mon, 10 Aug 2026 15:33:36 -0400 Subject: [PATCH 09/16] feat(db): enable SQLite community persistence Signed-off-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> --- Cargo.lock | 1 + Cargo.toml | 2 +- crates/buzz-db/src/sqlite.rs | 79 ++++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index f2c5f0050d..13c9a631eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4549,6 +4549,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ + "cc", "pkg-config", "vcpkg", ] diff --git a/Cargo.toml b/Cargo.toml index be9cb5aa7b..69b6e1aad0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,7 +53,7 @@ tower-http = { version = "0.6", features = ["trace", "cors", "compression-gzip" # Database sqlx = { version = "0.9", features = [ - "runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json" + "runtime-tokio", "tls-rustls", "postgres", "sqlite", "uuid", "chrono", "json" ] } # Redis diff --git a/crates/buzz-db/src/sqlite.rs b/crates/buzz-db/src/sqlite.rs index 922657ad11..c3e4dfdc8f 100644 --- a/crates/buzz-db/src/sqlite.rs +++ b/crates/buzz-db/src/sqlite.rs @@ -265,6 +265,85 @@ pub(crate) async fn is_community_active( Ok(count != 0) } +pub(crate) async fn rebind_single_node_community_host( + pool: &SqlitePool, + normalized_host: &str, + owner_pubkey: &str, +) -> Result> { + let mut tx = pool.begin().await?; + + // Defect-era databases may contain several loopback communities, one per + // ephemeral port. Prefer a community owned by this desktop identity, then + // the one carrying the most events. This preserves the most useful UUID and + // its channels/messages while making the current Host header resolvable. + let row = sqlx::query( + r#"SELECT c.id, c.host + FROM communities c + LEFT JOIN relay_members rm + ON rm.community_id = c.id + AND lower(rm.pubkey) = lower(?1) + AND rm.role = 'owner' + LEFT JOIN events e ON e.community_id = c.id + WHERE c.archived_at IS NULL + AND c.host LIKE '127.0.0.1:%' + GROUP BY c.id, c.host, c.created_at + ORDER BY (rm.pubkey IS NOT NULL) DESC, count(e.id) DESC, c.created_at ASC, c.id ASC + LIMIT 1"#, + ) + .bind(owner_pubkey) + .fetch_optional(&mut *tx) + .await?; + let Some(row) = row else { + tx.commit().await?; + return Ok(None); + }; + let record = community_record(row)?; + if record.host.eq_ignore_ascii_case(normalized_host) { + tx.commit().await?; + return Ok(Some(record)); + } + + // The OS may reuse a port that belongs to another stale defect-era row. + // Swap that collision to the selected row's old authority transactionally, + // using a temporary unique host to satisfy the case-insensitive constraint. + if let Some(collision_id) = sqlx::query_scalar::<_, String>( + "SELECT id FROM communities WHERE host = ?1 COLLATE NOCASE AND id <> ?2", + ) + .bind(normalized_host) + .bind(record.id.as_uuid().to_string()) + .fetch_optional(&mut *tx) + .await? + { + let temporary_host = format!("rebind-{}.invalid", Uuid::new_v4()); + sqlx::query("UPDATE communities SET host = ?2 WHERE id = ?1") + .bind(&collision_id) + .bind(&temporary_host) + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE communities SET host = ?2 WHERE id = ?1") + .bind(record.id.as_uuid().to_string()) + .bind(normalized_host) + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE communities SET host = ?2 WHERE id = ?1") + .bind(collision_id) + .bind(&record.host) + .execute(&mut *tx) + .await?; + } else { + sqlx::query("UPDATE communities SET host = ?2 WHERE id = ?1") + .bind(record.id.as_uuid().to_string()) + .bind(normalized_host) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(Some(CommunityRecord { + id: record.id, + host: normalized_host.to_string(), + })) +} + pub(crate) async fn ensure_configured_community( pool: &SqlitePool, normalized_host: &str, From ceb2b026b62887bfc83fc3c07b126bd5fa995505 Mon Sep 17 00:00:00 2001 From: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Date: Mon, 10 Aug 2026 15:40:22 -0400 Subject: [PATCH 10/16] refactor(db): narrow SQLite community foundation Signed-off-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> --- crates/buzz-db/src/lib.rs | 935 ++++++------------ crates/buzz-db/src/sqlite.rs | 858 +--------------- crates/buzz-relay/src/api/bridge.rs | 8 +- .../src/handlers/command_executor.rs | 67 +- crates/buzz-relay/src/handlers/event.rs | 3 +- .../buzz-relay/src/handlers/side_effects.rs | 14 - 6 files changed, 362 insertions(+), 1523 deletions(-) diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index cf4aaf94e7..6de335aa42 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -180,7 +180,6 @@ enum DbBackend { #[derive(Clone, Debug)] pub struct Db { backend: DbBackend, - // Retained for Postgres test helpers. All production method access goes through `pg_pool`. pub(crate) pool: PgPool, /// Maximum connections configured for this pool (from [`DbConfig::max_connections`]). pub(crate) max_connections: u32, @@ -649,17 +648,6 @@ pub struct TokenSummary { } impl Db { - /// Returns the PostgreSQL pool or a typed error for local SQLite handles. - fn pg_pool(&self) -> Result<&PgPool> { - match self.backend { - DbBackend::Postgres => Ok(&self.pool), - DbBackend::SQLite(_) => Err(DbError::UnsupportedBackend( - "PostgreSQL operation on SQLite Db", - )), - } - } - - /// Returns the SQLite pool or a typed error for PostgreSQL handles. fn sqlite_pool(&self) -> Result<&sqlx::SqlitePool> { match &self.backend { DbBackend::SQLite(pool) => Ok(pool), @@ -670,8 +658,6 @@ impl Db { } /// Creates a local single-node database backed by SQLite. - /// - /// The embedded local schema is applied before this constructor returns. pub async fn new_sqlite(database_url: &str) -> Result { let sqlite = sqlite::connect(database_url).await?; let pool = PgPoolOptions::new() @@ -688,7 +674,6 @@ impl Db { reader_aurora_identity: std::sync::Arc::new(std::sync::OnceLock::new()), }) } - /// Creates a new `Db` by connecting a Postgres pool with the given config. /// /// When `config.read_database_url` is set, a second pool with the same @@ -887,10 +872,10 @@ impl Db { if self.read_pool.is_none() { return Ok(false); } - replica_fence::verify_floor_guard_catalog(self.pg_pool()?).await?; - replica_fence::verify_floor_guard_behavior(self.pg_pool()?).await?; + replica_fence::verify_floor_guard_catalog(&self.pool).await?; + replica_fence::verify_floor_guard_behavior(&self.pool).await?; tokio::spawn(replica_fence::run_probe( - self.pg_pool()?.clone(), + self.pool.clone(), std::sync::Arc::clone(&self.fence), )); Ok(true) @@ -905,8 +890,8 @@ impl Db { /// reads must go through [`Db::route_read`]-backed entry points; this /// remains only for the fence's own plumbing tests. #[cfg(test)] - fn read(&self) -> Result<&PgPool> { - Ok(self.read_pool.as_ref().unwrap_or(self.pg_pool()?)) + fn read(&self) -> &PgPool { + self.read_pool.as_ref().unwrap_or(&self.pool) } /// Whether a distinct read-replica pool is configured. @@ -1064,7 +1049,7 @@ impl Db { /// Run pending database migrations. pub async fn migrate(&self) -> Result<()> { match &self.backend { - DbBackend::Postgres => migration::run_migrations(self.pg_pool()?).await, + DbBackend::Postgres => migration::run_migrations(&self.pool).await, DbBackend::SQLite(_) => sqlite::migrate(self.sqlite_pool()?).await, } } @@ -1083,19 +1068,9 @@ impl Db { /// `idle` — connections available for immediate reuse /// `max` — pool ceiling set at construction pub fn pool_stats(&self) -> DbPoolStats { - let Some(pool) = (match &self.backend { - DbBackend::Postgres => Some(&self.pool), - DbBackend::SQLite(_) => None, - }) else { - return DbPoolStats { - size: 0, - idle: 0, - max: 0, - }; - }; DbPoolStats { - size: pool.size(), - idle: pool.num_idle() as u32, + size: self.pool.size(), + idle: self.pool.num_idle() as u32, max: self.max_connections, } } @@ -1126,7 +1101,7 @@ impl Db { &self, lock_key: i64, ) -> Result> { - let mut connection = self.pg_pool()?.acquire().await?; + let mut connection = self.pool.acquire().await?; let acquired = sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)") .bind(lock_key) .fetch_one(&mut *connection) @@ -1154,7 +1129,7 @@ impl Db { limit: i64, ) -> Result> { admin_moderation::list_reports( - self.pg_pool()?, + &self.pool, community_id, status, report_type, @@ -1172,7 +1147,7 @@ impl Db { &self, id: Uuid, ) -> Result> { - admin_moderation::get_report(self.pg_pool()?, id).await + admin_moderation::get_report(&self.pool, id).await } /// List feedback for the deployment-global read-only admin plane. @@ -1180,7 +1155,7 @@ impl Db { &self, limit: i64, ) -> Result> { - admin_moderation::list_feedback(self.pg_pool()?, limit).await + admin_moderation::list_feedback(&self.pool, limit).await } /// Fetch one feedback submission for the deployment-global admin plane. @@ -1188,42 +1163,42 @@ impl Db { &self, id: Uuid, ) -> Result> { - admin_moderation::get_feedback(self.pg_pool()?, id).await + admin_moderation::get_feedback(&self.pool, id).await } /// Return total number of communities on this relay. pub async fn usage_community_count(&self) -> Result { - usage::community_count(self.pg_pool()?).await + usage::community_count(&self.pool).await } /// Return per-community user counts split by human/agent. pub async fn usage_user_counts(&self) -> Result> { - usage::user_counts(self.pg_pool()?).await + usage::user_counts(&self.pool).await } /// Return per-community channel counts by type. pub async fn usage_channel_counts(&self) -> Result> { - usage::channel_counts(self.pg_pool()?).await + usage::channel_counts(&self.pool).await } /// Return per-community kind=9 message counts. pub async fn usage_message_counts(&self) -> Result> { - usage::message_counts(self.pg_pool()?).await + usage::message_counts(&self.pool).await } /// Return per-community relay-member counts by role. pub async fn usage_relay_member_counts(&self) -> Result> { - usage::relay_member_counts(self.pg_pool()?).await + usage::relay_member_counts(&self.pool).await } /// Return per-community workflow counts by status. pub async fn usage_workflow_counts(&self) -> Result> { - usage::workflow_counts(self.pg_pool()?).await + usage::workflow_counts(&self.pool).await } /// Return per-community git-repo counts. pub async fn usage_git_repo_counts(&self) -> Result> { - usage::git_repo_counts(self.pg_pool()?).await + usage::git_repo_counts(&self.pool).await } /// Return per-community distinct active-user counts for a given SQL interval. @@ -1233,7 +1208,7 @@ impl Db { &self, interval_sql: &'static str, ) -> Result> { - usage::active_user_counts(self.pg_pool()?, interval_sql).await + usage::active_user_counts(&self.pool, interval_sql).await } /// Return per-community active-channel counts for a given SQL interval. @@ -1241,12 +1216,12 @@ impl Db { &self, interval_sql: &'static str, ) -> Result> { - usage::active_channel_counts(self.pg_pool()?, interval_sql).await + usage::active_channel_counts(&self.pool, interval_sql).await } /// Return all community id → host mappings. pub async fn usage_community_hosts(&self) -> Result> { - usage::community_hosts(self.pg_pool()?).await + usage::community_hosts(&self.pool).await } /// Begin a database transaction for atomic multi-statement operations. @@ -1254,7 +1229,7 @@ impl Db { /// Returns a `'static` transaction because `PgPool` is `Arc`-backed internally. /// The transaction holds an owned pool handle, not a borrow. pub async fn begin_transaction(&self) -> Result> { - self.pg_pool()?.begin().await.map_err(Into::into) + self.pool.begin().await.map_err(Into::into) } /// Returns the community mapped to a normalized request host, if one exists. @@ -1277,7 +1252,7 @@ impl Db { "#, ) .bind(normalized_host) - .fetch_optional(self.pg_pool()?) + .fetch_optional(&self.pool) .await?; row.map(|row| { @@ -1301,7 +1276,7 @@ impl Db { "SELECT EXISTS(SELECT 1 FROM communities WHERE id = $1 AND archived_at IS NULL)", ) .bind(community_id.as_uuid()) - .fetch_one(self.pg_pool()?) + .fetch_one(&self.pool) .await?; Ok(active) } @@ -1313,7 +1288,7 @@ impl Db { ) -> Result> { let row = sqlx::query("SELECT id, host FROM communities WHERE lower(host) = lower($1)") .bind(normalized_host) - .fetch_optional(self.pg_pool()?) + .fetch_optional(&self.pool) .await?; row.map(|row| { Ok(CommunityRecord { @@ -1344,7 +1319,7 @@ impl Db { "#, ) .bind(owner_pubkey) - .fetch_all(self.pg_pool()?) + .fetch_all(&self.pool) .await?; rows.into_iter() @@ -1386,7 +1361,7 @@ impl Db { "#, ) .bind(community_id.as_uuid()) - .fetch_optional(self.pg_pool()?) + .fetch_optional(&self.pool) .await?; row.map(|row| { @@ -1396,6 +1371,22 @@ impl Db { .transpose() } + /// Rebinds a stale loopback community to the current single-node authority. + pub async fn rebind_single_node_community_host( + &self, + normalized_host: &str, + owner_pubkey: &str, + ) -> Result> { + match &self.backend { + DbBackend::SQLite(pool) => { + sqlite::rebind_single_node_community_host(pool, normalized_host, owner_pubkey).await + } + DbBackend::Postgres => Err(DbError::UnsupportedBackend( + "single-node community rebind on PostgreSQL Db", + )), + } + } + /// Returns the community's workspace icon (NIP-11 `icon`), if set. /// /// Set by relay admins/owners via the kind:9033 command; the value is @@ -1409,7 +1400,7 @@ impl Db { "#, ) .bind(community_id.as_uuid()) - .fetch_optional(self.pg_pool()?) + .fetch_optional(&self.pool) .await?; Ok(row @@ -1434,32 +1425,11 @@ impl Db { ) .bind(community_id.as_uuid()) .bind(icon) - .execute(self.pg_pool()?) + .execute(&self.pool) .await?; Ok(()) } - /// Rebind the durable loopback community to this single-node process's - /// current ephemeral authority while preserving its stable UUID and data. - /// SQLite-only: production deployments never rewrite tenant host mappings. - pub async fn rebind_single_node_community_host( - &self, - normalized_host: &str, - owner_pubkey: &str, - ) -> Result> { - if matches!(&self.backend, DbBackend::SQLite(_)) { - return sqlite::rebind_single_node_community_host( - self.sqlite_pool()?, - normalized_host, - owner_pubkey, - ) - .await; - } - Err(DbError::UnsupportedBackend( - "single-node community rebind requires SQLite", - )) - } - /// Ensure a configured community host exists and return its row. /// /// This is the startup/config seeding path for N=1 deployments. Migrations @@ -1481,7 +1451,7 @@ impl Db { "#, ) .bind(normalized_host) - .fetch_one(self.pg_pool()?) + .fetch_one(&self.pool) .await?; let id: Uuid = row.try_get("id")?; @@ -1506,7 +1476,7 @@ impl Db { owner_pubkey: &str, ) -> Result { let owner_pubkey = owner_pubkey.to_ascii_lowercase(); - let mut tx = self.pg_pool()?.begin().await?; + let mut tx = self.pool.begin().await?; // Serialize on the owner pubkey so concurrent creates to the same // owner cannot both pass the ownership count check. @@ -1605,7 +1575,7 @@ impl Db { .bind(normalized_host) .bind(owner_pubkey) .bind(protected_deployment_host) - .fetch_optional(self.pg_pool()?) + .fetch_optional(&self.pool) .await?; row.map(|row| { Ok(ArchivedCommunityRecord { @@ -1635,7 +1605,7 @@ impl Db { ) .bind(normalized_host) .bind(owner_pubkey) - .fetch_optional(self.pg_pool()?) + .fetch_optional(&self.pool) .await?; row.map(|row| { Ok(UnarchivedCommunityRecord { @@ -1660,7 +1630,7 @@ impl Db { "#, ) .bind(channel_id) - .fetch_optional(self.pg_pool()?) + .fetch_optional(&self.pool) .await?; row.map(|row| { @@ -1695,30 +1665,25 @@ impl Db { if channel_ids.is_empty() { return Ok(std::collections::HashMap::new()); } - match &self.backend { - DbBackend::SQLite(pool) => sqlite::communities_of_channels(pool, channel_ids).await, - DbBackend::Postgres => { - let rows = sqlx::query( - r#" + let rows = sqlx::query( + r#" SELECT id, community_id FROM channels WHERE id = ANY($1) AND deleted_at IS NULL "#, - ) - .bind(channel_ids) - .fetch_all(self.pg_pool()?) - .await?; + ) + .bind(channel_ids) + .fetch_all(&self.pool) + .await?; - let mut out = std::collections::HashMap::with_capacity(rows.len()); - for row in rows { - let ch: Uuid = row.try_get("id")?; - let cm: Uuid = row.try_get("community_id")?; - out.insert(ch, CommunityId::from_uuid(cm)); - } - Ok(out) - } + let mut out = std::collections::HashMap::with_capacity(rows.len()); + for row in rows { + let ch: Uuid = row.try_get("id")?; + let cm: Uuid = row.try_get("community_id")?; + out.insert(ch, CommunityId::from_uuid(cm)); } + Ok(out) } /// Inserts an event. Returns `(StoredEvent, was_inserted)` — `false` on duplicate. @@ -1728,13 +1693,9 @@ impl Db { event: &nostr::Event, channel_id: Option, ) -> Result<(StoredEvent, bool)> { - if let DbBackend::SQLite(pool) = &self.backend { - return sqlite::insert_event(pool, community_id, event, channel_id).await; - } - let result = event::insert_event(self.pg_pool()?, community_id, event, channel_id).await?; + let result = event::insert_event(&self.pool, community_id, event, channel_id).await?; if result.1 { - if let Err(e) = insert_mentions(self.pg_pool()?, community_id, event, channel_id).await - { + if let Err(e) = insert_mentions(&self.pool, community_id, event, channel_id).await { tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); } } @@ -1749,10 +1710,7 @@ impl Db { /// [`Db::query_events_routed`] instead — converting a caller is an /// explicit, per-callsite decision, never a change to this method. pub async fn query_events(&self, q: &EventQuery) -> Result> { - match &self.backend { - DbBackend::SQLite(pool) => sqlite::query_events(pool, q).await, - DbBackend::Postgres => event::query_events(self.pg_pool()?, q).await, - } + event::query_events(&self.pool, q).await } /// [`Db::query_events`] with replica routing — the opt-in fast path for @@ -1776,9 +1734,6 @@ impl Db { path: &'static str, q: &EventQuery, ) -> Result> { - if let DbBackend::SQLite(pool) = &self.backend { - return sqlite::query_events(pool, q).await; - } let predicate = RoutePredicate::for_query(q, self.replica_read_max_age.is_some()); match self.route_read(path, predicate).await { RouteDecision::Replica(mut tx, _entry, reason) => { @@ -1792,11 +1747,11 @@ impl Db { // writer rather than surfacing a routed error. tracing::warn!(path, "replica read failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); - event::query_events(self.pg_pool()?, q).await + event::query_events(&self.pool, q).await } } } - RouteDecision::Writer => event::query_events(self.pg_pool()?, q).await, + RouteDecision::Writer => event::query_events(&self.pool, q).await, } } @@ -1813,9 +1768,6 @@ impl Db { path: &'static str, q: &EventQuery, ) -> Result> { - if let DbBackend::SQLite(pool) = &self.backend { - return sqlite::query_events(pool, q).await; - } match self.route_read(path, RoutePredicate::Bounded).await { RouteDecision::Replica(mut tx, _entry, reason) => { match event::query_events_on(&mut tx, q).await { @@ -1826,11 +1778,11 @@ impl Db { Err(e) => { tracing::warn!(path, "replica read failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); - event::query_events(self.pg_pool()?, q).await + event::query_events(&self.pool, q).await } } } - RouteDecision::Writer => event::query_events(self.pg_pool()?, q).await, + RouteDecision::Writer => event::query_events(&self.pool, q).await, } } @@ -1839,7 +1791,7 @@ impl Db { /// Always reads from the WRITER pool — see [`Db::query_events`] for the /// writer-vs-routed rule. pub async fn count_events(&self, q: &EventQuery) -> Result { - event::count_events(self.pg_pool()?, q).await + event::count_events(&self.pool, q).await } /// [`Db::count_events`] with replica routing — same contract, rules, @@ -1863,11 +1815,11 @@ impl Db { Err(e) => { tracing::warn!(path, "replica count failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); - event::count_events(self.pg_pool()?, q).await + event::count_events(&self.pool, q).await } } } - RouteDecision::Writer => event::count_events(self.pg_pool()?, q).await, + RouteDecision::Writer => event::count_events(&self.pool, q).await, } } @@ -1881,7 +1833,7 @@ impl Db { creator_pubkey: &[u8], ) -> Result { event::huddle_started_link_exists( - self.pg_pool()?, + &self.pool, community_id, parent_channel_id, ephemeral_channel_id, @@ -1901,8 +1853,7 @@ impl Db { kind: i32, pubkey_bytes: &[u8], ) -> Result> { - event::get_latest_global_replaceable(self.pg_pool()?, community_id, kind, pubkey_bytes) - .await + event::get_latest_global_replaceable(&self.pool, community_id, kind, pubkey_bytes).await } /// Fetches a single non-deleted event by its raw ID bytes. @@ -1913,12 +1864,7 @@ impl Db { community_id: CommunityId, id_bytes: &[u8], ) -> Result> { - match &self.backend { - DbBackend::SQLite(pool) => sqlite::get_event_by_id(pool, community_id, id_bytes).await, - DbBackend::Postgres => { - event::get_event_by_id(self.pg_pool()?, community_id, id_bytes).await - } - } + event::get_event_by_id(&self.pool, community_id, id_bytes).await } /// Fetches a single event by its raw ID bytes, **including soft-deleted rows**. @@ -1927,13 +1873,7 @@ impl Db { community_id: CommunityId, id_bytes: &[u8], ) -> Result> { - match &self.backend { - DbBackend::SQLite(pool) => sqlite::get_event_by_id(pool, community_id, id_bytes).await, - DbBackend::Postgres => { - event::get_event_by_id_including_deleted(self.pg_pool()?, community_id, id_bytes) - .await - } - } + event::get_event_by_id_including_deleted(&self.pool, community_id, id_bytes).await } /// Soft-deletes an event. Returns `Ok(true)` if deleted, `Ok(false)` if already deleted. @@ -1942,7 +1882,7 @@ impl Db { community_id: CommunityId, event_id: &[u8], ) -> Result { - event::soft_delete_event(self.pg_pool()?, community_id, event_id).await + event::soft_delete_event(&self.pool, community_id, event_id).await } /// Soft-delete the live row for an addressable coordinate `(kind, pubkey, d_tag)` @@ -1958,7 +1898,7 @@ impl Db { deletion_created_at_secs: i64, ) -> Result { event::soft_delete_by_coordinate( - self.pg_pool()?, + &self.pool, community_id, kind, pubkey, @@ -1976,21 +1916,14 @@ impl Db { parent_event_id: Option<&[u8]>, root_event_id: Option<&[u8]>, ) -> Result { - match &self.backend { - DbBackend::SQLite(pool) => { - sqlite::soft_delete_event(pool, community_id, event_id).await - } - DbBackend::Postgres => { - event::soft_delete_event_and_update_thread( - self.pg_pool()?, - community_id, - event_id, - parent_event_id, - root_event_id, - ) - .await - } - } + event::soft_delete_event_and_update_thread( + &self.pool, + community_id, + event_id, + parent_event_id, + root_event_id, + ) + .await } /// Returns the most recent `created_at` for a channel. @@ -1999,7 +1932,7 @@ impl Db { community_id: CommunityId, channel_id: Uuid, ) -> Result>> { - event::get_last_message_at(self.pg_pool()?, community_id, channel_id).await + event::get_last_message_at(&self.pool, community_id, channel_id).await } /// Bulk-fetch the most recent `created_at` for a set of channel IDs. @@ -2008,7 +1941,7 @@ impl Db { community_id: CommunityId, channel_ids: &[Uuid], ) -> Result>> { - event::get_last_message_at_bulk(self.pg_pool()?, community_id, channel_ids).await + event::get_last_message_at_bulk(&self.pool, community_id, channel_ids).await } /// Batch-fetch non-deleted events by their raw IDs. @@ -2017,7 +1950,7 @@ impl Db { community_id: CommunityId, ids: &[&[u8]], ) -> Result> { - event::get_events_by_ids(self.pg_pool()?, community_id, ids).await + event::get_events_by_ids(&self.pool, community_id, ids).await } /// [`Db::get_events_by_ids`] with replica routing — same contract and @@ -2043,13 +1976,11 @@ impl Db { Err(e) => { tracing::warn!(path, "replica read failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); - event::get_events_by_ids(self.pg_pool()?, community_id, ids).await + event::get_events_by_ids(&self.pool, community_id, ids).await } } } - RouteDecision::Writer => { - event::get_events_by_ids(self.pg_pool()?, community_id, ids).await - } + RouteDecision::Writer => event::get_events_by_ids(&self.pool, community_id, ids).await, } } @@ -2059,7 +1990,7 @@ impl Db { limit: i64, lease_until: DateTime, ) -> Result> { - push::claim_due_match_batch(self.pg_pool()?, limit, lease_until).await + push::claim_due_match_batch(&self.pool, limit, lease_until).await } /// Load active endpoint-enabled leases eligible for push matching. @@ -2067,7 +1998,7 @@ impl Db { &self, community: CommunityId, ) -> Result> { - push::active_match_leases(self.pg_pool()?, community).await + push::active_match_leases(&self.pool, community).await } /// Complete matcher jobs from one claimed batch while the fence holds. @@ -2077,7 +2008,7 @@ impl Db { claim_id: uuid::Uuid, event_ids: &[Vec], ) -> Result { - push::complete_match_batch(self.pg_pool()?, community, claim_id, event_ids).await + push::complete_match_batch(&self.pool, community, claim_id, event_ids).await } /// Release fenced matcher claims from one batch for retry. @@ -2088,12 +2019,12 @@ impl Db { event_ids: &[Vec], next: DateTime, ) -> Result { - push::retry_match_batch(self.pg_pool()?, community, claim_id, event_ids, next).await + push::retry_match_batch(&self.pool, community, claim_id, event_ids, next).await } /// Delete exhausted matcher jobs (periodic sweep, off the claim path). pub async fn reap_exhausted_push_matches(&self) -> Result { - push::reap_exhausted_matches(self.pg_pool()?).await + push::reap_exhausted_matches(&self.pool).await } /// Idempotently enqueue a wake for a matched lease and event. @@ -2104,7 +2035,7 @@ impl Db { installation_id: &str, wake: push::NewWake<'_>, ) -> Result { - push::enqueue_wake(self.pg_pool()?, community, author, installation_id, wake).await + push::enqueue_wake(&self.pool, community, author, installation_id, wake).await } /// Set-wise [`Self::enqueue_push_wake`]: one transaction per batch. @@ -2113,7 +2044,7 @@ impl Db { community: CommunityId, requests: &[push::WakeRequest], ) -> Result> { - push::enqueue_wakes(self.pg_pool()?, community, requests).await + push::enqueue_wakes(&self.pool, community, requests).await } /// Exclusively claim due wake jobs for one community. @@ -2123,7 +2054,7 @@ impl Db { limit: i64, lease_until: DateTime, ) -> Result> { - push::claim_due_wakes(self.pg_pool()?, community, limit, lease_until).await + push::claim_due_wakes(&self.pool, community, limit, lease_until).await } /// Revalidate a wake's claim, source event, and current lease before send. @@ -2133,7 +2064,7 @@ impl Db { id: Uuid, claim_id: Uuid, ) -> Result { - push::revalidate_wake_for_send(self.pg_pool()?, community, id, claim_id).await + push::revalidate_wake_for_send(&self.pool, community, id, claim_id).await } /// Mark a fenced wake claim delivered. @@ -2143,7 +2074,7 @@ impl Db { id: Uuid, claim_id: Uuid, ) -> Result { - push::complete_wake(self.pg_pool()?, community, id, claim_id).await + push::complete_wake(&self.pool, community, id, claim_id).await } /// Release a fenced wake claim for retry at the supplied time. @@ -2154,7 +2085,7 @@ impl Db { claim_id: Uuid, next: DateTime, ) -> Result { - push::retry_wake(self.pg_pool()?, community, id, claim_id, next).await + push::retry_wake(&self.pool, community, id, claim_id, next).await } /// Mark a fenced wake claim terminally failed. @@ -2164,7 +2095,7 @@ impl Db { id: Uuid, claim_id: Uuid, ) -> Result { - push::fail_wake(self.pg_pool()?, community, id, claim_id).await + push::fail_wake(&self.pool, community, id, claim_id).await } /// Disable an endpoint only if the specified lease generation is current. @@ -2176,7 +2107,7 @@ impl Db { generation: i64, ) -> Result { push::disable_endpoint_generation( - self.pg_pool()?, + &self.pool, community, author, installation_id, @@ -2197,7 +2128,7 @@ impl Db { max_active_leases: i64, ) -> Result { push::accept_lease_event( - self.pg_pool()?, + &self.pool, community, event, installation_id, @@ -2216,15 +2147,8 @@ impl Db { channel_id: Option, thread_meta: Option>, ) -> Result<(StoredEvent, bool)> { - if let DbBackend::SQLite(pool) = &self.backend { - // SQLite Phase 1 persists the canonical event; thread relationships - // remain durable in the event's NIP-10 tags until Phase 2 ports the - // denormalized thread metadata tables. - let _ = thread_meta; - return sqlite::insert_event(pool, community_id, event, channel_id).await; - } let result = event::insert_event_with_thread_metadata( - self.pg_pool()?, + &self.pool, community_id, event, channel_id, @@ -2232,8 +2156,7 @@ impl Db { ) .await?; if result.1 { - if let Err(e) = insert_mentions(self.pg_pool()?, community_id, event, channel_id).await - { + if let Err(e) = insert_mentions(&self.pool, community_id, event, channel_id).await { tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); } } @@ -2252,21 +2175,8 @@ impl Db { actor_pubkey: &[u8], emoji: &str, ) -> Result { - if let DbBackend::SQLite(pool) = &self.backend { - let _ = thread_meta; - return sqlite::insert_reaction_event( - pool, - community_id, - event, - channel_id, - target_event_id, - actor_pubkey, - emoji, - ) - .await; - } let outcome = event::insert_reaction_event_with_thread_metadata( - self.pg_pool()?, + &self.pool, community_id, event, channel_id, @@ -2280,8 +2190,7 @@ impl Db { was_inserted: true, .. } = &outcome { - if let Err(e) = insert_mentions(self.pg_pool()?, community_id, event, channel_id).await - { + if let Err(e) = insert_mentions(&self.pool, community_id, event, channel_id).await { tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); } } @@ -2301,7 +2210,7 @@ impl Db { ttl_seconds: Option, ) -> Result { channel::create_channel( - self.pg_pool()?, + &self.pool, community_id, name, channel_type, @@ -2343,7 +2252,7 @@ impl Db { .await; } channel::create_channel_with_id( - self.pg_pool()?, + &self.pool, community_id, channel_id, name, @@ -2364,9 +2273,7 @@ impl Db { ) -> Result { match &self.backend { DbBackend::SQLite(pool) => sqlite::get_channel(pool, community_id, channel_id).await, - DbBackend::Postgres => { - channel::get_channel(self.pg_pool()?, community_id, channel_id).await - } + DbBackend::Postgres => channel::get_channel(&self.pool, community_id, channel_id).await, } } @@ -2376,7 +2283,7 @@ impl Db { community_id: CommunityId, channel_id: Uuid, ) -> Result> { - channel::get_canvas(self.pg_pool()?, community_id, channel_id).await + channel::get_canvas(&self.pool, community_id, channel_id).await } /// Sets or clears the canvas content for a channel. @@ -2386,7 +2293,7 @@ impl Db { channel_id: Uuid, canvas: Option<&str>, ) -> Result<()> { - channel::set_canvas(self.pg_pool()?, community_id, channel_id, canvas).await + channel::set_canvas(&self.pool, community_id, channel_id, canvas).await } /// Adds a member to a channel. @@ -2403,7 +2310,7 @@ impl Db { .await; } channel::add_member( - self.pg_pool()?, + &self.pool, community_id, channel_id, pubkey, @@ -2421,14 +2328,7 @@ impl Db { pubkey: &[u8], actor_pubkey: &[u8], ) -> Result<()> { - channel::remove_member( - self.pg_pool()?, - community_id, - channel_id, - pubkey, - actor_pubkey, - ) - .await + channel::remove_member(&self.pool, community_id, channel_id, pubkey, actor_pubkey).await } /// Returns `true` if the pubkey is an active member. @@ -2443,7 +2343,7 @@ impl Db { sqlite::is_member(pool, community_id, channel_id, pubkey).await } DbBackend::Postgres => { - channel::is_member(self.pg_pool()?, community_id, channel_id, pubkey).await + channel::is_member(&self.pool, community_id, channel_id, pubkey).await } } } @@ -2456,7 +2356,7 @@ impl Db { channel_ids: &[Uuid], pubkeys: &[Vec], ) -> Result)>> { - channel::membership_pairs(self.pg_pool()?, community_id, channel_ids, pubkeys).await + channel::membership_pairs(&self.pool, community_id, channel_ids, pubkeys).await } /// Returns all active members of a channel. @@ -2467,9 +2367,7 @@ impl Db { ) -> Result> { match &self.backend { DbBackend::SQLite(pool) => sqlite::get_members(pool, community_id, channel_id).await, - DbBackend::Postgres => { - channel::get_members(self.pg_pool()?, community_id, channel_id).await - } + DbBackend::Postgres => channel::get_members(&self.pool, community_id, channel_id).await, } } @@ -2479,7 +2377,7 @@ impl Db { community_id: CommunityId, channel_ids: &[Uuid], ) -> Result> { - channel::get_members_bulk(self.pg_pool()?, community_id, channel_ids).await + channel::get_members_bulk(&self.pool, community_id, channel_ids).await } /// Get all channel IDs accessible to a pubkey. @@ -2493,7 +2391,7 @@ impl Db { sqlite::get_accessible_channel_ids(pool, community_id, pubkey).await } DbBackend::Postgres => { - channel::get_accessible_channel_ids(self.pg_pool()?, community_id, pubkey).await + channel::get_accessible_channel_ids(&self.pool, community_id, pubkey).await } } } @@ -2504,7 +2402,7 @@ impl Db { community_id: CommunityId, visibility: Option<&str>, ) -> Result> { - channel::list_channels(self.pg_pool()?, community_id, visibility).await + channel::list_channels(&self.pool, community_id, visibility).await } /// Returns full channel records for all channels a user can access. @@ -2516,7 +2414,7 @@ impl Db { member_only: Option, ) -> Result> { channel::get_accessible_channels( - self.pg_pool()?, + &self.pool, community_id, pubkey, visibility_filter, @@ -2530,7 +2428,7 @@ impl Db { &self, community_id: CommunityId, ) -> Result> { - channel::get_bot_members(self.pg_pool()?, community_id).await + channel::get_bot_members(&self.pool, community_id).await } /// Bulk-fetch user records by pubkey. @@ -2539,7 +2437,7 @@ impl Db { community_id: CommunityId, pubkeys: &[Vec], ) -> Result> { - channel::get_users_bulk(self.pg_pool()?, community_id, pubkeys).await + channel::get_users_bulk(&self.pool, community_id, pubkeys).await } /// Updates a channel's name and/or description. @@ -2549,7 +2447,7 @@ impl Db { channel_id: Uuid, updates: channel::ChannelUpdate, ) -> Result { - channel::update_channel(self.pg_pool()?, community_id, channel_id, updates).await + channel::update_channel(&self.pool, community_id, channel_id, updates).await } /// Sets the topic for a channel. @@ -2560,7 +2458,7 @@ impl Db { topic: &str, set_by: &[u8], ) -> Result<()> { - channel::set_topic(self.pg_pool()?, community_id, channel_id, topic, set_by).await + channel::set_topic(&self.pool, community_id, channel_id, topic, set_by).await } /// Sets the purpose for a channel. @@ -2571,12 +2469,12 @@ impl Db { purpose: &str, set_by: &[u8], ) -> Result<()> { - channel::set_purpose(self.pg_pool()?, community_id, channel_id, purpose, set_by).await + channel::set_purpose(&self.pool, community_id, channel_id, purpose, set_by).await } /// Archives a channel. pub async fn archive_channel(&self, community_id: CommunityId, channel_id: Uuid) -> Result<()> { - channel::archive_channel(self.pg_pool()?, community_id, channel_id).await + channel::archive_channel(&self.pool, community_id, channel_id).await } /// Unarchives a channel. @@ -2585,7 +2483,7 @@ impl Db { community_id: CommunityId, channel_id: Uuid, ) -> Result<()> { - channel::unarchive_channel(self.pg_pool()?, community_id, channel_id).await + channel::unarchive_channel(&self.pool, community_id, channel_id).await } /// Soft-delete a channel. @@ -2594,7 +2492,7 @@ impl Db { community_id: CommunityId, channel_id: Uuid, ) -> Result { - channel::soft_delete_channel(self.pg_pool()?, community_id, channel_id).await + channel::soft_delete_channel(&self.pool, community_id, channel_id).await } /// Returns the count of active members in a channel. @@ -2603,7 +2501,7 @@ impl Db { community_id: CommunityId, channel_id: Uuid, ) -> Result { - channel::get_member_count(self.pg_pool()?, community_id, channel_id).await + channel::get_member_count(&self.pool, community_id, channel_id).await } /// Bulk-fetch member counts for a set of channel IDs. @@ -2612,7 +2510,7 @@ impl Db { community_id: CommunityId, channel_ids: &[Uuid], ) -> Result> { - channel::get_member_counts_bulk(self.pg_pool()?, community_id, channel_ids).await + channel::get_member_counts_bulk(&self.pool, community_id, channel_ids).await } /// Get the active role of a pubkey in a channel. @@ -2622,21 +2520,14 @@ impl Db { channel_id: Uuid, pubkey: &[u8], ) -> Result> { - match &self.backend { - DbBackend::SQLite(pool) => { - sqlite::get_member_role(pool, community_id, channel_id, pubkey).await - } - DbBackend::Postgres => { - channel::get_member_role(self.pg_pool()?, community_id, channel_id, pubkey).await - } - } + channel::get_member_role(&self.pool, community_id, channel_id, pubkey).await } /// Archive ephemeral channels whose TTL deadline has passed. pub async fn reap_expired_ephemeral_channels( &self, ) -> Result> { - channel::reap_expired_ephemeral_channels(self.pg_pool()?).await + channel::reap_expired_ephemeral_channels(&self.pool).await } /// Query due reminders ready for delivery. @@ -2645,7 +2536,7 @@ impl Db { now_secs: i64, batch_limit: i64, ) -> Result> { - event::query_due_reminders(self.pg_pool()?, now_secs, batch_limit).await + event::query_due_reminders(&self.pool, now_secs, batch_limit).await } /// Atomically claim a due reminder for delivery (cross-pod dedup). @@ -2655,7 +2546,7 @@ impl Db { event_id: &[u8], event_created_at: chrono::DateTime, ) -> Result { - event::claim_due_reminder(self.pg_pool()?, community_id, event_id, event_created_at).await + event::claim_due_reminder(&self.pool, community_id, event_id, event_created_at).await } /// Atomically claim a due reminder using a caller-supplied delivery stamp. @@ -2667,7 +2558,7 @@ impl Db { delivery_stamp: i64, ) -> Result { event::claim_due_reminder_with_stamp( - self.pg_pool()?, + &self.pool, community_id, event_id, event_created_at, @@ -2685,7 +2576,7 @@ impl Db { delivery_stamp: i64, ) -> Result { event::release_due_reminder( - self.pg_pool()?, + &self.pool, community_id, event_id, event_created_at, @@ -2700,10 +2591,7 @@ impl Db { /// already existed. Callers use the `true` return to increment /// `buzz_users_created_total`. pub async fn ensure_user(&self, community_id: CommunityId, pubkey: &[u8]) -> Result { - match &self.backend { - DbBackend::SQLite(pool) => sqlite::ensure_user(pool, community_id, pubkey).await, - DbBackend::Postgres => user::ensure_user(self.pg_pool()?, community_id, pubkey).await, - } + user::ensure_user(&self.pool, community_id, pubkey).await } /// Get a single user record by pubkey. @@ -2712,10 +2600,7 @@ impl Db { community_id: CommunityId, pubkey: &[u8], ) -> Result> { - match &self.backend { - DbBackend::SQLite(pool) => sqlite::get_user(pool, community_id, pubkey).await, - DbBackend::Postgres => user::get_user(self.pg_pool()?, community_id, pubkey).await, - } + user::get_user(&self.pool, community_id, pubkey).await } /// Update a user's profile fields. @@ -2728,32 +2613,16 @@ impl Db { about: Option<&str>, nip05_handle: Option<&str>, ) -> Result<()> { - match &self.backend { - DbBackend::SQLite(pool) => { - sqlite::update_user_profile( - pool, - community_id, - pubkey, - display_name, - avatar_url, - about, - nip05_handle, - ) - .await - } - DbBackend::Postgres => { - user::update_user_profile( - self.pg_pool()?, - community_id, - pubkey, - display_name, - avatar_url, - about, - nip05_handle, - ) - .await - } - } + user::update_user_profile( + &self.pool, + community_id, + pubkey, + display_name, + avatar_url, + about, + nip05_handle, + ) + .await } /// Look up a user by NIP-05 handle. @@ -2763,7 +2632,7 @@ impl Db { local_part: &str, domain: &str, ) -> Result> { - user::get_user_by_nip05(self.pg_pool()?, community_id, local_part, domain).await + user::get_user_by_nip05(&self.pool, community_id, local_part, domain).await } /// Search users by display name, NIP-05 handle, or pubkey prefix. @@ -2773,7 +2642,7 @@ impl Db { query: &str, limit: u32, ) -> Result> { - user::search_users(self.pg_pool()?, community_id, query, limit).await + user::search_users(&self.pool, community_id, query, limit).await } /// Atomically set agent owner — only if no owner is currently assigned. @@ -2784,15 +2653,7 @@ impl Db { agent_pubkey: &[u8], owner_pubkey: &[u8], ) -> Result { - match &self.backend { - DbBackend::SQLite(pool) => { - sqlite::set_agent_owner(pool, community_id, agent_pubkey, owner_pubkey).await - } - DbBackend::Postgres => { - user::set_agent_owner(self.pg_pool()?, community_id, agent_pubkey, owner_pubkey) - .await - } - } + user::set_agent_owner(&self.pool, community_id, agent_pubkey, owner_pubkey).await } /// Get the channel_add_policy and agent_owner_pubkey for a user. @@ -2801,14 +2662,7 @@ impl Db { community_id: CommunityId, pubkey: &[u8], ) -> Result>)>> { - match &self.backend { - DbBackend::SQLite(pool) => { - sqlite::get_agent_channel_policy(pool, community_id, pubkey).await - } - DbBackend::Postgres => { - user::get_agent_channel_policy(self.pg_pool()?, community_id, pubkey).await - } - } + user::get_agent_channel_policy(&self.pool, community_id, pubkey).await } /// Check whether `actor_pubkey` is the agent owner of `target_pubkey`. @@ -2818,15 +2672,7 @@ impl Db { target_pubkey: &[u8], actor_pubkey: &[u8], ) -> Result { - match &self.backend { - DbBackend::SQLite(pool) => { - sqlite::is_agent_owner(pool, community_id, target_pubkey, actor_pubkey).await - } - DbBackend::Postgres => { - user::is_agent_owner(self.pg_pool()?, community_id, target_pubkey, actor_pubkey) - .await - } - } + user::is_agent_owner(&self.pool, community_id, target_pubkey, actor_pubkey).await } /// Set the channel_add_policy for a user. @@ -2836,7 +2682,7 @@ impl Db { pubkey: &[u8], policy: &str, ) -> Result<()> { - user::set_channel_add_policy(self.pg_pool()?, community_id, pubkey, policy).await + user::set_channel_add_policy(&self.pool, community_id, pubkey, policy).await } /// Find an existing DM by its participant hash. @@ -2845,7 +2691,7 @@ impl Db { community_id: CommunityId, participant_hash: &[u8], ) -> Result> { - dm::find_dm_by_participants(self.pg_pool()?, community_id, participant_hash).await + dm::find_dm_by_participants(&self.pool, community_id, participant_hash).await } /// Create or return an existing DM channel. @@ -2855,7 +2701,7 @@ impl Db { participants: &[&[u8]], created_by: &[u8], ) -> Result { - dm::create_dm(self.pg_pool()?, community_id, participants, created_by).await + dm::create_dm(&self.pool, community_id, participants, created_by).await } /// List all DMs for a user. @@ -2866,7 +2712,7 @@ impl Db { limit: u32, cursor: Option, ) -> Result> { - dm::list_dms_for_user(self.pg_pool()?, community_id, pubkey, limit, cursor).await + dm::list_dms_for_user(&self.pool, community_id, pubkey, limit, cursor).await } /// Open or retrieve a DM for the given participants. @@ -2876,33 +2722,7 @@ impl Db { pubkeys: &[&[u8]], created_by: &[u8], ) -> Result<(channel::ChannelRecord, bool)> { - match &self.backend { - DbBackend::SQLite(pool) => { - sqlite::open_dm(pool, community_id, pubkeys, created_by, None) - .await? - .ok_or_else(|| DbError::InvalidData("unexpected duplicate DM open".into())) - } - DbBackend::Postgres => { - dm::open_dm(self.pg_pool()?, community_id, pubkeys, created_by).await - } - } - } - - /// Atomically persist a SQLite DM-open command and its channel mutation. - /// `None` means the command event was already processed. - pub async fn open_dm_sqlite_command( - &self, - community_id: CommunityId, - pubkeys: &[&[u8]], - created_by: &[u8], - event: &nostr::Event, - ) -> Result> { - match &self.backend { - DbBackend::SQLite(pool) => { - sqlite::open_dm(pool, community_id, pubkeys, created_by, Some(event)).await - } - DbBackend::Postgres => Ok(None), - } + dm::open_dm(&self.pool, community_id, pubkeys, created_by).await } /// Hide a DM channel for a specific user. @@ -2915,7 +2735,7 @@ impl Db { channel_id: Uuid, pubkey: &[u8], ) -> Result<()> { - dm::hide_dm(self.pg_pool()?, community_id, channel_id, pubkey).await + dm::hide_dm(&self.pool, community_id, channel_id, pubkey).await } /// Unhide a DM channel for a specific user. @@ -2925,7 +2745,7 @@ impl Db { channel_id: Uuid, pubkey: &[u8], ) -> Result<()> { - dm::unhide_dm(self.pg_pool()?, community_id, channel_id, pubkey).await + dm::unhide_dm(&self.pool, community_id, channel_id, pubkey).await } /// List the channel IDs of all DMs the given user currently has hidden. @@ -2934,7 +2754,7 @@ impl Db { community_id: CommunityId, pubkey: &[u8], ) -> Result> { - dm::list_hidden_dms(self.pg_pool()?, community_id, pubkey).await + dm::list_hidden_dms(&self.pool, community_id, pubkey).await } /// Insert thread metadata. @@ -2953,7 +2773,7 @@ impl Db { broadcast: bool, ) -> Result<()> { thread::insert_thread_metadata( - self.pg_pool()?, + &self.pool, community_id, event_id, event_created_at, @@ -3052,7 +2872,7 @@ impl Db { } } thread::get_thread_replies( - self.pg_pool()?, + &self.pool, community_id, root_event_id, depth_limit, @@ -3068,7 +2888,7 @@ impl Db { community_id: CommunityId, event_id: &[u8], ) -> Result> { - thread::get_thread_summary(self.pg_pool()?, community_id, event_id).await + thread::get_thread_summary(&self.pool, community_id, event_id).await } /// One channel window: top-level rows + summaries + server `has_more`. @@ -3152,7 +2972,7 @@ impl Db { ReadSession { inner: ReadSessionInner::Replica { tx, - writer: self.pg_pool()?.clone(), + writer: self.pool.clone(), }, }, )); @@ -3176,7 +2996,7 @@ impl Db { RouteDecision::Writer => {} } let window = thread::get_channel_window( - self.pg_pool()?, + &self.pool, community_id, channel_id, limit, @@ -3187,7 +3007,7 @@ impl Db { Ok(( window, ReadSession { - inner: ReadSessionInner::Writer(self.pg_pool()?.clone()), + inner: ReadSessionInner::Writer(self.pool.clone()), }, )) } @@ -3284,12 +3104,7 @@ impl Db { community_id: CommunityId, event_id: &[u8], ) -> Result> { - if matches!(&self.backend, DbBackend::SQLite(_)) { - // SQLite Phase 1 derives ancestry from durable NIP-10 event tags; - // denormalized counters and metadata arrive with the Phase 2 port. - return Ok(None); - } - thread::get_thread_metadata_by_event(self.pg_pool()?, community_id, event_id).await + thread::get_thread_metadata_by_event(&self.pool, community_id, event_id).await } /// Decrement reply counts. @@ -3299,13 +3114,8 @@ impl Db { parent_event_id: &[u8], root_event_id: Option<&[u8]>, ) -> Result<()> { - thread::decrement_reply_count( - self.pg_pool()?, - community_id, - parent_event_id, - root_event_id, - ) - .await + thread::decrement_reply_count(&self.pool, community_id, parent_event_id, root_event_id) + .await } /// Add (or re-activate) a reaction. @@ -3319,7 +3129,7 @@ impl Db { reaction_event_id: Option<&[u8]>, ) -> Result { reaction::add_reaction( - self.pg_pool()?, + &self.pool, community, event_id, event_created_at, @@ -3339,19 +3149,8 @@ impl Db { pubkey: &[u8], emoji: &str, ) -> Result { - if let DbBackend::SQLite(pool) = &self.backend { - return sqlite::remove_reaction( - pool, - community, - event_id, - event_created_at, - pubkey, - emoji, - ) - .await; - } reaction::remove_reaction( - self.pg_pool()?, + &self.pool, community, event_id, event_created_at, @@ -3367,12 +3166,7 @@ impl Db { community: CommunityId, reaction_event_id: &[u8], ) -> Result { - if let DbBackend::SQLite(pool) = &self.backend { - return sqlite::remove_reaction_by_source_event_id(pool, community, reaction_event_id) - .await; - } - reaction::remove_reaction_by_source_event_id(self.pg_pool()?, community, reaction_event_id) - .await + reaction::remove_reaction_by_source_event_id(&self.pool, community, reaction_event_id).await } /// Look up the active reaction row for one actor + emoji + target tuple. @@ -3385,7 +3179,7 @@ impl Db { emoji: &str, ) -> Result> { reaction::get_active_reaction_record( - self.pg_pool()?, + &self.pool, community, event_id, event_created_at, @@ -3406,7 +3200,7 @@ impl Db { reaction_event_id: &[u8], ) -> Result { reaction::set_reaction_event_id( - self.pg_pool()?, + &self.pool, community, event_id, event_created_at, @@ -3427,7 +3221,7 @@ impl Db { cursor: Option<&str>, ) -> Result> { reaction::get_reactions( - self.pg_pool()?, + &self.pool, community, event_id, event_created_at, @@ -3443,7 +3237,7 @@ impl Db { community: CommunityId, event_ids: &[(&[u8], DateTime)], ) -> Result> { - reaction::get_reactions_bulk(self.pg_pool()?, community, event_ids).await + reaction::get_reactions_bulk(&self.pool, community, event_ids).await } /// Find events that @mention the given pubkey. @@ -3456,7 +3250,7 @@ impl Db { limit: i64, ) -> Result> { feed::query_mentions( - self.pg_pool()?, + &self.pool, community, pubkey_bytes, accessible_channel_ids, @@ -3482,17 +3276,6 @@ impl Db { since: Option>, limit: i64, ) -> Result> { - if let DbBackend::SQLite(pool) = &self.backend { - return sqlite::query_feed_mentions( - pool, - community, - pubkey_bytes, - accessible_channel_ids, - since, - limit, - ) - .await; - } match self.route_read(path, RoutePredicate::Bounded).await { RouteDecision::Replica(mut tx, _entry, reason) => { match feed::query_mentions_on( @@ -3513,7 +3296,7 @@ impl Db { tracing::warn!(path, "replica read failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); feed::query_mentions( - self.pg_pool()?, + &self.pool, community, pubkey_bytes, accessible_channel_ids, @@ -3526,7 +3309,7 @@ impl Db { } RouteDecision::Writer => { feed::query_mentions( - self.pg_pool()?, + &self.pool, community, pubkey_bytes, accessible_channel_ids, @@ -3548,7 +3331,7 @@ impl Db { limit: i64, ) -> Result> { feed::query_needs_action( - self.pg_pool()?, + &self.pool, community, pubkey_bytes, accessible_channel_ids, @@ -3570,17 +3353,6 @@ impl Db { since: Option>, limit: i64, ) -> Result> { - if let DbBackend::SQLite(pool) = &self.backend { - return sqlite::query_feed_needs_action( - pool, - community, - pubkey_bytes, - accessible_channel_ids, - since, - limit, - ) - .await; - } match self.route_read(path, RoutePredicate::Bounded).await { RouteDecision::Replica(mut tx, _entry, reason) => { match feed::query_needs_action_on( @@ -3601,7 +3373,7 @@ impl Db { tracing::warn!(path, "replica read failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); feed::query_needs_action( - self.pg_pool()?, + &self.pool, community, pubkey_bytes, accessible_channel_ids, @@ -3614,7 +3386,7 @@ impl Db { } RouteDecision::Writer => { feed::query_needs_action( - self.pg_pool()?, + &self.pool, community, pubkey_bytes, accessible_channel_ids, @@ -3634,14 +3406,7 @@ impl Db { since: Option>, limit: i64, ) -> Result> { - feed::query_activity( - self.pg_pool()?, - community, - accessible_channel_ids, - since, - limit, - ) - .await + feed::query_activity(&self.pool, community, accessible_channel_ids, since, limit).await } /// [`Db::query_feed_activity`] with replica routing — BOUNDED arm only; @@ -3655,16 +3420,6 @@ impl Db { since: Option>, limit: i64, ) -> Result> { - if let DbBackend::SQLite(pool) = &self.backend { - return sqlite::query_feed_activity( - pool, - community, - accessible_channel_ids, - since, - limit, - ) - .await; - } match self.route_read(path, RoutePredicate::Bounded).await { RouteDecision::Replica(mut tx, _entry, reason) => { match feed::query_activity_on( @@ -3684,7 +3439,7 @@ impl Db { tracing::warn!(path, "replica read failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); feed::query_activity( - self.pg_pool()?, + &self.pool, community, accessible_channel_ids, since, @@ -3695,14 +3450,8 @@ impl Db { } } RouteDecision::Writer => { - feed::query_activity( - self.pg_pool()?, - community, - accessible_channel_ids, - since, - limit, - ) - .await + feed::query_activity(&self.pool, community, accessible_channel_ids, since, limit) + .await } } } @@ -3720,7 +3469,7 @@ impl Db { expires_at: Option>, ) -> Result { api_token::create_api_token( - self.pg_pool()?, + &self.pool, *community_id.as_uuid(), token_hash, owner_pubkey, @@ -3745,7 +3494,7 @@ impl Db { expires_at: Option>, ) -> Result> { api_token::create_api_token_if_under_limit( - self.pg_pool()?, + &self.pool, *community_id.as_uuid(), token_hash, owner_pubkey, @@ -3778,7 +3527,7 @@ impl Db { ) .bind(community_id.as_uuid()) .bind(hash) - .fetch_optional(self.pg_pool()?) + .fetch_optional(&self.pool) .await?; match row { @@ -3794,7 +3543,7 @@ impl Db { hash: &[u8], ) -> Result> { api_token::get_api_token_by_hash_including_revoked( - self.pg_pool()?, + &self.pool, *community_id.as_uuid(), hash, ) @@ -3808,7 +3557,7 @@ impl Db { ) .bind(community_id.as_uuid()) .bind(hash) - .execute(self.pg_pool()?) + .execute(&self.pool) .await?; Ok(()) } @@ -3834,7 +3583,7 @@ impl Db { "#, ) .bind(community_id.as_uuid()) - .fetch_all(self.pg_pool()?) + .fetch_all(&self.pool) .await?; let mut out = Vec::with_capacity(rows.len()); @@ -3862,7 +3611,7 @@ impl Db { community_id: CommunityId, pubkey: &[u8], ) -> Result> { - api_token::list_tokens_by_owner(self.pg_pool()?, *community_id.as_uuid(), pubkey).await + api_token::list_tokens_by_owner(&self.pool, *community_id.as_uuid(), pubkey).await } /// Revoke a single token by ID, scoped to (community, owner). @@ -3874,7 +3623,7 @@ impl Db { revoked_by: &[u8], ) -> Result { api_token::revoke_token( - self.pg_pool()?, + &self.pool, *community_id.as_uuid(), id, owner_pubkey, @@ -3891,7 +3640,7 @@ impl Db { revoked_by: &[u8], ) -> Result { api_token::revoke_all_tokens( - self.pg_pool()?, + &self.pool, *community_id.as_uuid(), owner_pubkey, revoked_by, @@ -3910,7 +3659,7 @@ impl Db { definition_hash: &[u8], ) -> Result { workflow::create_workflow( - self.pg_pool()?, + &self.pool, community_id, channel_id, owner_pubkey, @@ -3934,7 +3683,7 @@ impl Db { definition_hash: &[u8], ) -> Result<()> { workflow::upsert_workflow( - self.pg_pool()?, + &self.pool, community_id, id, channel_id, @@ -3952,7 +3701,7 @@ impl Db { community_id: CommunityId, id: Uuid, ) -> Result { - workflow::get_workflow(self.pg_pool()?, community_id, id).await + workflow::get_workflow(&self.pool, community_id, id).await } /// List workflows for a channel. @@ -3963,8 +3712,7 @@ impl Db { limit: Option, offset: Option, ) -> Result> { - workflow::list_channel_workflows(self.pg_pool()?, community_id, channel_id, limit, offset) - .await + workflow::list_channel_workflows(&self.pool, community_id, channel_id, limit, offset).await } /// List active, enabled workflows for a channel. @@ -3973,12 +3721,12 @@ impl Db { community_id: CommunityId, channel_id: Uuid, ) -> Result> { - workflow::list_enabled_channel_workflows(self.pg_pool()?, community_id, channel_id).await + workflow::list_enabled_channel_workflows(&self.pool, community_id, channel_id).await } /// List all active, enabled schedule-triggered workflows. pub async fn list_all_enabled_workflows(&self) -> Result> { - workflow::list_all_enabled_workflows(self.pg_pool()?).await + workflow::list_all_enabled_workflows(&self.pool).await } /// Claim a scheduled workflow fire for an authoritative schedule instant. @@ -3996,7 +3744,7 @@ impl Db { scheduled_for: chrono::DateTime, ) -> Result> { workflow::claim_scheduled_workflow_fire( - self.pg_pool()?, + &self.pool, community_id, workflow_id, scheduled_for, @@ -4010,7 +3758,7 @@ impl Db { community_id: CommunityId, workflow_id: Uuid, ) -> Result>> { - workflow::latest_scheduled_workflow_fire(self.pg_pool()?, community_id, workflow_id).await + workflow::latest_scheduled_workflow_fire(&self.pool, community_id, workflow_id).await } /// Attach the workflow run id created from a won scheduled-fire claim. @@ -4022,7 +3770,7 @@ impl Db { workflow_run_id: Uuid, ) -> Result { workflow::attach_scheduled_workflow_run( - self.pg_pool()?, + &self.pool, community_id, workflow_id, scheduled_for, @@ -4036,7 +3784,7 @@ impl Db { &self, older_than: chrono::DateTime, ) -> Result { - workflow::prune_scheduled_workflow_fires_before(self.pg_pool()?, older_than).await + workflow::prune_scheduled_workflow_fires_before(&self.pool, older_than).await } /// Update a workflow's name, definition, and hash. @@ -4049,7 +3797,7 @@ impl Db { definition_hash: &[u8], ) -> Result<()> { workflow::update_workflow( - self.pg_pool()?, + &self.pool, community_id, id, name, @@ -4066,7 +3814,7 @@ impl Db { id: Uuid, status: workflow::WorkflowStatus, ) -> Result<()> { - workflow::update_workflow_status(self.pg_pool()?, community_id, id, status).await + workflow::update_workflow_status(&self.pool, community_id, id, status).await } /// Enable or disable a workflow. @@ -4076,7 +3824,7 @@ impl Db { id: Uuid, enabled: bool, ) -> Result<()> { - workflow::set_workflow_enabled(self.pg_pool()?, community_id, id, enabled).await + workflow::set_workflow_enabled(&self.pool, community_id, id, enabled).await } /// Disable all of an owner's workflows in a channel (SEC-006, on @@ -4088,7 +3836,7 @@ impl Db { owner_pubkey: &[u8], ) -> Result { workflow::disable_workflows_for_owner_in_channel( - self.pg_pool()?, + &self.pool, community_id, channel_id, owner_pubkey, @@ -4098,7 +3846,7 @@ impl Db { /// Delete a workflow and all its runs/approvals. pub async fn delete_workflow(&self, community_id: CommunityId, id: Uuid) -> Result<()> { - workflow::delete_workflow(self.pg_pool()?, community_id, id).await + workflow::delete_workflow(&self.pool, community_id, id).await } /// Delete a workflow only when it belongs to the provided owner. @@ -4109,7 +3857,7 @@ impl Db { id: Uuid, owner_pubkey: &[u8], ) -> Result> { - workflow::delete_workflow_for_owner(self.pg_pool()?, community_id, id, owner_pubkey).await + workflow::delete_workflow_for_owner(&self.pool, community_id, id, owner_pubkey).await } /// Find a workflow by owner pubkey and name within a community. Used for @@ -4120,7 +3868,7 @@ impl Db { owner_pubkey: &[u8], name: &str, ) -> Result> { - workflow::find_by_owner_and_name(self.pg_pool()?, community_id, owner_pubkey, name).await + workflow::find_by_owner_and_name(&self.pool, community_id, owner_pubkey, name).await } /// Create a new workflow run. @@ -4132,7 +3880,7 @@ impl Db { trigger_context: Option<&serde_json::Value>, ) -> Result { workflow::create_workflow_run( - self.pg_pool()?, + &self.pool, community_id, workflow_id, trigger_event_id, @@ -4147,7 +3895,7 @@ impl Db { community_id: CommunityId, id: Uuid, ) -> Result { - workflow::get_workflow_run(self.pg_pool()?, community_id, id).await + workflow::get_workflow_run(&self.pool, community_id, id).await } /// List runs for a workflow. @@ -4157,7 +3905,7 @@ impl Db { workflow_id: Uuid, limit: i64, ) -> Result> { - workflow::list_workflow_runs(self.pg_pool()?, community_id, workflow_id, limit).await + workflow::list_workflow_runs(&self.pool, community_id, workflow_id, limit).await } /// Update a workflow run's status. @@ -4171,7 +3919,7 @@ impl Db { error: Option<&str>, ) -> Result<()> { workflow::update_workflow_run( - self.pg_pool()?, + &self.pool, community_id, id, status, @@ -4184,7 +3932,7 @@ impl Db { /// Create an approval request. pub async fn create_approval(&self, params: workflow::CreateApprovalParams<'_>) -> Result<()> { - workflow::create_approval(self.pg_pool()?, params).await + workflow::create_approval(&self.pool, params).await } /// Fetch an approval by raw token. @@ -4193,7 +3941,7 @@ impl Db { community_id: CommunityId, token: &str, ) -> Result { - workflow::get_approval(self.pg_pool()?, community_id, token).await + workflow::get_approval(&self.pool, community_id, token).await } /// Fetch an approval by its already-hashed token (no re-hashing). @@ -4202,7 +3950,7 @@ impl Db { community_id: CommunityId, token_hash: &[u8], ) -> Result { - workflow::get_approval_by_stored_hash(self.pg_pool()?, community_id, token_hash).await + workflow::get_approval_by_stored_hash(&self.pool, community_id, token_hash).await } /// Fetch all approvals for a workflow run. @@ -4212,7 +3960,7 @@ impl Db { workflow_id: uuid::Uuid, run_id: uuid::Uuid, ) -> Result> { - workflow::get_run_approvals(self.pg_pool()?, community_id, workflow_id, run_id).await + workflow::get_run_approvals(&self.pool, community_id, workflow_id, run_id).await } /// Update an approval's status. @@ -4225,7 +3973,7 @@ impl Db { note: Option<&str>, ) -> Result { workflow::update_approval( - self.pg_pool()?, + &self.pool, community_id, token, status, @@ -4245,7 +3993,7 @@ impl Db { note: Option<&str>, ) -> Result { workflow::update_approval_by_stored_hash( - self.pg_pool()?, + &self.pool, community_id, token_hash, status, @@ -4257,7 +4005,7 @@ impl Db { /// Ensures monthly partitions exist for the next N months. pub async fn ensure_future_partitions(&self, months_ahead: u32) -> Result<()> { - partition::ensure_future_partitions(self.pg_pool()?, months_ahead).await + partition::ensure_future_partitions(&self.pool, months_ahead).await } /// Backfill `d_tag` for existing NIP-33 events (kind 30000–39999) that have `d_tag IS NULL`. @@ -4274,7 +4022,7 @@ impl Db { ) \ WHERE kind BETWEEN 30000 AND 39999 AND d_tag IS NULL", ) - .execute(self.pg_pool()?) + .execute(&self.pool) .await?; Ok(result.rows_affected()) } @@ -4286,7 +4034,7 @@ impl Db { ) .bind(community.as_uuid()) .bind(pubkey) - .fetch_one(self.pg_pool()?) + .fetch_one(&self.pool) .await?; let cnt: i64 = row.try_get("cnt")?; Ok(cnt > 0) @@ -4297,7 +4045,7 @@ impl Db { let row = sqlx::query("SELECT COUNT(*) as cnt FROM pubkey_allowlist WHERE community_id = $1") .bind(community.as_uuid()) - .fetch_one(self.pg_pool()?) + .fetch_one(&self.pool) .await?; let cnt: i64 = row.try_get("cnt")?; Ok(cnt > 0) @@ -4319,7 +4067,7 @@ impl Db { .bind(pubkey) .bind(added_by) .bind(note) - .execute(self.pg_pool()?) + .execute(&self.pool) .await?; Ok(result.rows_affected() > 0) } @@ -4334,7 +4082,7 @@ impl Db { sqlx::query("DELETE FROM pubkey_allowlist WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) .bind(pubkey) - .execute(self.pg_pool()?) + .execute(&self.pool) .await?; Ok(result.rows_affected() > 0) } @@ -4345,7 +4093,7 @@ impl Db { "SELECT pubkey, added_by, added_at, note FROM pubkey_allowlist WHERE community_id = $1 ORDER BY added_at DESC", ) .bind(community.as_uuid()) - .fetch_all(self.pg_pool()?) + .fetch_all(&self.pool) .await?; let mut out = Vec::with_capacity(rows.len()); @@ -4369,33 +4117,27 @@ impl Db { /// [`Db::query_events_routed_bounded`]. Not precedent for routing other /// permission reads. pub async fn is_relay_member(&self, community: CommunityId, pubkey: &str) -> Result { - match &self.backend { - DbBackend::SQLite(pool) => sqlite::is_relay_member(pool, community, pubkey).await, - DbBackend::Postgres => { - let path = "relay_membership"; - match self.route_read(path, RoutePredicate::Bounded).await { - RouteDecision::Replica(mut tx, _entry, reason) => { - match relay_members::is_relay_member_on(&mut tx, community, pubkey).await { - Ok(is_member) => { - Self::record_route(path, "replica", reason); - Ok(is_member) - } - Err(e) => { - tracing::warn!( - path, - "replica read failed; re-running on writer: {e}" - ); - Self::record_route(path, "writer", "replica_error"); - relay_members::is_relay_member(self.pg_pool()?, community, pubkey) - .await - } - } + if let DbBackend::SQLite(pool) = &self.backend { + return sqlite::is_relay_member(pool, community, pubkey).await; + } + let path = "relay_membership"; + match self.route_read(path, RoutePredicate::Bounded).await { + RouteDecision::Replica(mut tx, _entry, reason) => { + match relay_members::is_relay_member_on(&mut tx, community, pubkey).await { + Ok(is_member) => { + Self::record_route(path, "replica", reason); + Ok(is_member) } - RouteDecision::Writer => { - relay_members::is_relay_member(self.pg_pool()?, community, pubkey).await + Err(e) => { + tracing::warn!(path, "replica read failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + relay_members::is_relay_member(&self.pool, community, pubkey).await } } } + RouteDecision::Writer => { + relay_members::is_relay_member(&self.pool, community, pubkey).await + } } } @@ -4405,12 +4147,10 @@ impl Db { community: CommunityId, pubkey: &str, ) -> Result> { - match &self.backend { - DbBackend::SQLite(pool) => sqlite::get_relay_member(pool, community, pubkey).await, - DbBackend::Postgres => { - relay_members::get_relay_member(self.pg_pool()?, community, pubkey).await - } + if let DbBackend::SQLite(pool) = &self.backend { + return sqlite::get_relay_member(pool, community, pubkey).await; } + relay_members::get_relay_member(&self.pool, community, pubkey).await } /// Returns all relay members of `community` ordered by `created_at` ascending. @@ -4418,12 +4158,10 @@ impl Db { &self, community: CommunityId, ) -> Result> { - match &self.backend { - DbBackend::SQLite(pool) => sqlite::list_relay_members(pool, community).await, - DbBackend::Postgres => { - relay_members::list_relay_members(self.pg_pool()?, community).await - } + if let DbBackend::SQLite(pool) = &self.backend { + return sqlite::list_relay_members(pool, community).await; } + relay_members::list_relay_members(&self.pool, community).await } /// Adds a new relay member to `community`. @@ -4437,15 +4175,10 @@ impl Db { role: &str, added_by: Option<&str>, ) -> Result { - match &self.backend { - DbBackend::SQLite(pool) => { - sqlite::add_relay_member(pool, community, pubkey, role, added_by).await - } - DbBackend::Postgres => { - relay_members::add_relay_member(self.pg_pool()?, community, pubkey, role, added_by) - .await - } + if let DbBackend::SQLite(pool) = &self.backend { + return sqlite::add_relay_member(pool, community, pubkey, role, added_by).await; } + relay_members::add_relay_member(&self.pool, community, pubkey, role, added_by).await } /// Claims relay membership via an invite and atomically persists the @@ -4457,14 +4190,8 @@ impl Db { role: &str, policy_version: Option<&str>, ) -> Result { - relay_members::claim_relay_membership( - self.pg_pool()?, - community, - pubkey, - role, - policy_version, - ) - .await + relay_members::claim_relay_membership(&self.pool, community, pubkey, role, policy_version) + .await } /// Returns whether a member has persisted acceptance evidence for a policy version. @@ -4474,13 +4201,8 @@ impl Db { pubkey: &str, policy_version: &str, ) -> Result { - relay_members::has_join_policy_acceptance( - self.pg_pool()?, - community, - pubkey, - policy_version, - ) - .await + relay_members::has_join_policy_acceptance(&self.pool, community, pubkey, policy_version) + .await } /// Removes a relay member from `community` atomically, refusing to delete the owner. @@ -4489,7 +4211,7 @@ impl Db { community: CommunityId, pubkey: &str, ) -> Result { - relay_members::remove_relay_member(self.pg_pool()?, community, pubkey).await + relay_members::remove_relay_member(&self.pool, community, pubkey).await } /// Removes a relay member from `community` only if their current role matches `expected_role`. @@ -4502,13 +4224,8 @@ impl Db { pubkey: &str, expected_role: &str, ) -> Result { - relay_members::remove_relay_member_if_role( - self.pg_pool()?, - community, - pubkey, - expected_role, - ) - .await + relay_members::remove_relay_member_if_role(&self.pool, community, pubkey, expected_role) + .await } /// Updates the role of an existing relay member in `community`. Returns `true` if updated. @@ -4518,17 +4235,15 @@ impl Db { pubkey: &str, new_role: &str, ) -> Result { - relay_members::update_relay_member_role(self.pg_pool()?, community, pubkey, new_role).await + relay_members::update_relay_member_role(&self.pool, community, pubkey, new_role).await } /// Ensures the owner pubkey exists with role `"owner"` in `community`. Called at startup. pub async fn bootstrap_owner(&self, community: CommunityId, owner_pubkey: &str) -> Result<()> { - match &self.backend { - DbBackend::SQLite(pool) => sqlite::bootstrap_owner(pool, community, owner_pubkey).await, - DbBackend::Postgres => { - relay_members::bootstrap_owner(self.pg_pool()?, community, owner_pubkey).await - } + if let DbBackend::SQLite(pool) = &self.backend { + return sqlite::bootstrap_owner(pool, community, owner_pubkey).await; } + relay_members::bootstrap_owner(&self.pool, community, owner_pubkey).await } /// Returns `true` if any member of `community` holds the `admin` or @@ -4548,7 +4263,7 @@ impl Db { expected_owner_pubkey: &str, ) -> Result { relay_members::transfer_ownership( - self.pg_pool()?, + &self.pool, community, new_owner_pubkey, expected_owner_pubkey, @@ -4561,7 +4276,7 @@ impl Db { /// Idempotent — uses `ON CONFLICT DO NOTHING`. Returns the number of rows /// inserted, or 0 if the `pubkey_allowlist` table doesn't exist. pub async fn backfill_from_allowlist(&self, community: CommunityId) -> Result { - relay_members::backfill_from_allowlist(self.pg_pool()?, community).await + relay_members::backfill_from_allowlist(&self.pool, community).await } /// Mints a v2 use-limited relay invite. The plaintext code is returned @@ -4576,8 +4291,7 @@ impl Db { ttl_secs: u64, max_uses: Option, ) -> Result { - relay_invite::mint_relay_invite(self.pg_pool()?, community, created_by, ttl_secs, max_uses) - .await + relay_invite::mint_relay_invite(&self.pool, community, created_by, ttl_secs, max_uses).await } /// Delete one bounded batch of invites expired before `cutoff`. @@ -4585,7 +4299,7 @@ impl Db { &self, cutoff: chrono::DateTime, ) -> Result { - relay_invite::reap_expired_relay_invites(self.pg_pool()?, cutoff).await + relay_invite::reap_expired_relay_invites(&self.pool, cutoff).await } /// Atomically claims a v2 relay invite. The full redemption (membership @@ -4601,7 +4315,7 @@ impl Db { policy_version: Option<&str>, ) -> Result { relay_invite::claim_relay_invite( - self.pg_pool()?, + &self.pool, community, token_hash, claimer_pubkey, @@ -4616,7 +4330,7 @@ impl Db { community: CommunityId, feedback: product_feedback::NewProductFeedback<'_>, ) -> Result { - product_feedback::insert(self.pg_pool()?, community, feedback).await + product_feedback::insert(&self.pool, community, feedback).await } /// List product feedback across the deployment, newest first. @@ -4624,7 +4338,7 @@ impl Db { &self, limit: i64, ) -> Result> { - product_feedback::list(self.pg_pool()?, limit).await + product_feedback::list(&self.pool, limit).await } /// Insert a tenant-scoped NIP-56 report row, idempotent by report event id. @@ -4633,7 +4347,7 @@ impl Db { community: CommunityId, report: moderation::NewReport<'_>, ) -> Result { - moderation::insert_report(self.pg_pool()?, community, report).await + moderation::insert_report(&self.pool, community, report).await } /// List moderation reports for a community, newest first. @@ -4643,7 +4357,7 @@ impl Db { status: Option<&str>, limit: i64, ) -> Result> { - moderation::list_reports(self.pg_pool()?, community, status, limit).await + moderation::list_reports(&self.pool, community, status, limit).await } /// Fetch one moderation report by row id. @@ -4652,7 +4366,7 @@ impl Db { community: CommunityId, report_id: Uuid, ) -> Result> { - moderation::get_report(self.pg_pool()?, community, report_id).await + moderation::get_report(&self.pool, community, report_id).await } /// Fetch one moderation report by signed NIP-56 report event id. @@ -4661,7 +4375,7 @@ impl Db { community: CommunityId, report_event_id: &[u8], ) -> Result> { - moderation::get_report_by_event(self.pg_pool()?, community, report_event_id).await + moderation::get_report_by_event(&self.pool, community, report_event_id).await } /// Resolve, dismiss, or escalate an open moderation report. @@ -4674,7 +4388,7 @@ impl Db { action_id: Option, ) -> Result { moderation::resolve_report( - self.pg_pool()?, + &self.pool, community, report_id, status, @@ -4693,15 +4407,7 @@ impl Db { reason: Option<&str>, expires_at: Option>, ) -> Result<()> { - moderation::ban_member( - self.pg_pool()?, - community, - pubkey, - actor, - reason, - expires_at, - ) - .await + moderation::ban_member(&self.pool, community, pubkey, actor, reason, expires_at).await } /// Lift a community ban for a member pubkey. @@ -4711,7 +4417,7 @@ impl Db { pubkey: &[u8], actor: &[u8], ) -> Result { - moderation::unban_member(self.pg_pool()?, community, pubkey, actor).await + moderation::unban_member(&self.pool, community, pubkey, actor).await } /// Upsert a community timeout/write-block for a member pubkey. @@ -4723,15 +4429,7 @@ impl Db { muted_until: DateTime, reason: Option<&str>, ) -> Result<()> { - moderation::timeout_member( - self.pg_pool()?, - community, - pubkey, - actor, - muted_until, - reason, - ) - .await + moderation::timeout_member(&self.pool, community, pubkey, actor, muted_until, reason).await } /// Clear a community timeout/write-block for a member pubkey. @@ -4741,7 +4439,7 @@ impl Db { pubkey: &[u8], actor: &[u8], ) -> Result { - moderation::untimeout_member(self.pg_pool()?, community, pubkey, actor).await + moderation::untimeout_member(&self.pool, community, pubkey, actor).await } /// Fetch the active ban/timeout restriction state for enforcement hot paths. @@ -4750,12 +4448,7 @@ impl Db { community: CommunityId, pubkey: &[u8], ) -> Result { - match &self.backend { - DbBackend::SQLite(_) => Ok(moderation::RestrictionState::default()), - DbBackend::Postgres => { - moderation::restriction_state(self.pg_pool()?, community, pubkey).await - } - } + moderation::restriction_state(&self.pool, community, pubkey).await } /// Fetch the full ban/timeout row for a member pubkey. @@ -4764,7 +4457,7 @@ impl Db { community: CommunityId, pubkey: &[u8], ) -> Result> { - moderation::get_ban(self.pg_pool()?, community, pubkey).await + moderation::get_ban(&self.pool, community, pubkey).await } /// List currently restricted members in a community. @@ -4772,7 +4465,7 @@ impl Db { &self, community: CommunityId, ) -> Result> { - moderation::list_restricted(self.pg_pool()?, community).await + moderation::list_restricted(&self.pool, community).await } /// Insert a moderation audit action row. @@ -4781,7 +4474,7 @@ impl Db { community: CommunityId, action: moderation::NewAction<'_>, ) -> Result { - moderation::insert_action(self.pg_pool()?, community, action).await + moderation::insert_action(&self.pool, community, action).await } /// List moderation audit action rows, newest first. @@ -4790,7 +4483,7 @@ impl Db { community: CommunityId, limit: i64, ) -> Result> { - moderation::list_actions(self.pg_pool()?, community, limit).await + moderation::list_actions(&self.pool, community, limit).await } /// Return the current owner of git repo name `repo_id` in `community`, or @@ -4800,7 +4493,7 @@ impl Db { community: CommunityId, repo_id: &str, ) -> Result> { - git_repo::repo_name_owner(self.pg_pool()?, community, repo_id).await + git_repo::repo_name_owner(&self.pool, community, repo_id).await } /// Reserve a git repo name for `owner_pubkey` in `community` (NIP-34). @@ -4813,7 +4506,7 @@ impl Db { repo_id: &str, owner_pubkey: &str, ) -> Result { - git_repo::reserve_repo_name(self.pg_pool()?, community, repo_id, owner_pubkey).await + git_repo::reserve_repo_name(&self.pool, community, repo_id, owner_pubkey).await } /// Count git repos reserved by `owner_pubkey` in `community` (quota check). @@ -4822,7 +4515,7 @@ impl Db { community: CommunityId, owner_pubkey: &str, ) -> Result { - git_repo::count_repos_for_owner(self.pg_pool()?, community, owner_pubkey).await + git_repo::count_repos_for_owner(&self.pool, community, owner_pubkey).await } /// Release a git repo name reservation held by `owner_pubkey` (rollback). @@ -4834,12 +4527,12 @@ impl Db { repo_id: &str, owner_pubkey: &str, ) -> Result { - git_repo::release_repo_name(self.pg_pool()?, community, repo_id, owner_pubkey).await + git_repo::release_repo_name(&self.pool, community, repo_id, owner_pubkey).await } /// Returns `true` if `pubkey` (64-char hex) is archived in `community_id`. pub async fn is_archived(&self, community_id: CommunityId, pubkey: &str) -> Result { - archived_identities::is_archived(self.pg_pool()?, community_id, pubkey).await + archived_identities::is_archived(&self.pool, community_id, pubkey).await } /// Archives an identity in `community_id`. Returns `true` if inserted, `false` if already archived. @@ -4855,7 +4548,7 @@ impl Db { request_event_id: &str, ) -> Result { archived_identities::archive( - self.pg_pool()?, + &self.pool, community_id, pubkey, consent_path, @@ -4869,7 +4562,7 @@ impl Db { /// Unarchives an identity from `community_id`. Returns `true` if deleted, `false` if absent. pub async fn unarchive(&self, community_id: CommunityId, pubkey: &str) -> Result { - archived_identities::unarchive(self.pg_pool()?, community_id, pubkey).await + archived_identities::unarchive(&self.pool, community_id, pubkey).await } /// Returns all identities archived in `community_id`, ordered by archive time ascending. @@ -4877,7 +4570,7 @@ impl Db { &self, community_id: CommunityId, ) -> Result> { - archived_identities::list_archived(self.pg_pool()?, community_id).await + archived_identities::list_archived(&self.pool, community_id).await } /// Soft-delete NIP-29 discovery events for a channel created by a specific relay pubkey. @@ -4894,7 +4587,7 @@ impl Db { .bind(community_id.as_uuid()) .bind(channel_id) .bind(relay_pubkey) - .execute(self.pg_pool()?) + .execute(&self.pool) .await?; Ok(result.rows_affected()) } @@ -4912,9 +4605,6 @@ impl Db { event: &nostr::Event, channel_id: Option, ) -> Result<(StoredEvent, bool)> { - if let DbBackend::SQLite(pool) = &self.backend { - return sqlite::replace_event(pool, community_id, event, channel_id, None).await; - } 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; @@ -4929,7 +4619,7 @@ impl Db { channel_id.as_ref().map(|id| id.as_bytes().as_slice()), ); - let mut tx = self.pg_pool()?.begin().await?; + let mut tx = self.pool.begin().await?; // Serialize all writers for the same (kind, pubkey, channel_id) tuple. // Advisory lock is transaction-scoped — released on commit/rollback. @@ -5025,9 +4715,7 @@ impl Db { // Mentions are a denormalized index — safe outside the transaction. // insert_event() normally handles this, but we inlined the INSERT above. - if let Err(e) = - crate::insert_mentions(self.pg_pool()?, community_id, event, channel_id).await - { + if let Err(e) = crate::insert_mentions(&self.pool, community_id, event, channel_id).await { tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); } @@ -5106,7 +4794,7 @@ impl Db { let lock_key = event_replacement_lock_key(community_id, kind_i32, pubkey_bytes.as_slice(), None); - let mut tx = self.pg_pool()?.begin().await?; + let mut tx = self.pool.begin().await?; // Acquire the per-community snapshot lock BEFORE reading members. // This serializes the entire read-build-write cycle: a concurrent @@ -5201,7 +4889,7 @@ impl Db { tx.commit().await?; - if let Err(e) = crate::insert_mentions(self.pg_pool()?, community_id, &event, None).await { + if let Err(e) = crate::insert_mentions(&self.pool, community_id, &event, None).await { tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); } @@ -5240,9 +4928,6 @@ impl Db { d_tag: &str, channel_id: Option, ) -> Result<(StoredEvent, bool)> { - if let DbBackend::SQLite(pool) = &self.backend { - return sqlite::replace_event(pool, community_id, event, channel_id, Some(d_tag)).await; - } 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; @@ -5256,7 +4941,7 @@ impl Db { Some(d_tag.as_bytes()), ); - let mut tx = self.pg_pool()?.begin().await?; + let mut tx = self.pool.begin().await?; sqlx::query("SELECT pg_advisory_xact_lock($1)") .bind(lock_key) @@ -5441,9 +5126,7 @@ impl Db { tx.commit().await?; // Mentions are a denormalized index — safe outside the transaction. - if let Err(e) = - crate::insert_mentions(self.pg_pool()?, community_id, event, channel_id).await - { + if let Err(e) = crate::insert_mentions(&self.pool, community_id, event, channel_id).await { tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); } @@ -7059,7 +6742,7 @@ mod tests { let db = Db::from_pool(pool); assert!(!db.has_read_pool()); assert!( - std::ptr::eq(db.read().expect("Postgres test DB"), &db.pool), + std::ptr::eq(db.read(), &db.pool), "read() must be the writer pool when no replica is configured" ); assert!(db.read_pool_stats().is_none()); diff --git a/crates/buzz-db/src/sqlite.rs b/crates/buzz-db/src/sqlite.rs index c3e4dfdc8f..2c2e6f6358 100644 --- a/crates/buzz-db/src/sqlite.rs +++ b/crates/buzz-db/src/sqlite.rs @@ -1,7 +1,7 @@ -//! SQLite storage for the single-node relay profile. +//! SQLite storage for the single-node community profile. //! -//! This module deliberately contains a separate, minimal schema rather than -//! attempting to translate the production PostgreSQL migrations. +//! This intentionally covers only community bootstrap, channels, memberships, +//! and relay ownership. Event and user persistence lands with PR 2. use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use sqlx::{Row, SqlitePool}; @@ -32,21 +32,6 @@ CREATE TABLE IF NOT EXISTS relay_members ( FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE ); -CREATE TABLE IF NOT EXISTS users ( - community_id TEXT NOT NULL, - pubkey BLOB NOT NULL, - display_name TEXT, - avatar_url TEXT, - about TEXT, - nip05_handle TEXT, - channel_add_policy TEXT NOT NULL DEFAULT 'anyone', - is_agent INTEGER NOT NULL DEFAULT 0, - agent_owner_pubkey BLOB, - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - PRIMARY KEY (community_id, pubkey), - FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE -); - CREATE TABLE IF NOT EXISTS channels ( id TEXT PRIMARY KEY NOT NULL, community_id TEXT NOT NULL, @@ -86,52 +71,6 @@ CREATE TABLE IF NOT EXISTS channel_members ( PRIMARY KEY (channel_id, pubkey), FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE ); - -CREATE TABLE IF NOT EXISTS moderation_restrictions ( - community_id TEXT NOT NULL, - pubkey TEXT NOT NULL COLLATE NOCASE, - restriction_type TEXT NOT NULL, - expires_at INTEGER, - PRIMARY KEY (community_id, pubkey, restriction_type), - FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS events ( - community_id TEXT NOT NULL, - id BLOB NOT NULL, - pubkey BLOB NOT NULL, - created_at INTEGER NOT NULL, - kind INTEGER NOT NULL, - tags_json TEXT NOT NULL, - content TEXT NOT NULL, - sig BLOB NOT NULL, - channel_id TEXT, - received_at INTEGER NOT NULL, - event_json TEXT NOT NULL, - PRIMARY KEY (community_id, id), - FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS idx_events_community_created - ON events (community_id, created_at DESC, id); -CREATE INDEX IF NOT EXISTS idx_events_community_kind - ON events (community_id, kind, created_at DESC); -CREATE INDEX IF NOT EXISTS idx_events_channel_created - ON events (community_id, channel_id, created_at DESC); - -CREATE TABLE IF NOT EXISTS reactions ( - community_id TEXT NOT NULL, - event_created_at INTEGER NOT NULL, - event_id BLOB NOT NULL, - pubkey BLOB NOT NULL, - emoji TEXT NOT NULL, - reaction_event_id BLOB, - removed_at INTEGER, - PRIMARY KEY (community_id, event_created_at, event_id, pubkey, emoji), - FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE -); -CREATE UNIQUE INDEX IF NOT EXISTS idx_reactions_source_event - ON reactions (community_id, reaction_event_id) - WHERE reaction_event_id IS NOT NULL; "#; pub(crate) async fn connect(path_or_url: &str) -> Result { @@ -152,78 +91,9 @@ pub(crate) async fn connect(path_or_url: &str) -> Result { } pub(crate) async fn migrate(pool: &SqlitePool) -> Result<()> { - let had_application_schema = sqlx::query_scalar::<_, i64>( - "SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'channels'", - ) - .fetch_one(pool) - .await? - != 0; - for statement in SCHEMA.split(';').map(str::trim).filter(|s| !s.is_empty()) { sqlx::query(statement).execute(pool).await?; } - sqlx::query( - "CREATE TABLE IF NOT EXISTS schema_version (singleton INTEGER PRIMARY KEY CHECK (singleton = 1), version INTEGER NOT NULL)", - ) - .execute(pool) - .await?; - let initial_version = if had_application_schema { 1_i64 } else { 2_i64 }; - sqlx::query( - "INSERT INTO schema_version (singleton, version) VALUES (1, ?1) ON CONFLICT(singleton) DO NOTHING", - ) - .bind(initial_version) - .execute(pool) - .await?; - - let mut version = - sqlx::query_scalar::<_, i64>("SELECT version FROM schema_version WHERE singleton = 1") - .fetch_one(pool) - .await?; - if version < 2 { - let mut tx = pool.begin().await?; - ensure_column_on( - &mut tx, - "channels", - "participant_hash", - "ALTER TABLE channels ADD COLUMN participant_hash BLOB", - ) - .await?; - ensure_column_on( - &mut tx, - "channel_members", - "hidden_at", - "ALTER TABLE channel_members ADD COLUMN hidden_at INTEGER", - ) - .await?; - sqlx::query("UPDATE schema_version SET version = 2 WHERE singleton = 1") - .execute(&mut *tx) - .await?; - tx.commit().await?; - version = 2; - } - if version != 2 { - return Err(crate::DbError::InvalidData(format!( - "unsupported SQLite schema version {version}" - ))); - } - Ok(()) -} - -async fn ensure_column_on( - tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, - table: &str, - column: &str, - alter_sql: &'static str, -) -> Result<()> { - let pragma = format!("PRAGMA table_info({table})"); - let exists = sqlx::query(sqlx::AssertSqlSafe(pragma)) - .fetch_all(&mut **tx) - .await? - .iter() - .any(|row| row.get::("name") == column); - if !exists { - sqlx::query(alter_sql).execute(&mut **tx).await?; - } Ok(()) } @@ -274,8 +144,8 @@ pub(crate) async fn rebind_single_node_community_host( // Defect-era databases may contain several loopback communities, one per // ephemeral port. Prefer a community owned by this desktop identity, then - // the one carrying the most events. This preserves the most useful UUID and - // its channels/messages while making the current Host header resolvable. + // the oldest community. This keeps ownership stable without depending on + // event persistence, which arrives with PR 2. let row = sqlx::query( r#"SELECT c.id, c.host FROM communities c @@ -283,11 +153,9 @@ pub(crate) async fn rebind_single_node_community_host( ON rm.community_id = c.id AND lower(rm.pubkey) = lower(?1) AND rm.role = 'owner' - LEFT JOIN events e ON e.community_id = c.id WHERE c.archived_at IS NULL AND c.host LIKE '127.0.0.1:%' - GROUP BY c.id, c.host, c.created_at - ORDER BY (rm.pubkey IS NOT NULL) DESC, count(e.id) DESC, c.created_at ASC, c.id ASC + ORDER BY CASE WHEN rm.pubkey IS NULL THEN 0 ELSE 1 END DESC, c.created_at ASC, c.id ASC LIMIT 1"#, ) .bind(owner_pubkey) @@ -369,487 +237,6 @@ pub(crate) async fn ensure_configured_community( created: inserted, }) } - -pub(crate) async fn insert_event( - pool: &SqlitePool, - community: CommunityId, - event: &nostr::Event, - channel_id: Option, -) -> Result<(buzz_core::StoredEvent, bool)> { - let kind = u32::from(event.kind.as_u16()); - if kind == buzz_core::kind::KIND_AUTH { - return Err(crate::DbError::AuthEventRejected); - } - if buzz_core::kind::is_ephemeral(kind) { - return Err(crate::DbError::EphemeralEventRejected(event.kind.as_u16())); - } - let received_at = chrono::Utc::now(); - let inserted = sqlx::query("INSERT INTO events (community_id, id, pubkey, created_at, kind, tags_json, content, sig, channel_id, received_at, event_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) ON CONFLICT DO NOTHING") - .bind(community.as_uuid().to_string()).bind(event.id.as_bytes().as_slice()).bind(event.pubkey.to_bytes().as_slice()) - .bind(event.created_at.as_secs() as i64).bind(event.kind.as_u16() as i32).bind(serde_json::to_string(&event.tags)?) - .bind(&event.content).bind(event.sig.serialize().as_slice()).bind(channel_id.map(|id| id.to_string())) - .bind(received_at.timestamp()).bind(serde_json::to_string(event)?).execute(pool).await?.rows_affected() == 1; - Ok(( - buzz_core::StoredEvent::with_received_at(event.clone(), received_at, channel_id, true), - inserted, - )) -} - -pub(crate) async fn replace_event( - pool: &SqlitePool, - community: CommunityId, - event: &nostr::Event, - channel_id: Option, - d_tag: Option<&str>, -) -> Result<(buzz_core::StoredEvent, bool)> { - let mut tx = pool.begin().await?; - let rows = sqlx::query("SELECT id, event_json FROM events WHERE community_id = ?1 AND kind = ?2 AND pubkey = ?3 AND channel_id IS ?4") - .bind(community.as_uuid().to_string()) - .bind(event.kind.as_u16() as i32) - .bind(event.pubkey.to_bytes().as_slice()) - .bind(channel_id.map(|id| id.to_string())) - .fetch_all(&mut *tx) - .await?; - let mut replaced_ids = Vec::new(); - for row in rows { - let existing: nostr::Event = serde_json::from_str(row.try_get("event_json")?)?; - let existing_d = crate::event::extract_d_tag(&existing).unwrap_or_default(); - if d_tag.is_some_and(|expected| existing_d != expected) { - continue; - } - if event.created_at < existing.created_at - || (event.created_at == existing.created_at && event.id <= existing.id) - { - return Ok(( - buzz_core::StoredEvent::with_received_at( - existing, - chrono::Utc::now(), - channel_id, - true, - ), - false, - )); - } - replaced_ids.push(row.try_get::, _>("id")?); - } - for id in replaced_ids { - sqlx::query("DELETE FROM events WHERE community_id = ?1 AND id = ?2") - .bind(community.as_uuid().to_string()) - .bind(id) - .execute(&mut *tx) - .await?; - } - let received_at = chrono::Utc::now(); - sqlx::query("INSERT INTO events (community_id, id, pubkey, created_at, kind, tags_json, content, sig, channel_id, received_at, event_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)") - .bind(community.as_uuid().to_string()).bind(event.id.as_bytes().as_slice()).bind(event.pubkey.to_bytes().as_slice()) - .bind(event.created_at.as_secs() as i64).bind(event.kind.as_u16() as i32).bind(serde_json::to_string(&event.tags)?) - .bind(&event.content).bind(event.sig.serialize().as_slice()).bind(channel_id.map(|id| id.to_string())) - .bind(received_at.timestamp()).bind(serde_json::to_string(event)?).execute(&mut *tx).await?; - tx.commit().await?; - Ok(( - buzz_core::StoredEvent::with_received_at(event.clone(), received_at, channel_id, true), - true, - )) -} - -pub(crate) async fn soft_delete_event( - pool: &SqlitePool, - community: CommunityId, - event_id: &[u8], -) -> Result { - Ok( - sqlx::query("DELETE FROM events WHERE community_id = ?1 AND id = ?2") - .bind(community.as_uuid().to_string()) - .bind(event_id) - .execute(pool) - .await? - .rows_affected() - != 0, - ) -} - -pub(crate) async fn get_event_by_id( - pool: &SqlitePool, - community: CommunityId, - id: &[u8], -) -> Result> { - let row = sqlx::query("SELECT event_json, received_at, channel_id FROM events WHERE community_id = ?1 AND id = ?2") - .bind(community.as_uuid().to_string()).bind(id).fetch_optional(pool).await?; - row.map(stored_event).transpose() -} - -pub(crate) async fn query_events( - pool: &SqlitePool, - q: &crate::EventQuery, -) -> Result> { - if q.before_id.is_some() && q.until.is_none() { - return Err(crate::DbError::InvalidData( - "before_id requires until to be set".into(), - )); - } - if q.global_only && q.channel_id.is_some() { - return Err(crate::DbError::InvalidData( - "global_only and channel_id are mutually exclusive".into(), - )); - } - if q.kinds.as_ref().is_some_and(Vec::is_empty) - || q.authors.as_ref().is_some_and(Vec::is_empty) - || q.ids.as_ref().is_some_and(Vec::is_empty) - || q.e_tags.as_ref().is_some_and(Vec::is_empty) - { - return Ok(vec![]); - } - let rows = sqlx::query("SELECT event_json, received_at, channel_id FROM events WHERE community_id = ?1 ORDER BY created_at DESC, id ASC") - .bind(q.community_id.as_uuid().to_string()).fetch_all(pool).await?; - let mut events = Vec::new(); - for row in rows { - let stored = stored_event(row)?; - let event = &stored.event; - let created = event.created_at.as_secs() as i64; - let id = event.id.as_bytes().as_slice(); - let tags: Vec> = event - .tags - .iter() - .map(|tag| tag.as_slice().to_vec()) - .collect(); - let has_tag = |name: &str, value: &str| { - tags.iter().any(|tag| { - tag.first().is_some_and(|v| v == name) && tag.get(1).is_some_and(|v| v == value) - }) - }; - if q.channel_id.is_some_and(|ch| stored.channel_id != Some(ch)) - || (q.global_only && stored.channel_id.is_some()) - { - continue; - } - if q.channel_ids - .as_ref() - .is_some_and(|ids| stored.channel_id.is_some_and(|id| !ids.contains(&id))) - { - continue; - } - if q.kinds - .as_ref() - .is_some_and(|ks| !ks.contains(&(event.kind.as_u16() as i32))) - { - continue; - } - if q.pubkey - .as_ref() - .is_some_and(|pk| pk.as_slice() != event.pubkey.to_bytes().as_slice()) - { - continue; - } - if q.authors.as_ref().is_some_and(|authors| { - !authors - .iter() - .any(|pk| pk.as_slice() == event.pubkey.to_bytes().as_slice()) - }) { - continue; - } - if q.ids - .as_ref() - .is_some_and(|ids| !ids.iter().any(|candidate| candidate.as_slice() == id)) - { - continue; - } - if q.since.is_some_and(|since| created < since.timestamp()) - || q.until.is_some_and(|until| created > until.timestamp()) - { - continue; - } - if q.before_id.as_ref().is_some_and(|before| { - q.until.is_some_and(|until| created == until.timestamp()) && id <= before.as_slice() - }) { - continue; - } - if q.p_tag_hex - .as_ref() - .is_some_and(|p| !has_tag("p", &p.to_ascii_lowercase())) - { - continue; - } - if q.e_tags - .as_ref() - .is_some_and(|values| !values.iter().any(|value| has_tag("e", value))) - { - continue; - } - let d_tag = tags - .iter() - .find(|tag| tag.first().is_some_and(|v| v == "d")) - .and_then(|tag| tag.get(1)); - if q.d_tag.as_ref().is_some_and(|d| d_tag != Some(d)) { - continue; - } - if q.d_tags - .as_ref() - .is_some_and(|ds| !d_tag.is_some_and(|d| ds.contains(d))) - { - continue; - } - if q.shared_gated_reader.as_ref().is_some_and(|reader| { - buzz_core::kind::SHARED_GATED_KINDS.contains(&(event.kind.as_u16() as u32)) - && reader.as_slice() != event.pubkey.to_bytes().as_slice() - && !has_tag("shared", "true") - }) { - continue; - } - events.push(stored); - } - let offset = q.offset.unwrap_or(0).max(0) as usize; - let limit = q - .limit - .unwrap_or(100) - .min(q.max_limit.unwrap_or(crate::DEFAULT_MAX_PAGE_LIMIT)) - .max(0) as usize; - Ok(events.into_iter().skip(offset).take(limit).collect()) -} - -pub(crate) async fn query_feed_mentions( - pool: &SqlitePool, - community: CommunityId, - pubkey_bytes: &[u8], - accessible_channel_ids: &[Uuid], - since: Option>, - limit: i64, -) -> Result> { - let mut query = crate::EventQuery::for_community(community); - query.kinds = Some(vec![ - buzz_core::kind::KIND_STREAM_MESSAGE as i32, - buzz_core::kind::KIND_STREAM_MESSAGE_V2 as i32, - buzz_core::kind::KIND_TEXT_NOTE as i32, - buzz_core::kind::KIND_FORUM_POST as i32, - buzz_core::kind::KIND_FORUM_COMMENT as i32, - buzz_core::kind::KIND_GIT_PULL_REQUEST as i32, - buzz_core::kind::KIND_GIT_PR_UPDATE as i32, - buzz_core::kind::KIND_GIT_ISSUE as i32, - buzz_core::kind::KIND_GIT_STATUS_OPEN as i32, - buzz_core::kind::KIND_GIT_STATUS_MERGED as i32, - buzz_core::kind::KIND_GIT_STATUS_CLOSED as i32, - buzz_core::kind::KIND_GIT_STATUS_DRAFT as i32, - ]); - query.p_tag_hex = Some(hex::encode(pubkey_bytes)); - query.channel_ids = Some(accessible_channel_ids.to_vec()); - query.since = since; - query.limit = Some(limit.min(crate::feed::FEED_MAX_LIMIT)); - query_events(pool, &query).await -} - -pub(crate) async fn query_feed_needs_action( - pool: &SqlitePool, - community: CommunityId, - pubkey_bytes: &[u8], - accessible_channel_ids: &[Uuid], - since: Option>, - limit: i64, -) -> Result> { - let mut query = crate::EventQuery::for_community(community); - query.kinds = Some(vec![ - buzz_core::kind::KIND_WORKFLOW_APPROVAL_REQUESTED as i32, - buzz_core::kind::KIND_STREAM_REMINDER as i32, - ]); - query.p_tag_hex = Some(hex::encode(pubkey_bytes)); - query.channel_ids = Some(accessible_channel_ids.to_vec()); - query.since = since; - query.limit = Some(limit.min(crate::feed::FEED_MAX_LIMIT)); - query_events(pool, &query).await -} - -pub(crate) async fn query_feed_activity( - pool: &SqlitePool, - community: CommunityId, - accessible_channel_ids: &[Uuid], - since: Option>, - limit: i64, -) -> Result> { - let mut query = crate::EventQuery::for_community(community); - query.kinds = Some(vec![ - buzz_core::kind::KIND_STREAM_MESSAGE as i32, - buzz_core::kind::KIND_STREAM_MESSAGE_V2 as i32, - buzz_core::kind::KIND_FORUM_POST as i32, - buzz_core::kind::KIND_JOB_REQUEST as i32, - buzz_core::kind::KIND_JOB_PROGRESS as i32, - buzz_core::kind::KIND_JOB_RESULT as i32, - ]); - query.channel_ids = Some(accessible_channel_ids.to_vec()); - query.since = since; - query.limit = Some(limit.min(crate::feed::FEED_MAX_LIMIT)); - query_events(pool, &query).await -} - -pub(crate) async fn insert_reaction_event( - pool: &SqlitePool, - community: CommunityId, - reaction_event: &nostr::Event, - channel_id: Option, - target_event_id: &[u8], - actor_pubkey: &[u8], - emoji: &str, -) -> Result { - let mut tx = pool.begin().await?; - let target_created_at: Option = sqlx::query_scalar( - "SELECT created_at FROM events WHERE community_id = ?1 AND id = ?2 LIMIT 1", - ) - .bind(community.as_uuid().to_string()) - .bind(target_event_id) - .fetch_optional(&mut *tx) - .await?; - let Some(target_created_at) = target_created_at else { - return Ok(crate::event::ReactionEventInsertOutcome::TargetMissing); - }; - let changed = sqlx::query("INSERT INTO reactions (community_id, event_created_at, event_id, pubkey, emoji, reaction_event_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT (community_id, event_created_at, event_id, pubkey, emoji) DO UPDATE SET removed_at = NULL, reaction_event_id = excluded.reaction_event_id WHERE reactions.removed_at IS NOT NULL") - .bind(community.as_uuid().to_string()).bind(target_created_at).bind(target_event_id) - .bind(actor_pubkey).bind(emoji).bind(reaction_event.id.as_bytes().as_slice()) - .execute(&mut *tx).await?.rows_affected() != 0; - if !changed { - return Ok(crate::event::ReactionEventInsertOutcome::Duplicate); - } - let received_at = chrono::Utc::now(); - let inserted = sqlx::query("INSERT INTO events (community_id, id, pubkey, created_at, kind, tags_json, content, sig, channel_id, received_at, event_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) ON CONFLICT DO NOTHING") - .bind(community.as_uuid().to_string()).bind(reaction_event.id.as_bytes().as_slice()).bind(reaction_event.pubkey.to_bytes().as_slice()) - .bind(reaction_event.created_at.as_secs() as i64).bind(reaction_event.kind.as_u16() as i32).bind(serde_json::to_string(&reaction_event.tags)?) - .bind(&reaction_event.content).bind(reaction_event.sig.serialize().as_slice()).bind(channel_id.map(|id| id.to_string())) - .bind(received_at.timestamp()).bind(serde_json::to_string(reaction_event)?).execute(&mut *tx).await?.rows_affected() == 1; - tx.commit().await?; - Ok(crate::event::ReactionEventInsertOutcome::Inserted { - stored_event: Box::new(buzz_core::StoredEvent::with_received_at( - reaction_event.clone(), - received_at, - channel_id, - true, - )), - was_inserted: inserted, - }) -} - -pub(crate) async fn remove_reaction( - pool: &SqlitePool, - community: CommunityId, - event_id: &[u8], - event_created_at: chrono::DateTime, - pubkey: &[u8], - emoji: &str, -) -> Result { - Ok(sqlx::query("UPDATE reactions SET removed_at = unixepoch() WHERE community_id = ?1 AND event_created_at = ?2 AND event_id = ?3 AND pubkey = ?4 AND emoji = ?5 AND removed_at IS NULL") - .bind(community.as_uuid().to_string()).bind(event_created_at.timestamp()).bind(event_id).bind(pubkey).bind(emoji) - .execute(pool).await?.rows_affected() != 0) -} - -pub(crate) async fn remove_reaction_by_source_event_id( - pool: &SqlitePool, - community: CommunityId, - reaction_event_id: &[u8], -) -> Result { - Ok(sqlx::query("UPDATE reactions SET removed_at = unixepoch() WHERE community_id = ?1 AND reaction_event_id = ?2 AND removed_at IS NULL") - .bind(community.as_uuid().to_string()).bind(reaction_event_id) - .execute(pool).await?.rows_affected() != 0) -} - -pub(crate) async fn open_dm( - pool: &SqlitePool, - community: CommunityId, - pubkeys: &[&[u8]], - created_by: &[u8], - command_event: Option<&nostr::Event>, -) -> Result> { - let mut participants = pubkeys.to_vec(); - if !participants.contains(&created_by) { - participants.push(created_by); - } - participants.sort_unstable(); - participants.dedup(); - if !(2..=9).contains(&participants.len()) { - return Err(crate::DbError::InvalidData( - "DM requires 2-9 unique participants".into(), - )); - } - if participants.iter().any(|pubkey| pubkey.len() != 32) { - return Err(crate::DbError::InvalidData( - "DM participant pubkeys must be 32 bytes".into(), - )); - } - let hash = crate::dm::compute_participant_hash(&participants); - let mut tx = pool.begin().await?; - if let Some(event) = command_event { - let received_at = chrono::Utc::now(); - let inserted = sqlx::query("INSERT INTO events (community_id, id, pubkey, created_at, kind, tags_json, content, sig, channel_id, received_at, event_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, ?9, ?10) ON CONFLICT DO NOTHING") - .bind(community.as_uuid().to_string()).bind(event.id.as_bytes().as_slice()).bind(event.pubkey.to_bytes().as_slice()) - .bind(event.created_at.as_secs() as i64).bind(event.kind.as_u16() as i32).bind(serde_json::to_string(&event.tags)?) - .bind(&event.content).bind(event.sig.serialize().as_slice()).bind(received_at.timestamp()).bind(serde_json::to_string(event)?) - .execute(&mut *tx).await?.rows_affected() != 0; - if !inserted { - return Ok(None); - } - } - let select = "SELECT id, name, channel_type, visibility, description, canvas, created_by, created_at, updated_at, archived_at, deleted_at, nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, ttl_seconds, ttl_deadline FROM channels WHERE community_id = ?1 AND participant_hash = ?2 AND channel_type = 'dm' AND deleted_at IS NULL LIMIT 1"; - if let Some(row) = sqlx::query(select) - .bind(community.as_uuid().to_string()) - .bind(hash.as_slice()) - .fetch_optional(&mut *tx) - .await? - { - sqlx::query("UPDATE channel_members SET hidden_at = NULL WHERE channel_id = ?1 AND pubkey = ?2 AND removed_at IS NULL") - .bind(row.try_get::("id")?) - .bind(created_by) - .execute(&mut *tx) - .await?; - let record = channel_record(row)?; - tx.commit().await?; - return Ok(Some((record, false))); - } - - let id = Uuid::new_v4(); - let name = if participants.len() == 2 { - "DM".to_owned() - } else { - format!("Group DM ({})", participants.len()) - }; - sqlx::query("INSERT INTO channels (id, community_id, name, channel_type, visibility, participant_hash, created_by) VALUES (?1, ?2, ?3, 'dm', 'private', ?4, ?5)") - .bind(id.to_string()) - .bind(community.as_uuid().to_string()) - .bind(name) - .bind(hash.as_slice()) - .bind(created_by) - .execute(&mut *tx) - .await?; - for participant in participants { - sqlx::query("INSERT INTO channel_members (channel_id, pubkey, role, invited_by) VALUES (?1, ?2, 'member', ?3)") - .bind(id.to_string()) - .bind(participant) - .bind(created_by) - .execute(&mut *tx) - .await?; - } - let row = sqlx::query(select) - .bind(community.as_uuid().to_string()) - .bind(hash.as_slice()) - .fetch_one(&mut *tx) - .await?; - let record = channel_record(row)?; - tx.commit().await?; - Ok(Some((record, true))) -} - -fn stored_event(row: sqlx::sqlite::SqliteRow) -> Result { - let json: String = row.try_get("event_json")?; - let event: nostr::Event = serde_json::from_str(&json)?; - let received: i64 = row.try_get("received_at")?; - let channel: Option = row.try_get("channel_id")?; - let channel_id = channel - .map(|id| { - Uuid::parse_str(&id) - .map_err(|e| crate::DbError::InvalidData(format!("invalid SQLite channel id: {e}"))) - }) - .transpose()?; - Ok(buzz_core::StoredEvent::with_received_at( - event, - timestamp(received)?, - channel_id, - true, - )) -} - #[allow(clippy::too_many_arguments)] pub(crate) async fn create_channel_with_id( pool: &SqlitePool, @@ -960,38 +347,6 @@ pub(crate) async fn get_accessible_channel_ids( .collect() } -pub(crate) async fn communities_of_channels( - pool: &SqlitePool, - channel_ids: &[Uuid], -) -> Result> { - let mut out = std::collections::HashMap::with_capacity(channel_ids.len()); - for channel_id in channel_ids { - let row = sqlx::query_scalar::<_, String>( - "SELECT community_id FROM channels WHERE id = ?1 AND deleted_at IS NULL", - ) - .bind(channel_id.to_string()) - .fetch_optional(pool) - .await?; - if let Some(community_id) = row { - let community_id = Uuid::parse_str(&community_id).map_err(|e| { - crate::DbError::InvalidData(format!("invalid SQLite community id: {e}")) - })?; - out.insert(*channel_id, CommunityId::from_uuid(community_id)); - } - } - Ok(out) -} - -pub(crate) async fn get_member_role( - pool: &SqlitePool, - community: CommunityId, - channel_id: Uuid, - pubkey: &[u8], -) -> Result> { - Ok(sqlx::query_scalar("SELECT cm.role FROM channel_members cm JOIN channels c ON c.id = cm.channel_id WHERE c.community_id = ?1 AND c.id = ?2 AND cm.pubkey = ?3 AND cm.removed_at IS NULL AND c.deleted_at IS NULL") - .bind(community.as_uuid().to_string()).bind(channel_id.to_string()).bind(pubkey).fetch_optional(pool).await?) -} - fn timestamp(value: i64) -> Result> { chrono::DateTime::from_timestamp(value, 0).ok_or(crate::DbError::InvalidTimestamp(value)) } @@ -1038,117 +393,6 @@ fn member_record(row: sqlx::sqlite::SqliteRow) -> Result Result { - Ok(sqlx::query( - "INSERT INTO users (community_id, pubkey) VALUES (?1, ?2) ON CONFLICT DO NOTHING", - ) - .bind(community.as_uuid().to_string()) - .bind(pubkey) - .execute(pool) - .await? - .rows_affected() - == 1) -} - -pub(crate) async fn update_user_profile( - pool: &SqlitePool, - community: CommunityId, - pubkey: &[u8], - display_name: Option<&str>, - avatar_url: Option<&str>, - about: Option<&str>, - nip05_handle: Option<&str>, -) -> Result<()> { - sqlx::query( - "UPDATE users SET display_name = COALESCE(?3, display_name), avatar_url = COALESCE(?4, avatar_url), about = COALESCE(?5, about), nip05_handle = COALESCE(?6, nip05_handle) WHERE community_id = ?1 AND pubkey = ?2", - ) - .bind(community.as_uuid().to_string()) - .bind(pubkey) - .bind(display_name) - .bind(avatar_url) - .bind(about) - .bind(nip05_handle) - .execute(pool) - .await?; - Ok(()) -} - -pub(crate) async fn get_user( - pool: &SqlitePool, - community: CommunityId, - pubkey: &[u8], -) -> Result> { - let row = sqlx::query( - "SELECT pubkey, display_name, avatar_url, about, nip05_handle FROM users WHERE community_id = ?1 AND pubkey = ?2", - ) - .bind(community.as_uuid().to_string()) - .bind(pubkey) - .fetch_optional(pool) - .await?; - row.map(|row| { - Ok(crate::user::UserProfile { - pubkey: row.try_get("pubkey")?, - display_name: row.try_get("display_name")?, - avatar_url: row.try_get("avatar_url")?, - about: row.try_get("about")?, - nip05_handle: row.try_get("nip05_handle")?, - }) - }) - .transpose() -} - -pub(crate) async fn set_agent_owner( - pool: &SqlitePool, - community: CommunityId, - agent_pubkey: &[u8], - owner_pubkey: &[u8], -) -> Result { - Ok(sqlx::query( - "UPDATE users SET agent_owner_pubkey = ?3, is_agent = 1 WHERE community_id = ?1 AND pubkey = ?2 AND agent_owner_pubkey IS NULL", - ) - .bind(community.as_uuid().to_string()) - .bind(agent_pubkey) - .bind(owner_pubkey) - .execute(pool) - .await? - .rows_affected() == 1) -} - -pub(crate) async fn get_agent_channel_policy( - pool: &SqlitePool, - community: CommunityId, - pubkey: &[u8], -) -> Result>)>> { - Ok(sqlx::query_as( - "SELECT channel_add_policy, agent_owner_pubkey FROM users WHERE community_id = ?1 AND pubkey = ?2", - ) - .bind(community.as_uuid().to_string()) - .bind(pubkey) - .fetch_optional(pool) - .await?) -} - -pub(crate) async fn is_agent_owner( - pool: &SqlitePool, - community: CommunityId, - target_pubkey: &[u8], - actor_pubkey: &[u8], -) -> Result { - Ok(sqlx::query_scalar::<_, i64>( - "SELECT count(*) FROM users WHERE community_id = ?1 AND pubkey = ?2 AND agent_owner_pubkey = ?3", - ) - .bind(community.as_uuid().to_string()) - .bind(target_pubkey) - .bind(actor_pubkey) - .fetch_one(pool) - .await? != 0) -} - pub(crate) async fn is_relay_member( pool: &SqlitePool, community: CommunityId, @@ -1264,63 +508,16 @@ fn community_record(row: sqlx::sqlite::SqliteRow) -> Result { #[cfg(test)] mod tests { use super::*; - use nostr::{EventBuilder, Keys, Kind}; + use nostr::Keys; #[tokio::test] - async fn upgrades_phase_1_core_schema_before_dm_use() { - let path = std::env::temp_dir().join(format!("buzz-db-upgrade-{}.sqlite", Uuid::new_v4())); - let options = SqliteConnectOptions::from_str(&format!("sqlite://{}", path.display())) - .unwrap() - .create_if_missing(true) - .foreign_keys(true); - let pool = SqlitePoolOptions::new() - .max_connections(1) - .connect_with(options) - .await - .unwrap(); - sqlx::query("CREATE TABLE communities (id TEXT PRIMARY KEY NOT NULL, host TEXT NOT NULL COLLATE NOCASE UNIQUE, icon TEXT, created_at INTEGER NOT NULL DEFAULT (unixepoch()), archived_at INTEGER)") - .execute(&pool).await.unwrap(); - sqlx::query("CREATE TABLE channels (id TEXT PRIMARY KEY NOT NULL, community_id TEXT NOT NULL, name TEXT NOT NULL, description TEXT, canvas TEXT, channel_type TEXT NOT NULL, visibility TEXT NOT NULL, created_by BLOB NOT NULL, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()), archived_at INTEGER, deleted_at INTEGER, nip29_group_id TEXT, topic_required INTEGER NOT NULL DEFAULT 0, max_members INTEGER, topic TEXT, topic_set_by BLOB, topic_set_at INTEGER, purpose TEXT, purpose_set_by BLOB, purpose_set_at INTEGER, ttl_seconds INTEGER, ttl_deadline INTEGER)") - .execute(&pool).await.unwrap(); - sqlx::query("CREATE TABLE channel_members (channel_id TEXT NOT NULL, pubkey BLOB NOT NULL, role TEXT NOT NULL, joined_at INTEGER NOT NULL DEFAULT (unixepoch()), invited_by BLOB, removed_at INTEGER, PRIMARY KEY (channel_id, pubkey))") - .execute(&pool).await.unwrap(); - pool.close().await; - - let upgraded = connect(path.to_str().unwrap()).await.unwrap(); - assert_eq!( - sqlx::query_scalar::<_, i64>("SELECT version FROM schema_version WHERE singleton = 1") - .fetch_one(&upgraded) - .await - .unwrap(), - 2 - ); - for (table, column) in [ - ("channels", "participant_hash"), - ("channel_members", "hidden_at"), - ] { - let pragma = format!("PRAGMA table_info({table})"); - assert!(sqlx::query(sqlx::AssertSqlSafe(pragma)) - .fetch_all(&upgraded) - .await - .unwrap() - .iter() - .any(|row| row.get::("name") == column)); - } - upgraded.close().await; - std::fs::remove_file(path).unwrap(); - } - - #[tokio::test] - async fn core_slice_survives_temporary_file_reopen() { + async fn community_slice_survives_temporary_file_reopen() { let path = std::env::temp_dir().join(format!("buzz-db-{}.sqlite", Uuid::new_v4())); let path_string = path.to_string_lossy().into_owned(); let owner = Keys::generate(); let owner_bytes = owner.public_key().to_bytes(); let owner_hex = owner.public_key().to_hex(); let channel_id = Uuid::new_v4(); - let event = EventBuilder::new(Kind::Custom(9), "durable message") - .sign_with_keys(&owner) - .unwrap(); let pool = connect(&path_string).await.unwrap(); let ensured = ensure_configured_community(&pool, "Local.Buzz") @@ -1329,7 +526,6 @@ mod tests { bootstrap_owner(&pool, ensured.id, &owner_hex) .await .unwrap(); - assert!(ensure_user(&pool, ensured.id, &owner_bytes).await.unwrap()); let (channel, created) = create_channel_with_id( &pool, ensured.id, @@ -1348,12 +544,6 @@ mod tests { assert!(is_member(&pool, ensured.id, channel_id, &owner_bytes) .await .unwrap()); - assert!( - insert_event(&pool, ensured.id, &event, Some(channel_id)) - .await - .unwrap() - .1 - ); pool.close().await; let reopened = connect(&path_string).await.unwrap(); @@ -1372,16 +562,32 @@ mod tests { .unwrap(), vec![channel_id] ); - let stored = get_event_by_id(&reopened, ensured.id, event.id.as_bytes()) + reopened.close().await; + std::fs::remove_file(path).unwrap(); + } + + #[tokio::test] + async fn rebind_prefers_owned_then_oldest_community() { + let pool = connect(":memory:").await.unwrap(); + let first = ensure_configured_community(&pool, "127.0.0.1:4000") + .await + .unwrap(); + let second = ensure_configured_community(&pool, "127.0.0.1:4001") + .await + .unwrap(); + bootstrap_owner(&pool, second.id, "owner").await.unwrap(); + let rebound = rebind_single_node_community_host(&pool, "127.0.0.1:5000", "owner") .await .unwrap() .unwrap(); - assert_eq!(stored.event.content, "durable message"); - let mut query = crate::EventQuery::for_community(ensured.id); - query.channel_id = Some(channel_id); - query.kinds = Some(vec![9]); - assert_eq!(query_events(&reopened, &query).await.unwrap().len(), 1); - reopened.close().await; - std::fs::remove_file(path).unwrap(); + assert_eq!(rebound.id, second.id); + assert_eq!( + lookup_community_host(&pool, first.id) + .await + .unwrap() + .unwrap(), + "127.0.0.1:4000" + ); + assert_eq!(rebound.host, "127.0.0.1:5000"); } } diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 4981845b17..a118ff453f 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1732,13 +1732,7 @@ async fn handle_bridge_search( .search .search(&search_query) .await - .map_err(|e| match e { - buzz_search::SearchError::Unsupported => api_error( - StatusCode::NOT_IMPLEMENTED, - "unsupported_feature: search is unavailable in this runtime profile", - ), - other => internal_error(&format!("search error: {other}")), - })?; + .map_err(|e| internal_error(&format!("search error: {e}")))?; // Fetch full events from DB by ID. Hit ids are already raw 32-byte // arrays from the FTS layer — no hex decode. diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index 8f0cd0a250..2d82736807 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -80,23 +80,8 @@ pub async fn handle_command( enum PersistResult { /// Event was already processed — return idempotent success. Duplicate, - /// Event inserted — PostgreSQL remains open until mutations finish; SQLite - /// committed the idempotency row atomically before the local mutation. - Inserted(CommandTransaction), -} - -enum CommandTransaction { - Sqlite, - Postgres(sqlx::Transaction<'static, sqlx::Postgres>), -} - -impl CommandTransaction { - async fn commit(self) -> Result<(), sqlx::Error> { - match self { - Self::Sqlite => Ok(()), - Self::Postgres(tx) => tx.commit().await, - } - } + /// Event inserted — transaction is open, handler must commit after mutations. + Inserted(sqlx::Transaction<'static, sqlx::Postgres>), } /// Persist a command event inside a transaction. Returns the OPEN transaction @@ -120,12 +105,6 @@ async fn persist_command_event( ) -> Result { let channel_id = channel_id_override.or_else(|| extract_channel_id(event)); - if state.config.profile.is_single_node() { - return Err(IngestError::Rejected( - "unsupported_feature: this command is unavailable in the single-node profile".into(), - )); - } - let mut tx = state .db .begin_transaction() @@ -248,7 +227,7 @@ async fn persist_command_event( // Duplicate — rollback (implicit on drop) and signal idempotent success. Ok(PersistResult::Duplicate) } else { - Ok(PersistResult::Inserted(CommandTransaction::Postgres(tx))) + Ok(PersistResult::Inserted(tx)) } } @@ -366,35 +345,27 @@ async fn handle_dm_open( } } - // SQLite couples the command idempotency row and DM mutation in one - // transaction. PostgreSQL retains the existing open-transaction path. + // Persist the command event (idempotency) — returns open transaction + let tx = match persist_command_event(state, tenant, event, None).await? { + PersistResult::Duplicate => { + return Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message: "duplicate: already processed".into(), + }); + } + PersistResult::Inserted(tx) => tx, + }; + + // 4. Execute: open_dm let all_refs: Vec<&[u8]> = all_bytes.iter().map(|b| b.as_slice()).collect(); - let sqlite_result = state + let (channel, was_created) = state .db - .open_dm_sqlite_command(tenant.community(), &all_refs, &self_bytes, event) + .open_dm(tenant.community(), &all_refs, &self_bytes) .await .map_err(|e| IngestError::Internal(format!("error: db open_dm: {e}")))?; - let (channel, was_created, tx) = if let Some(result) = sqlite_result { - (result.0, result.1, CommandTransaction::Sqlite) - } else { - let tx = match persist_command_event(state, tenant, event, None).await? { - PersistResult::Duplicate => { - return Ok(IngestResult { - event_id: event.id.to_hex(), - accepted: true, - message: "duplicate: already processed".into(), - }); - } - PersistResult::Inserted(tx) => tx, - }; - let (channel, was_created) = state - .db - .open_dm(tenant.community(), &all_refs, &self_bytes) - .await - .map_err(|e| IngestError::Internal(format!("error: db open_dm: {e}")))?; - (channel, was_created, tx) - }; + // Commit: event + mutation succeeded atomically. tx.commit() .await .map_err(|e| IngestError::Internal(format!("error: commit transaction: {e}")))?; diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index cf295bdfa3..a67797385b 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -525,8 +525,7 @@ async fn dispatch_persistent_event_inner( .iter() .any(|t| t.as_slice().first().map(|s| s.as_str()) == Some("buzz:workflow")); - if !state.config.profile.is_single_node() - && !buzz_core::kind::is_workflow_execution_kind(kind_u32) + if !buzz_core::kind::is_workflow_execution_kind(kind_u32) && !buzz_core::kind::is_command_kind(kind_u32) && !is_relay_workflow_msg && kind_u32 != KIND_GIFT_WRAP diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index ac24e74c23..88a9f0c731 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -809,9 +809,6 @@ pub fn emit_live_thread_summary( channel_id: Uuid, root_id: Vec, ) { - if state.config.profile.is_single_node() { - return; - } let tenant = tenant.clone(); let state = Arc::clone(state); tokio::spawn(async move { @@ -2935,12 +2932,6 @@ pub async fn publish_nip43_membership_list( tenant: &TenantContext, state: &Arc, ) -> anyhow::Result<()> { - // The production implementation relies on a PostgreSQL advisory lock for - // snapshot serialization. Single-node still emits the durable per-member - // delta above, but deliberately omits this denormalized list snapshot. - if state.config.profile.is_single_node() { - return Ok(()); - } let started_at = std::time::Instant::now(); metrics::counter!("buzz_nip43_membership_publications_total", "result" => "attempted") .increment(1); @@ -2999,11 +2990,6 @@ async fn publish_nip43_delta( target_pubkey_hex: &str, label: &str, ) -> anyhow::Result<()> { - // These are globally scoped events. Keep local relay membership private to - // the loopback process rather than exposing deltas to every subscriber. - if state.config.profile.is_single_node() { - return Ok(()); - } let relay_pubkey_hex = state.relay_keypair.public_key().to_hex(); let tags = vec![ From d5967250b92f23d29449ec201725134dfb60cac4 Mon Sep 17 00:00:00 2001 From: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Date: Mon, 10 Aug 2026 15:41:24 -0400 Subject: [PATCH 11/16] test(db): gate SQLite community foundation Signed-off-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> --- Justfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Justfile b/Justfile index bff1c48fc4..598027c71d 100644 --- a/Justfile +++ b/Justfile @@ -306,6 +306,10 @@ test-unit: # #[ignore]d, so --lib runs only the infra-free set. Without this gate a # stray file in migrations/ or a broken lint ships green. cargo nextest run -p buzz-db --lib + # Embedded single-node community foundation. This is intentionally + # narrower than the PostgreSQL suite: only SQLite community, channel, + # membership, and relay-owner durability belongs to PR 1A. + cargo nextest run -p buzz-db sqlite::tests # Multi-tenant conformance gate (buzz-conformance): the independent # replay checker + golden fixtures. No infra — pure in-process trace # replay — so it belongs in the unit job. Run all targets (lib + the From f190c2bdb3c12ac0c23a9f21d2e1baab127e3db2 Mon Sep 17 00:00:00 2001 From: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Date: Mon, 10 Aug 2026 15:46:21 -0400 Subject: [PATCH 12/16] fix(desktop): persist local relay service identity Signed-off-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/local_relay.rs | 39 +++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/local_relay.rs b/desktop/src-tauri/src/local_relay.rs index 7a70d8155c..6fcf4f66c1 100644 --- a/desktop/src-tauri/src/local_relay.rs +++ b/desktop/src-tauri/src/local_relay.rs @@ -5,6 +5,8 @@ //! supervises the bundled `buzz-relay` binary. use std::{ + fs::OpenOptions, + io::Write, net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}, path::{Path, PathBuf}, process::{Child, Command, Stdio}, @@ -104,13 +106,15 @@ pub(crate) fn start( ) -> Result { let relay_port = requested_port.unwrap_or(free_loopback_port()?); let owner_pubkey = keys.public_key().to_hex(); + let data_dir = local_data_dir(app, &owner_pubkey)?; + let relay_keys = load_or_create_relay_keys(&data_dir)?; let config = LocalRelayConfig { relay_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), relay_port), health_port: free_loopback_port()?, metrics_port: free_loopback_port()?, - data_dir: local_data_dir(app, &owner_pubkey)?, + data_dir, owner_pubkey, - relay_private_key: keys.secret_key().to_secret_hex(), + relay_private_key: relay_keys.secret_key().to_secret_hex(), }; let url = config.url(); let media_dir = config.data_dir.join("media"); @@ -233,6 +237,34 @@ fn local_data_dir(app: &AppHandle, owner_pubkey: &str) -> Result Result { + let path = data_dir.join("relay-service.key"); + match std::fs::read_to_string(&path) { + Ok(secret) => Keys::parse(secret.trim()) + .map_err(|error| format!("parse local relay service key: {error}")), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + std::fs::create_dir_all(data_dir) + .map_err(|error| format!("create local relay data dir: {error}"))?; + let keys = Keys::generate(); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .map_err(|error| format!("create local relay service key: {error}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(std::fs::Permissions::from_mode(0o600)) + .map_err(|error| format!("secure local relay service key: {error}"))?; + } + file.write_all(keys.secret_key().to_secret_hex().as_bytes()) + .map_err(|error| format!("write local relay service key: {error}"))?; + Ok(keys) + } + Err(error) => Err(format!("read local relay service key: {error}")), + } +} + fn sqlite_url(path: &Path) -> String { // sqlx accepts an absolute sqlite URL with three slashes. Path display is // intentional: app-data paths come from the OS, not user relay input. @@ -272,7 +304,8 @@ fn relay_binary() -> Result { #[cfg(test)] mod tests { use super::{ - is_local_relay_url, local_relay_url, sqlite_url, LocalRelayConfig, LOCAL_RELAY_SENTINEL, + is_local_relay_url, load_or_create_relay_keys, local_relay_url, sqlite_url, + LocalRelayConfig, LOCAL_RELAY_SENTINEL, }; use std::{ collections::HashMap, From 893a2624964354284daec5fa61033b3f6ea0093b Mon Sep 17 00:00:00 2001 From: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Date: Mon, 10 Aug 2026 16:11:50 -0400 Subject: [PATCH 13/16] fix(local-mode): harden sidecar foundations Signed-off-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> --- crates/buzz-db/src/sqlite.rs | 203 +++++++++++++++++++++- crates/buzz-relay/src/main.rs | 28 ++- crates/buzz-relay/src/metrics.rs | 34 +++- desktop/src-tauri/src/local_relay.rs | 248 ++++++++++++++++++--------- 4 files changed, 426 insertions(+), 87 deletions(-) diff --git a/crates/buzz-db/src/sqlite.rs b/crates/buzz-db/src/sqlite.rs index 2c2e6f6358..516b3b712c 100644 --- a/crates/buzz-db/src/sqlite.rs +++ b/crates/buzz-db/src/sqlite.rs @@ -305,11 +305,110 @@ pub(crate) async fn add_member( role: crate::channel::MemberRole, invited_by: Option<&[u8]>, ) -> Result { - get_channel(pool, community, channel_id).await?; + if pubkey.len() != 32 { + return Err(crate::DbError::InvalidData(format!( + "pubkey must be 32 bytes, got {}", + pubkey.len() + ))); + } + + let mut tx = pool.begin().await?; + let channel = sqlx::query( + "SELECT * FROM channels WHERE community_id = ?1 AND id = ?2 AND deleted_at IS NULL", + ) + .bind(community.as_uuid().to_string()) + .bind(channel_id.to_string()) + .fetch_optional(&mut *tx) + .await? + .ok_or(crate::DbError::ChannelNotFound(channel_id))?; + let channel = channel_record(channel)?; + + let effective_role = if channel.visibility == "private" { + let inviter = invited_by.ok_or_else(|| { + crate::DbError::AccessDenied("private channel requires an invite".to_string()) + })?; + let creator_bootstrap = inviter == pubkey && inviter == channel.created_by.as_slice(); + if !creator_bootstrap { + let inviter_role = active_member_role(&mut tx, channel_id, inviter) + .await? + .ok_or_else(|| { + crate::DbError::AccessDenied("inviter is not an active member".to_string()) + })?; + if role.is_elevated() && !is_elevated_role(&inviter_role) { + return Err(crate::DbError::AccessDenied( + "only owners/admins may grant elevated roles".to_string(), + )); + } + } + role + } else if role.is_elevated() { + let granter_role = match invited_by { + Some(inviter) => active_member_role(&mut tx, channel_id, inviter).await?, + None => None, + }; + if !granter_role.is_some_and(|role| is_elevated_role(&role)) { + return Err(crate::DbError::AccessDenied( + "only owners/admins may grant elevated roles".to_string(), + )); + } + role + } else { + role + }; + + if let Some(current_role) = active_member_role(&mut tx, channel_id, pubkey).await? { + if current_role != effective_role.as_str() { + let actor_role = match invited_by { + Some(inviter) => active_member_role(&mut tx, channel_id, inviter).await?, + None => None, + }; + if !actor_role.is_some_and(|role| is_elevated_role(&role)) { + return Err(crate::DbError::AccessDenied( + "only owners/admins may change an active member's role".to_string(), + )); + } + if current_role == "owner" && effective_role != crate::channel::MemberRole::Owner { + let owner_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM channel_members WHERE channel_id = ?1 AND role = 'owner' AND removed_at IS NULL", + ) + .bind(channel_id.to_string()) + .fetch_one(&mut *tx) + .await?; + if owner_count <= 1 { + return Err(crate::DbError::AccessDenied( + "cannot demote the last owner — transfer ownership first".to_string(), + )); + } + } + } + } + sqlx::query("INSERT INTO channel_members (channel_id, pubkey, role, invited_by) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(channel_id, pubkey) DO UPDATE SET role = excluded.role, invited_by = excluded.invited_by, removed_at = NULL") - .bind(channel_id.to_string()).bind(pubkey).bind(role.as_str()).bind(invited_by).execute(pool).await?; - member_record(sqlx::query("SELECT channel_id, pubkey, role, joined_at, invited_by, removed_at FROM channel_members WHERE channel_id = ?1 AND pubkey = ?2") - .bind(channel_id.to_string()).bind(pubkey).fetch_one(pool).await?) + .bind(channel_id.to_string()).bind(pubkey).bind(effective_role.as_str()).bind(invited_by).execute(&mut *tx).await?; + let row = sqlx::query("SELECT channel_id, pubkey, role, joined_at, invited_by, removed_at FROM channel_members WHERE channel_id = ?1 AND pubkey = ?2") + .bind(channel_id.to_string()).bind(pubkey).fetch_one(&mut *tx).await?; + let record = member_record(row)?; + tx.commit().await?; + Ok(record) +} + +async fn active_member_role( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + channel_id: Uuid, + pubkey: &[u8], +) -> Result> { + sqlx::query_scalar( + "SELECT role FROM channel_members WHERE channel_id = ?1 AND pubkey = ?2 AND removed_at IS NULL", + ) + .bind(channel_id.to_string()) + .bind(pubkey) + .fetch_optional(&mut **tx) + .await + .map_err(Into::into) +} + +fn is_elevated_role(role: &str) -> bool { + matches!(role, "owner" | "admin") } pub(crate) async fn is_member( @@ -566,6 +665,102 @@ mod tests { std::fs::remove_file(path).unwrap(); } + #[tokio::test] + async fn sqlite_membership_preserves_public_contract_invariants() { + let pool = connect(":memory:").await.unwrap(); + let community = ensure_configured_community(&pool, "local.buzz") + .await + .unwrap(); + let owner = Keys::generate().public_key().to_bytes(); + let member = Keys::generate().public_key().to_bytes(); + let other = Keys::generate().public_key().to_bytes(); + let channel_id = Uuid::new_v4(); + create_channel_with_id( + &pool, + community.id, + channel_id, + "private", + crate::channel::ChannelType::Stream, + crate::channel::ChannelVisibility::Private, + None, + &owner, + None, + ) + .await + .unwrap(); + + assert!(matches!( + add_member( + &pool, + community.id, + channel_id, + &[0; 31], + crate::channel::MemberRole::Member, + Some(&owner), + ) + .await, + Err(crate::DbError::InvalidData(_)) + )); + assert!(matches!( + add_member( + &pool, + community.id, + channel_id, + &member, + crate::channel::MemberRole::Member, + None, + ) + .await, + Err(crate::DbError::AccessDenied(_)) + )); + add_member( + &pool, + community.id, + channel_id, + &member, + crate::channel::MemberRole::Member, + Some(&owner), + ) + .await + .unwrap(); + assert!(matches!( + add_member( + &pool, + community.id, + channel_id, + &other, + crate::channel::MemberRole::Admin, + Some(&member), + ) + .await, + Err(crate::DbError::AccessDenied(_)) + )); + assert!(matches!( + add_member( + &pool, + community.id, + channel_id, + &member, + crate::channel::MemberRole::Guest, + Some(&member), + ) + .await, + Err(crate::DbError::AccessDenied(_)) + )); + assert!(matches!( + add_member( + &pool, + community.id, + channel_id, + &owner, + crate::channel::MemberRole::Member, + Some(&owner), + ) + .await, + Err(crate::DbError::AccessDenied(_)) + )); + } + #[tokio::test] async fn rebind_prefers_owned_then_oldest_community() { let pool = connect(":memory:").await.unwrap(); diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index f972caaff4..a6adbbf083 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -1,4 +1,5 @@ use std::collections::{HashMap, HashSet}; +use std::net::{IpAddr, Ipv4Addr}; use std::sync::atomic::Ordering; use std::sync::Arc; @@ -18,7 +19,7 @@ use buzz_db::{Db, DbConfig}; use buzz_pubsub::{rate_limiter::AdmissionRateLimiter, InProcessNip98ReplayGuard, PubSubManager}; use buzz_search::SearchService; -use buzz_relay::config::{Config, MAX_DRAIN_JITTER_MS}; +use buzz_relay::config::{Config, RelayProfile, MAX_DRAIN_JITTER_MS}; use buzz_relay::metrics as relay_metrics; use buzz_relay::router::{build_health_router, build_router}; use buzz_relay::state::{AppBackends, AppState}; @@ -1217,7 +1218,7 @@ async fn run_single_node(config: Config, tracer_init: telemetry::TracerInit) -> )); } - relay_metrics::install(config.metrics_port, usage_metrics_idle_timeout_secs(60)); + relay_metrics::install_loopback(config.metrics_port, usage_metrics_idle_timeout_secs(60)); let db_path = std::env::var("BUZZ_LOCAL_DB").unwrap_or_else(|_| "buzz-local.sqlite".to_string()); let media_root = @@ -1340,6 +1341,24 @@ async fn run_single_node(config: Config, tracer_init: telemetry::TracerInit) -> /// roughly the 5s grace plus the ack wait. const GRACEFUL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +fn health_listener_ip(profile: RelayProfile) -> IpAddr { + match profile { + RelayProfile::SingleNode => IpAddr::V4(Ipv4Addr::LOCALHOST), + RelayProfile::Production => IpAddr::V4(Ipv4Addr::UNSPECIFIED), + } +} + +#[cfg(test)] +mod listener_tests { + use super::{health_listener_ip, RelayProfile}; + + #[test] + fn single_node_health_listener_is_loopback_only() { + assert!(health_listener_ip(RelayProfile::SingleNode).is_loopback()); + assert!(health_listener_ip(RelayProfile::Production).is_unspecified()); + } +} + async fn serve( router: axum::Router, health_router: axum::Router, @@ -1347,10 +1366,11 @@ async fn serve( ) -> anyhow::Result<()> { let config = &state.config; - let health_listener = tokio::net::TcpListener::bind(("0.0.0.0", config.health_port)) + let health_host = health_listener_ip(config.profile); + let health_listener = tokio::net::TcpListener::bind((health_host, config.health_port)) .await .map_err(|e| anyhow::anyhow!("Failed to bind health port {}: {e}", config.health_port))?; - info!(port = config.health_port, "Health probe listener started"); + info!(host = %health_host, port = config.health_port, "Health probe listener started"); tokio::spawn(async move { axum::serve(health_listener, health_router).await.ok(); }); diff --git a/crates/buzz-relay/src/metrics.rs b/crates/buzz-relay/src/metrics.rs index 16e521a44e..21d1ff43c4 100644 --- a/crates/buzz-relay/src/metrics.rs +++ b/crates/buzz-relay/src/metrics.rs @@ -14,6 +14,7 @@ //! recorded by [`track_metrics`] middleware on the app router. Buzz-specific //! metrics are recorded inline at their call sites. +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::time::{Duration, Instant}; use axum::{ @@ -56,6 +57,17 @@ const GIT_PACK_BUCKETS: [f64; 9] = [0.0, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 1 /// Integer-count buckets for fan-out recipient histograms. const FANOUT_BUCKETS: [f64; 9] = [0.0, 1.0, 5.0, 10.0, 25.0, 50.0, 100.0, 500.0, 1000.0]; +fn prometheus_listener(port: u16, loopback_only: bool) -> SocketAddr { + SocketAddr::new( + IpAddr::V4(if loopback_only { + Ipv4Addr::LOCALHOST + } else { + Ipv4Addr::UNSPECIFIED + }), + port, + ) +} + /// Install the global metrics recorder and spawn the Prometheus HTTP exporter. /// /// `build()` returns the recorder + exporter future and internally spawns @@ -64,8 +76,17 @@ const FANOUT_BUCKETS: [f64; 9] = [0.0, 1.0, 5.0, 10.0, 25.0, 50.0, 100.0, 500.0, /// Must be called from within a Tokio runtime. /// Panics if a recorder is already installed or the port is in use. pub fn install(port: u16, gauge_idle_timeout_secs: u64) { + install_on(prometheus_listener(port, false), gauge_idle_timeout_secs); +} + +/// Install the metrics exporter on loopback for the single-node profile. +pub fn install_loopback(port: u16, gauge_idle_timeout_secs: u64) { + install_on(prometheus_listener(port, true), gauge_idle_timeout_secs); +} + +fn install_on(listener: SocketAddr, gauge_idle_timeout_secs: u64) { let (recorder, exporter) = PrometheusBuilder::new() - .with_http_listener(([0, 0, 0, 0], port)) + .with_http_listener(listener) // Remove gauge series that the relay intentionally stops emitting. .idle_timeout( MetricKindMask::GAUGE, @@ -205,3 +226,14 @@ pub async fn track_metrics(req: Request, next: Next) -> Response { response } + +#[cfg(test)] +mod tests { + use super::prometheus_listener; + + #[test] + fn single_node_metrics_listener_is_loopback_only() { + assert!(prometheus_listener(9102, true).ip().is_loopback()); + assert!(prometheus_listener(9102, false).ip().is_unspecified()); + } +} diff --git a/desktop/src-tauri/src/local_relay.rs b/desktop/src-tauri/src/local_relay.rs index 6fcf4f66c1..4813631af5 100644 --- a/desktop/src-tauri/src/local_relay.rs +++ b/desktop/src-tauri/src/local_relay.rs @@ -5,12 +5,11 @@ //! supervises the bundled `buzz-relay` binary. use std::{ - fs::OpenOptions, - io::Write, + io::Read, net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}, path::{Path, PathBuf}, process::{Child, Command, Stdio}, - sync::Mutex, + sync::{Arc, Mutex}, time::{Duration, Instant}, }; @@ -19,6 +18,9 @@ use tauri::{AppHandle, Manager}; const STARTUP_TIMEOUT: Duration = Duration::from_secs(15); const POLL_INTERVAL: Duration = Duration::from_millis(100); +const STDERR_TAIL_BYTES: usize = 8 * 1024; + +type StderrTail = Arc>>; pub(crate) struct LocalRelayRuntime { child: Child, @@ -104,62 +106,41 @@ pub(crate) fn start( keys: &Keys, requested_port: Option, ) -> Result { - let relay_port = requested_port.unwrap_or(free_loopback_port()?); let owner_pubkey = keys.public_key().to_hex(); let data_dir = local_data_dir(app, &owner_pubkey)?; let relay_keys = load_or_create_relay_keys(&data_dir)?; - let config = LocalRelayConfig { - relay_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), relay_port), - health_port: free_loopback_port()?, - metrics_port: free_loopback_port()?, - data_dir, - owner_pubkey, - relay_private_key: relay_keys.secret_key().to_secret_hex(), - }; - let url = config.url(); - let media_dir = config.data_dir.join("media"); + let media_dir = data_dir.join("media"); std::fs::create_dir_all(&media_dir) .map_err(|e| format!("create local relay media dir: {e}"))?; - let binary = relay_binary()?; - let mut command = Command::new(&binary); - command - .envs(config.environment()) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - let mut child = command - .spawn() - .map_err(|e| format!("start bundled buzz-relay at {}: {e}", binary.display()))?; - let info_url = format!("http://{}/info", config.relay_addr); - let deadline = Instant::now() + STARTUP_TIMEOUT; - while Instant::now() < deadline { - if child - .try_wait() - .map_err(|e| format!("inspect local relay: {e}"))? - .is_some() - { - return Err("bundled buzz-relay exited before becoming ready".to_string()); - } - if reqwest::blocking::Client::new() - .get(&info_url) - .header("Accept", "application/nostr+json") - .send() - .is_ok_and(|response| response.status().is_success()) - { - return Ok(LocalRelayRuntime { - child, - url, - owner_pubkey: config.owner_pubkey, - }); + // The relay owns its TCP listeners, so the desktop cannot safely retain a + // reservation across `spawn`. Retry a complete, distinct three-port set + // when an automatically-selected port loses that race; explicit user ports + // fail with the child diagnostic instead of silently moving the endpoint. + let attempts = if requested_port.is_some() { 1 } else { 3 }; + let mut last_error = None; + for _ in 0..attempts { + let (relay_port, health_port, metrics_port) = + allocate_distinct_loopback_ports(requested_port)?; + let config = LocalRelayConfig { + relay_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), relay_port), + health_port, + metrics_port, + data_dir: data_dir.clone(), + owner_pubkey: owner_pubkey.clone(), + relay_private_key: relay_keys.secret_key().to_secret_hex(), + }; + + match spawn_and_wait(&binary, config) { + Ok(runtime) => return Ok(runtime), + Err(error) if requested_port.is_none() && is_bind_conflict(&error) => { + last_error = Some(error); + } + Err(error) => return Err(error), } - std::thread::sleep(POLL_INTERVAL); } - - let _ = child.kill(); - let _ = child.wait(); - Err("bundled buzz-relay did not become ready within 15 seconds".to_string()) + Err(last_error.expect("at least one local relay startup attempt")) } pub(crate) fn ensure_started( @@ -237,38 +218,130 @@ fn local_data_dir(app: &AppHandle, owner_pubkey: &str) -> Result String { + // sqlx accepts an absolute sqlite URL with three slashes. Path display is + // intentional: app-data paths come from the OS, not user relay input. + format!("sqlite://{}", path.display()) +} + +/// Load the relay's service identity from its identity-scoped nest, or create +/// it once with the same atomic, owner-only file semantics used for private +/// desktop identities. This key signs relay-originated metadata; it is never +/// the human/owner key supplied by `apply_workspace`. fn load_or_create_relay_keys(data_dir: &Path) -> Result { let path = data_dir.join("relay-service.key"); - match std::fs::read_to_string(&path) { - Ok(secret) => Keys::parse(secret.trim()) - .map_err(|error| format!("parse local relay service key: {error}")), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - std::fs::create_dir_all(data_dir) - .map_err(|error| format!("create local relay data dir: {error}"))?; - let keys = Keys::generate(); - let mut file = OpenOptions::new() - .write(true) - .create_new(true) - .open(&path) - .map_err(|error| format!("create local relay service key: {error}"))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - file.set_permissions(std::fs::Permissions::from_mode(0o600)) - .map_err(|error| format!("secure local relay service key: {error}"))?; - } - file.write_all(keys.secret_key().to_secret_hex().as_bytes()) - .map_err(|error| format!("write local relay service key: {error}"))?; - Ok(keys) + if path.exists() { + let value = std::fs::read_to_string(&path) + .map_err(|e| format!("read local relay service key: {e}"))?; + return Keys::parse(value.trim()) + .map_err(|e| format!("parse local relay service key: {e}")); + } + + std::fs::create_dir_all(data_dir).map_err(|e| format!("create local relay data dir: {e}"))?; + let keys = Keys::generate(); + crate::app_state::save_key_file(&path, &keys) + .map_err(|e| format!("persist local relay service key: {e}"))?; + Ok(keys) +} + +fn is_bind_conflict(error: &str) -> bool { + error.contains("Address already in use") || error.contains("address already in use") +} + +fn spawn_and_wait(binary: &Path, config: LocalRelayConfig) -> Result { + let url = config.url(); + let info_url = format!("http://{}/info", config.relay_addr); + let stderr = Arc::new(Mutex::new(Vec::new())); + let mut command = Command::new(binary); + command + .envs(config.environment()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + let mut child = command + .spawn() + .map_err(|e| format!("start bundled buzz-relay at {}: {e}", binary.display()))?; + let stderr_reader = capture_stderr(child.stderr.take(), stderr.clone()); + + let client = reqwest::blocking::Client::new(); + let deadline = Instant::now() + STARTUP_TIMEOUT; + while Instant::now() < deadline { + if let Some(status) = child + .try_wait() + .map_err(|e| format!("inspect local relay: {e}"))? + { + let _ = stderr_reader.join(); + return Err(format!( + "bundled buzz-relay exited before becoming ready ({status}){}", + stderr_context(&stderr) + )); } - Err(error) => Err(format!("read local relay service key: {error}")), + if client + .get(&info_url) + .header("Accept", "application/nostr+json") + .send() + .is_ok_and(|response| response.status().is_success()) + { + return Ok(LocalRelayRuntime { + child, + url, + owner_pubkey: config.owner_pubkey, + }); + } + std::thread::sleep(POLL_INTERVAL); } + + let _ = child.kill(); + let _ = child.wait(); + let _ = stderr_reader.join(); + Err(format!( + "bundled buzz-relay did not become ready within 15 seconds{}", + stderr_context(&stderr) + )) } -fn sqlite_url(path: &Path) -> String { - // sqlx accepts an absolute sqlite URL with three slashes. Path display is - // intentional: app-data paths come from the OS, not user relay input. - format!("sqlite://{}", path.display()) +fn capture_stderr( + stderr: Option, + tail: StderrTail, +) -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + if let Some(mut stderr) = stderr { + let mut buffer = [0; 1024]; + while let Ok(read) = stderr.read(&mut buffer) { + if read == 0 { + break; + } + let mut tail = tail.lock().expect("stderr tail mutex poisoned"); + tail.extend_from_slice(&buffer[..read]); + if tail.len() > STDERR_TAIL_BYTES { + let excess = tail.len() - STDERR_TAIL_BYTES; + tail.drain(..excess); + } + } + } + }) +} + +fn stderr_context(stderr: &StderrTail) -> String { + let tail = stderr.lock().expect("stderr tail mutex poisoned"); + let text = String::from_utf8_lossy(&tail).trim().to_string(); + (!text.is_empty()) + .then(|| format!("; stderr: {text}")) + .unwrap_or_default() +} + +fn allocate_distinct_loopback_ports( + requested_port: Option, +) -> Result<(u16, u16, u16), String> { + let relay_port = requested_port.unwrap_or(free_loopback_port()?); + let mut ports = vec![relay_port]; + while ports.len() < 3 { + let port = free_loopback_port()?; + if !ports.contains(&port) { + ports.push(port); + } + } + Ok((ports[0], ports[1], ports[2])) } fn free_loopback_port() -> Result { @@ -304,16 +377,35 @@ fn relay_binary() -> Result { #[cfg(test)] mod tests { use super::{ - is_local_relay_url, load_or_create_relay_keys, local_relay_url, sqlite_url, - LocalRelayConfig, LOCAL_RELAY_SENTINEL, + allocate_distinct_loopback_ports, is_local_relay_url, load_or_create_relay_keys, + local_relay_url, sqlite_url, stderr_context, LocalRelayConfig, StderrTail, + LOCAL_RELAY_SENTINEL, }; use std::{ collections::HashMap, net::{IpAddr, Ipv4Addr, SocketAddr}, path::{Path, PathBuf}, process::{Command, Stdio}, + sync::{Arc, Mutex}, }; + #[test] + fn selected_ports_are_distinct_in_one_startup_attempt() { + let (relay, health, metrics) = allocate_distinct_loopback_ports(None).expect("ports"); + assert_ne!(relay, health); + assert_ne!(relay, metrics); + assert_ne!(health, metrics); + } + + #[test] + fn startup_error_includes_bounded_stderr_tail() { + let tail: StderrTail = Arc::new(Mutex::new(b"bind: address already in use\n".to_vec())); + assert_eq!( + stderr_context(&tail), + "; stderr: bind: address already in use" + ); + } + #[test] fn sqlite_url_is_absolute() { assert_eq!( From ed6e79460f3c49ecb7d9d77b8b0813c109f07f17 Mon Sep 17 00:00:00 2001 From: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Date: Mon, 10 Aug 2026 16:21:54 -0400 Subject: [PATCH 14/16] fix(sqlite): preserve original member inviter Signed-off-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> --- crates/buzz-db/src/sqlite.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/buzz-db/src/sqlite.rs b/crates/buzz-db/src/sqlite.rs index 516b3b712c..46c85e761e 100644 --- a/crates/buzz-db/src/sqlite.rs +++ b/crates/buzz-db/src/sqlite.rs @@ -383,7 +383,7 @@ pub(crate) async fn add_member( } } - sqlx::query("INSERT INTO channel_members (channel_id, pubkey, role, invited_by) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(channel_id, pubkey) DO UPDATE SET role = excluded.role, invited_by = excluded.invited_by, removed_at = NULL") + sqlx::query("INSERT INTO channel_members (channel_id, pubkey, role, invited_by) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(channel_id, pubkey) DO UPDATE SET role = excluded.role, removed_at = NULL") .bind(channel_id.to_string()).bind(pubkey).bind(effective_role.as_str()).bind(invited_by).execute(&mut *tx).await?; let row = sqlx::query("SELECT channel_id, pubkey, role, joined_at, invited_by, removed_at FROM channel_members WHERE channel_id = ?1 AND pubkey = ?2") .bind(channel_id.to_string()).bind(pubkey).fetch_one(&mut *tx).await?; @@ -673,6 +673,7 @@ mod tests { .unwrap(); let owner = Keys::generate().public_key().to_bytes(); let member = Keys::generate().public_key().to_bytes(); + let admin = Keys::generate().public_key().to_bytes(); let other = Keys::generate().public_key().to_bytes(); let channel_id = Uuid::new_v4(); create_channel_with_id( @@ -723,6 +724,27 @@ mod tests { ) .await .unwrap(); + add_member( + &pool, + community.id, + channel_id, + &admin, + crate::channel::MemberRole::Admin, + Some(&owner), + ) + .await + .unwrap(); + let readded = add_member( + &pool, + community.id, + channel_id, + &member, + crate::channel::MemberRole::Member, + Some(&admin), + ) + .await + .unwrap(); + assert_eq!(readded.invited_by, Some(owner.to_vec())); assert!(matches!( add_member( &pool, From 67debfa7eb5e48919a1904e7e7c12a319c637ac7 Mon Sep 17 00:00:00 2001 From: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Date: Mon, 10 Aug 2026 16:23:33 -0400 Subject: [PATCH 15/16] test(sqlite): cover inviter preservation on reactivation Signed-off-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> --- crates/buzz-db/src/sqlite.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/buzz-db/src/sqlite.rs b/crates/buzz-db/src/sqlite.rs index 46c85e761e..c6103ae85d 100644 --- a/crates/buzz-db/src/sqlite.rs +++ b/crates/buzz-db/src/sqlite.rs @@ -745,6 +745,26 @@ mod tests { .await .unwrap(); assert_eq!(readded.invited_by, Some(owner.to_vec())); + sqlx::query::( + "UPDATE channel_members SET removed_at = unixepoch() WHERE channel_id = ?1 AND pubkey = ?2", + ) + .bind(channel_id.to_string()) + .bind(&member[..]) + .execute(&pool) + .await + .unwrap(); + let reactivated = add_member( + &pool, + community.id, + channel_id, + &member, + crate::channel::MemberRole::Member, + Some(&admin), + ) + .await + .unwrap(); + assert_eq!(reactivated.invited_by, Some(owner.to_vec())); + assert!(reactivated.removed_at.is_none()); assert!(matches!( add_member( &pool, From aaf4be8490a18755687d6394ede20fd12e356b65 Mon Sep 17 00:00:00 2001 From: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Date: Mon, 10 Aug 2026 19:38:03 -0400 Subject: [PATCH 16/16] fix(desktop): clear local mode push checks Signed-off-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/commands/agent_auth.rs | 45 ++++++++++++++++++++ desktop/src-tauri/src/commands/agents.rs | 40 ----------------- desktop/src-tauri/src/commands/workspace.rs | 2 +- desktop/src-tauri/src/local_relay.rs | 8 ++-- 4 files changed, 51 insertions(+), 44 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_auth.rs b/desktop/src-tauri/src/commands/agent_auth.rs index bba0f33860..d90c9ad7f6 100644 --- a/desktop/src-tauri/src/commands/agent_auth.rs +++ b/desktop/src-tauri/src/commands/agent_auth.rs @@ -13,6 +13,51 @@ use serde::{Deserialize, Serialize}; use crate::managed_agents::{ default_agent_workdir, known_acp_runtime_exact, normalize_agent_args, resolve_command, }; +use crate::{ + app_state::AppState, + managed_agents::{load_managed_agents, save_managed_agents}, + util::now_iso, +}; + +pub(super) fn backfill_missing_agent_auth_tags( + app: &tauri::AppHandle, + state: &AppState, +) -> Result { + let owner_keys = state.signing_keys()?; + let compat_owner = nostr::Keys::parse(&owner_keys.secret_key().to_secret_hex()) + .map_err(|e| format!("failed to bridge owner keys: {e}"))?; + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(app)?; + let mut changed = 0; + for record in &mut records { + if record.auth_tag.is_some() + || record + .pubkey + .eq_ignore_ascii_case(&owner_keys.public_key().to_hex()) + { + continue; + } + let agent = nostr::PublicKey::from_hex(&record.pubkey) + .map_err(|e| format!("invalid managed agent pubkey {}: {e}", record.pubkey))?; + record.auth_tag = Some( + buzz_sdk_pkg::nip_oa::compute_auth_tag(&compat_owner, &agent, "").map_err(|e| { + format!( + "failed to compute NIP-OA auth tag for {}: {e}", + record.pubkey + ) + })?, + ); + record.updated_at = now_iso(); + changed += 1; + } + if changed > 0 { + save_managed_agents(app, &records)?; + } + Ok(changed) +} #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index db9562e8d1..dd61fc9398 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -18,46 +18,6 @@ use crate::{ util::now_iso, }; -pub(super) fn backfill_missing_agent_auth_tags( - app: &AppHandle, - state: &AppState, -) -> Result { - let owner_keys = state.signing_keys()?; - let compat_owner = nostr::Keys::parse(&owner_keys.secret_key().to_secret_hex()) - .map_err(|e| format!("failed to bridge owner keys: {e}"))?; - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(app)?; - let mut changed = 0; - for record in &mut records { - if record.auth_tag.is_some() - || record - .pubkey - .eq_ignore_ascii_case(&owner_keys.public_key().to_hex()) - { - continue; - } - let agent = nostr::PublicKey::from_hex(&record.pubkey) - .map_err(|e| format!("invalid managed agent pubkey {}: {e}", record.pubkey))?; - record.auth_tag = Some( - buzz_sdk_pkg::nip_oa::compute_auth_tag(&compat_owner, &agent, "").map_err(|e| { - format!( - "failed to compute NIP-OA auth tag for {}: {e}", - record.pubkey - ) - })?, - ); - record.updated_at = now_iso(); - changed += 1; - } - if changed > 0 { - save_managed_agents(app, &records)?; - } - Ok(changed) -} - /// Read the workspace owner pubkey without holding the lock. Used to populate `BUZZ_ACP_AGENT_OWNER` /// as a fallback for legacy agent records that have no NIP-OA `auth_tag`. pub(super) fn workspace_owner_hex(state: &AppState) -> Result { diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 32f041cda0..26739bfab8 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -230,7 +230,7 @@ pub async fn apply_workspace( try_regenerate_nest(&app); if is_local_workspace { - let changed = crate::commands::agents::backfill_missing_agent_auth_tags(&app, &state)?; + let changed = super::agent_auth::backfill_missing_agent_auth_tags(&app, &state)?; if changed > 0 { eprintln!("buzz-desktop: backfilled NIP-OA auth tags for {changed} local agent(s)"); } diff --git a/desktop/src-tauri/src/local_relay.rs b/desktop/src-tauri/src/local_relay.rs index 4813631af5..7fd6497ace 100644 --- a/desktop/src-tauri/src/local_relay.rs +++ b/desktop/src-tauri/src/local_relay.rs @@ -325,9 +325,11 @@ fn capture_stderr( fn stderr_context(stderr: &StderrTail) -> String { let tail = stderr.lock().expect("stderr tail mutex poisoned"); let text = String::from_utf8_lossy(&tail).trim().to_string(); - (!text.is_empty()) - .then(|| format!("; stderr: {text}")) - .unwrap_or_default() + if text.is_empty() { + String::new() + } else { + format!("; stderr: {text}") + } } fn allocate_distinct_loopback_ports(