From 168168d8fae97ea0e73118274fae90327f2b61ba Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 14:26:04 -0400 Subject: [PATCH] feat(relay): enable SQLite command persistence Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- crates/buzz-db/src/lib.rs | 17 +++++++- .../src/handlers/command_executor.rs | 39 +++++++++++++------ 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index e9bda7fff..08bb3fffb 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -4818,15 +4818,28 @@ impl Db { } /// Ensures monthly partitions exist for the next N months. + /// + /// SQLite stores events in an unpartitioned local table, so this is an + /// intentional single-node no-op. pub async fn ensure_future_partitions(&self, months_ahead: u32) -> Result<()> { - partition::ensure_future_partitions(self.pg_pool()?, months_ahead).await + match &self.backend { + DbBackend::SQLite(_) => Ok(()), + DbBackend::Postgres => { + 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`. /// /// Idempotent — safe to call on every startup. No-ops when all rows are already populated. - /// Runs a single UPDATE touching only NIP-33 rows with NULL d_tag. + /// Runs a single UPDATE touching only NIP-33 rows with NULL d_tag. SQLite + /// derives d-tags from the persisted event JSON and has no denormalized + /// `d_tag` column, so its profile deliberately returns zero. pub async fn backfill_d_tags(&self) -> Result { + if matches!(&self.backend, DbBackend::SQLite(_)) { + return Ok(0); + } let result = sqlx::query( "UPDATE events \ SET d_tag = COALESCE( \ diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index 8f0cd0a25..a96b763ac 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -119,11 +119,35 @@ async fn persist_command_event( channel_id_override: Option, ) -> Result { let channel_id = channel_id_override.or_else(|| extract_channel_id(event)); + let d_tag = buzz_db::event::extract_d_tag(event); + if let Some(ref d_tag) = d_tag { + if d_tag.len() > buzz_db::event::D_TAG_MAX_LEN { + return Err(IngestError::Rejected(format!( + "invalid: d tag too long ({} bytes, max {})", + d_tag.len(), + buzz_db::event::D_TAG_MAX_LEN, + ))); + } + } if state.config.profile.is_single_node() { - return Err(IngestError::Rejected( - "unsupported_feature: this command is unavailable in the single-node profile".into(), - )); + let result = if let Some(d_tag) = d_tag.as_deref() { + state + .db + .replace_parameterized_event(tenant.community(), event, d_tag, channel_id) + .await + } else { + state + .db + .insert_event(tenant.community(), event, channel_id) + .await + } + .map_err(|e| IngestError::Internal(format!("error: persist SQLite command: {e}")))?; + return Ok(if result.1 { + PersistResult::Inserted(CommandTransaction::Sqlite) + } else { + PersistResult::Duplicate + }); } let mut tx = state @@ -146,16 +170,7 @@ async fn persist_command_event( let received_at = chrono::Utc::now(); // Extract d_tag for parameterized replaceable kinds (NIP-33). - let d_tag = buzz_db::event::extract_d_tag(event); if let Some(ref d_tag) = d_tag { - if d_tag.len() > buzz_db::event::D_TAG_MAX_LEN { - return Err(IngestError::Rejected(format!( - "invalid: d tag too long ({} bytes, max {})", - d_tag.len(), - buzz_db::event::D_TAG_MAX_LEN, - ))); - } - // Command kinds normally use plain insert semantics, but workflow // definitions are NIP-33 events. Serialize writers for the same // coordinate and reject stale writes before executing the domain