commit f32653675fd15570660135060f444982848ae092
parent 44ab4befe958367fd6476db14861ab793c19ce75
Author: triesap <tyson@radroots.org>
Date: Sat, 6 Jun 2026 03:43:15 -0700
store-surreal: project long form posts
Diffstat:
1 file changed, 707 insertions(+), 10 deletions(-)
diff --git a/crates/tangle_store_surreal/src/lib.rs b/crates/tangle_store_surreal/src/lib.rs
@@ -6,9 +6,10 @@ use std::collections::BTreeSet;
use surrealdb::Surreal;
use surrealdb::engine::local::{Db, Mem, RocksDb};
use tangle_nips::{
- CommentEvent, DeletionTarget, ListingProjection, ListingProjectionEvaluation,
- NIP99_DRAFT_LISTING_KIND, NIP99_PUBLIC_LISTING_KIND, ReactionEvent, ReactionValue,
- evaluate_listing_projection, parse_comment_event, parse_deletion_request, parse_reaction_event,
+ CommentEvent, DeletionTarget, ListingProjection, ListingProjectionEvaluation, LongFormEvent,
+ LongFormKind, NIP99_DRAFT_LISTING_KIND, NIP99_PUBLIC_LISTING_KIND, ReactionEvent,
+ ReactionValue, evaluate_listing_projection, parse_comment_event, parse_deletion_request,
+ parse_long_form_event, parse_reaction_event,
};
use tangle_protocol::{AddressCoordinate, Event, EventId, Filter, UnixTimestamp, event_to_value};
use tangle_store::{StoreEventOutcome, StoredEvent};
@@ -837,6 +838,13 @@ pub enum ReactionProjectionOutcome {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum LongFormProjectionOutcome {
+ NotLongForm,
+ Ineligible,
+ Projected,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HiddenEventOutcome {
NotFound,
Hidden,
@@ -1026,6 +1034,34 @@ impl CommentProjectionQuery {
}
}
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct LongFormProjectionQuery {
+ author_pubkey: Option<String>,
+ topic: Option<String>,
+ limit: Option<u64>,
+}
+
+impl LongFormProjectionQuery {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub fn with_author_pubkey(mut self, pubkey: &str) -> Self {
+ self.author_pubkey = Some(pubkey.to_owned());
+ self
+ }
+
+ pub fn with_topic(mut self, topic: &str) -> Self {
+ self.topic = Some(topic.to_owned());
+ self
+ }
+
+ pub fn with_limit(mut self, limit: u64) -> Self {
+ self.limit = Some(limit);
+ self
+ }
+}
+
#[derive(Clone)]
pub struct SurrealStore {
db: Surreal<Db>,
@@ -2068,6 +2104,189 @@ UPSERT type::record('reaction_projection', $event_id) CONTENT {
response.take(0).map_err(SurrealStoreError::from)
}
+ pub async fn project_long_form(
+ &self,
+ event: &Event,
+ projected_at: UnixTimestamp,
+ ) -> Result<LongFormProjectionOutcome, SurrealStoreError> {
+ let article = match parse_long_form_event(event) {
+ Ok(Some(article)) => article,
+ Ok(None) => return Ok(LongFormProjectionOutcome::NotLongForm),
+ Err(_) => return Ok(LongFormProjectionOutcome::Ineligible),
+ };
+ if article.long_form_kind() != LongFormKind::Published {
+ return Ok(LongFormProjectionOutcome::Ineligible);
+ }
+ let fields = long_form_projection_fields(&article, projected_at);
+ if self
+ .long_form_current_row(&fields.long_form_key)
+ .await?
+ .as_ref()
+ .is_some_and(|row| !long_form_current_should_replace(&article, row))
+ {
+ return Ok(LongFormProjectionOutcome::Ineligible);
+ }
+ self.db
+ .query(
+ r#"
+UPSERT type::record('long_form_current', $long_form_key) CONTENT {
+ long_form_key: $long_form_key,
+ event_id: $event_id,
+ author_pubkey: $author_pubkey,
+ d: $d,
+ created_at: $created_at,
+ updated_at: $updated_at,
+ published_at: $published_at,
+ title: $title,
+ image: $image,
+ summary: $summary,
+ content: $content,
+ tags: $tags,
+ referenced_events: $referenced_events,
+ referenced_addresses: $referenced_addresses,
+ referenced_pubkeys: $referenced_pubkeys,
+ hidden: false,
+ deleted: false,
+ projected_at: $projected_at
+};
+UPSERT type::record('search_doc', $long_form_key) CONTENT {
+ doc_key: $long_form_key,
+ event_id: $event_id,
+ current_event_id: $event_id,
+ doc_type: "long_form",
+ kind: $kind,
+ pubkey: $author_pubkey,
+ address_key: $long_form_key,
+ title: $search_title,
+ summary: $summary,
+ body: $content,
+ category_text: $category_text,
+ location_text: NONE,
+ tags: $tags,
+ categories: [],
+ created_at: $created_at,
+ updated_at: $updated_at,
+ visible: true,
+ status: "published",
+ seller_trust_score: NONE
+};
+"#,
+ )
+ .bind(("long_form_key", fields.long_form_key.as_str()))
+ .bind(("event_id", fields.event_id.as_str()))
+ .bind(("author_pubkey", fields.author_pubkey.as_str()))
+ .bind(("d", fields.d.as_str()))
+ .bind(("created_at", fields.created_at))
+ .bind(("updated_at", fields.updated_at))
+ .bind(("published_at", fields.published_at))
+ .bind(("title", fields.title.as_deref()))
+ .bind(("image", fields.image.as_deref()))
+ .bind(("summary", fields.summary.as_deref()))
+ .bind(("content", fields.content.as_str()))
+ .bind(("tags", fields.tags.clone()))
+ .bind(("referenced_events", fields.referenced_events.clone()))
+ .bind(("referenced_addresses", fields.referenced_addresses.clone()))
+ .bind(("referenced_pubkeys", fields.referenced_pubkeys.clone()))
+ .bind(("projected_at", fields.projected_at))
+ .bind(("kind", fields.kind))
+ .bind(("search_title", fields.search_title.as_str()))
+ .bind(("category_text", fields.tags.join(" ")))
+ .await
+ .map_err(SurrealStoreError::from)?
+ .check()
+ .map_err(SurrealStoreError::from)?;
+ self.replace_long_form_topic_rows(&fields).await?;
+ Ok(LongFormProjectionOutcome::Projected)
+ }
+
+ pub async fn long_form_current_row(
+ &self,
+ long_form_key: &str,
+ ) -> Result<Option<serde_json::Value>, SurrealStoreError> {
+ let mut response = self
+ .db
+ .query("SELECT * FROM ONLY type::record('long_form_current', $long_form_key);")
+ .bind(("long_form_key", long_form_key))
+ .await
+ .map_err(SurrealStoreError::from)?
+ .check()
+ .map_err(SurrealStoreError::from)?;
+ response.take(0).map_err(SurrealStoreError::from)
+ }
+
+ pub async fn long_form_topic_rows(
+ &self,
+ long_form_key: &str,
+ ) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
+ let mut response = self
+ .db
+ .query(
+ "SELECT * FROM long_form_topic WHERE long_form_key = $long_form_key ORDER BY topic ASC;",
+ )
+ .bind(("long_form_key", long_form_key))
+ .await
+ .map_err(SurrealStoreError::from)?
+ .check()
+ .map_err(SurrealStoreError::from)?;
+ response.take(0).map_err(SurrealStoreError::from)
+ }
+
+ pub async fn query_long_form_projections(
+ &self,
+ query: &LongFormProjectionQuery,
+ ) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
+ let topic_keys = match query.topic.as_deref() {
+ Some(topic) => {
+ let normalized = topic.trim().to_ascii_lowercase();
+ let mut response = self
+ .db
+ .query(
+ "SELECT VALUE long_form_key FROM long_form_topic WHERE topic = $topic AND hidden = false AND deleted = false ORDER BY updated_at DESC, event_id ASC;",
+ )
+ .bind(("topic", normalized.as_str()))
+ .await
+ .map_err(SurrealStoreError::from)?
+ .check()
+ .map_err(SurrealStoreError::from)?;
+ let keys: Vec<String> = response.take(0).map_err(SurrealStoreError::from)?;
+ if keys.is_empty() {
+ return Ok(Vec::new());
+ }
+ Some(keys)
+ }
+ None => None,
+ };
+ let mut statement =
+ "SELECT * FROM long_form_current WHERE hidden = false AND deleted = false".to_owned();
+ if query.author_pubkey.is_some() {
+ statement.push_str(" AND author_pubkey = $author_pubkey");
+ }
+ if topic_keys.is_some() {
+ statement.push_str(" AND long_form_key IN $topic_keys");
+ }
+ statement.push_str(" ORDER BY updated_at DESC, event_id ASC");
+ if query.limit.is_some() {
+ statement.push_str(" LIMIT $limit");
+ }
+ statement.push(';');
+ let mut surreal_query = self.db.query(statement);
+ if let Some(value) = &query.author_pubkey {
+ surreal_query = surreal_query.bind(("author_pubkey", value.as_str()));
+ }
+ if let Some(keys) = topic_keys {
+ surreal_query = surreal_query.bind(("topic_keys", keys));
+ }
+ if let Some(value) = query.limit {
+ surreal_query = surreal_query.bind(("limit", value));
+ }
+ let mut response = surreal_query
+ .await
+ .map_err(SurrealStoreError::from)?
+ .check()
+ .map_err(SurrealStoreError::from)?;
+ response.take(0).map_err(SurrealStoreError::from)
+ }
+
pub async fn project_listing_helpers(
&self,
event: &Event,
@@ -2365,6 +2584,8 @@ UPDATE event_current SET hidden = true WHERE event_id = $event_id;
UPDATE listing_current SET hidden = true WHERE event_id = $event_id;
UPDATE comment_projection SET hidden = true WHERE event_id = $event_id;
UPDATE reaction_projection SET hidden = true WHERE event_id = $event_id;
+UPDATE long_form_current SET hidden = true WHERE event_id = $event_id;
+UPDATE long_form_topic SET hidden = true WHERE event_id = $event_id;
UPDATE search_doc SET visible = false WHERE event_id = $event_id OR current_event_id = $event_id;
"#,
)
@@ -2416,8 +2637,10 @@ UPDATE event_current SET hidden = false WHERE event_id = $event_id;
UPDATE listing_current SET hidden = false WHERE event_id = $event_id;
UPDATE comment_projection SET hidden = false WHERE event_id = $event_id;
UPDATE reaction_projection SET hidden = false WHERE event_id = $event_id;
-UPDATE search_doc SET visible = true WHERE (event_id = $event_id OR current_event_id = $event_id) AND status = "active";
-UPDATE search_doc SET visible = false WHERE (event_id = $event_id OR current_event_id = $event_id) AND status != "active";
+UPDATE long_form_current SET hidden = false WHERE event_id = $event_id;
+UPDATE long_form_topic SET hidden = false WHERE event_id = $event_id;
+UPDATE search_doc SET visible = true WHERE (event_id = $event_id OR current_event_id = $event_id) AND (status = "active" OR status = "published");
+UPDATE search_doc SET visible = false WHERE (event_id = $event_id OR current_event_id = $event_id) AND status != "active" AND status != "published";
"#,
)
.bind(("event_id", event_id.as_str()))
@@ -2688,6 +2911,43 @@ UPDATE search_doc SET visible = false WHERE (event_id = $event_id OR current_eve
response.take(0).map_err(SurrealStoreError::from)
}
+ async fn replace_long_form_topic_rows(
+ &self,
+ fields: &LongFormProjectionFields,
+ ) -> Result<(), SurrealStoreError> {
+ self.db
+ .query("DELETE long_form_topic WHERE long_form_key = $long_form_key;")
+ .bind(("long_form_key", fields.long_form_key.as_str()))
+ .await
+ .map_err(SurrealStoreError::from)?
+ .check()
+ .map_err(SurrealStoreError::from)?;
+ for topic in &fields.tags {
+ self.db
+ .query(
+ r#"
+CREATE long_form_topic CONTENT {
+ long_form_key: $long_form_key,
+ topic: $topic,
+ updated_at: $updated_at,
+ event_id: $event_id,
+ hidden: false,
+ deleted: false
+};
+"#,
+ )
+ .bind(("long_form_key", fields.long_form_key.as_str()))
+ .bind(("topic", topic.as_str()))
+ .bind(("updated_at", fields.updated_at))
+ .bind(("event_id", fields.event_id.as_str()))
+ .await
+ .map_err(SurrealStoreError::from)?
+ .check()
+ .map_err(SurrealStoreError::from)?;
+ }
+ Ok(())
+ }
+
async fn upsert_rate_limit_state(
&self,
key: &str,
@@ -2938,6 +3198,9 @@ UPSERT type::record('reaction_count', $target_event_id) CONTENT {
UPDATE nostr_event SET deleted = true WHERE event_id = $event_id AND pubkey = $author_pubkey;
UPDATE comment_projection SET deleted = true WHERE event_id = $event_id AND pubkey = $author_pubkey;
UPDATE reaction_projection SET deleted = true WHERE event_id = $event_id AND pubkey = $author_pubkey;
+UPDATE long_form_current SET deleted = true WHERE event_id = $event_id AND author_pubkey = $author_pubkey;
+UPDATE long_form_topic SET deleted = true WHERE event_id = $event_id;
+UPDATE search_doc SET visible = false WHERE event_id = $event_id OR current_event_id = $event_id;
"#,
)
.bind(("event_id", event_id))
@@ -2963,6 +3226,11 @@ UPDATE reaction_projection SET deleted = true WHERE event_id = $event_id AND pub
.query(
"UPDATE event_current SET deleted = true WHERE address_key = $address_key AND pubkey = $author_pubkey;",
)
+ .query(
+ "UPDATE long_form_current SET deleted = true WHERE long_form_key = $address_key AND author_pubkey = $author_pubkey;",
+ )
+ .query("UPDATE long_form_topic SET deleted = true WHERE long_form_key = $address_key;")
+ .query("UPDATE search_doc SET visible = false WHERE address_key = $address_key;")
.bind(("address_key", address_key))
.bind(("author_pubkey", author_pubkey))
.await
@@ -3215,6 +3483,27 @@ struct ReactionProjectionFields {
projected_at: u64,
}
+struct LongFormProjectionFields {
+ long_form_key: String,
+ event_id: String,
+ author_pubkey: String,
+ d: String,
+ created_at: u64,
+ updated_at: u64,
+ published_at: Option<u64>,
+ title: Option<String>,
+ image: Option<String>,
+ summary: Option<String>,
+ content: String,
+ tags: Vec<String>,
+ referenced_events: Vec<String>,
+ referenced_addresses: Vec<String>,
+ referenced_pubkeys: Vec<String>,
+ projected_at: u64,
+ kind: u32,
+ search_title: String,
+}
+
struct ListingCurrentFields {
listing_key: String,
listing_key_hash: String,
@@ -3314,6 +3603,54 @@ fn reaction_value_string(value: &ReactionValue) -> String {
}
}
+fn long_form_projection_fields(
+ article: &LongFormEvent,
+ projected_at: UnixTimestamp,
+) -> LongFormProjectionFields {
+ let d = article.d().as_str().to_owned();
+ LongFormProjectionFields {
+ long_form_key: article.address().key().to_string(),
+ event_id: article.event_id().as_str().to_owned(),
+ author_pubkey: article.pubkey().as_str().to_owned(),
+ d: d.clone(),
+ created_at: article.created_at().as_u64(),
+ updated_at: article.created_at().as_u64(),
+ published_at: article.published_at(),
+ title: article.title().map(str::to_owned),
+ image: article.image().map(str::to_owned),
+ summary: article.summary().map(str::to_owned),
+ content: article.content().to_owned(),
+ tags: article.topics().to_vec(),
+ referenced_events: article
+ .referenced_events()
+ .iter()
+ .map(|event_id| event_id.as_str().to_owned())
+ .collect(),
+ referenced_addresses: article
+ .referenced_addresses()
+ .iter()
+ .map(|address| address.key().to_string())
+ .collect(),
+ referenced_pubkeys: article
+ .referenced_pubkeys()
+ .iter()
+ .map(|pubkey| pubkey.as_str().to_owned())
+ .collect(),
+ projected_at: projected_at.as_u64(),
+ kind: article.address().kind().as_u32(),
+ search_title: article.title().unwrap_or(&d).to_owned(),
+ }
+}
+
+fn long_form_current_should_replace(article: &LongFormEvent, row: &serde_json::Value) -> bool {
+ let incoming_created_at = article.created_at().as_u64();
+ let existing_created_at = row["updated_at"].as_u64().unwrap_or_default();
+ let existing_event_id = row["event_id"].as_str().unwrap_or_default();
+ incoming_created_at > existing_created_at
+ || (incoming_created_at == existing_created_at
+ && article.event_id().as_str() > existing_event_id)
+}
+
struct SearchDocumentFields {
doc_key: String,
address_key: Option<String>,
@@ -3542,12 +3879,15 @@ mod tests {
CommentProjectionOutcome, CommentProjectionQuery, CurrentEventOutcome,
DeletionMarkerOutcome, DurableRateLimitDecision, HiddenEventOutcome, ListingCurrentOutcome,
ListingHelperOutcome, ListingProjectionQuery, ListingRevisionOutcome,
- MigrationApplyOutcome, ReactionProjectionOutcome, SearchDocumentOutcome,
- SearchDocumentQuery, SurrealConfigError, SurrealConnectionConfig, SurrealConnectionMode,
- SurrealMigration, SurrealMigrationError, SurrealMigrationPlan, SurrealStore,
- SurrealStoreError, base_migration_plan, migration_tracking_schema,
+ LongFormProjectionOutcome, LongFormProjectionQuery, MigrationApplyOutcome,
+ ReactionProjectionOutcome, SearchDocumentOutcome, SearchDocumentQuery, SurrealConfigError,
+ SurrealConnectionConfig, SurrealConnectionMode, SurrealMigration, SurrealMigrationError,
+ SurrealMigrationPlan, SurrealStore, SurrealStoreError, base_migration_plan,
+ migration_tracking_schema,
+ };
+ use tangle_nips::{
+ ListingProjectionEvaluation, NIP23_LONG_FORM_DRAFT_KIND, NIP23_LONG_FORM_KIND,
};
- use tangle_nips::ListingProjectionEvaluation;
use tangle_protocol::{
Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent,
filter_from_value,
@@ -5948,6 +6288,323 @@ mod tests {
}
#[tokio::test]
+ async fn project_long_form_posts_persists_current_topic_and_search_rows() {
+ let store = memory_store().await;
+ store
+ .apply_plan(&base_migration_plan())
+ .await
+ .expect("apply plan");
+ let article = long_form_article(
+ 1_714_125_060,
+ "harvest-notes",
+ "Harvest notes",
+ "The storage carrots held well.",
+ &["Carrots", "CSA"],
+ );
+ let draft = build_fixture_event_from_parts(
+ FixtureKey::Seller,
+ 1_714_125_061,
+ u64::from(NIP23_LONG_FORM_DRAFT_KIND),
+ vec![vec!["d".to_owned(), "draft-a".to_owned()]],
+ "Draft body.",
+ )
+ .expect("draft");
+ let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
+ let long_form_key = format!(
+ "30023:{}:harvest-notes",
+ article.unsigned().pubkey().as_str()
+ );
+
+ assert_eq!(
+ store
+ .project_long_form(&listing, UnixTimestamp::new(1_714_125_062))
+ .await
+ .expect("not long form"),
+ LongFormProjectionOutcome::NotLongForm
+ );
+ assert_eq!(
+ store
+ .project_long_form(&draft, UnixTimestamp::new(1_714_125_062))
+ .await
+ .expect("draft long form"),
+ LongFormProjectionOutcome::Ineligible
+ );
+ assert_eq!(
+ store
+ .project_long_form(&article, UnixTimestamp::new(1_714_125_063))
+ .await
+ .expect("project article"),
+ LongFormProjectionOutcome::Projected
+ );
+
+ let row = store
+ .long_form_current_row(&long_form_key)
+ .await
+ .expect("long-form row")
+ .expect("long-form row exists");
+ assert_eq!(row["long_form_key"], long_form_key);
+ assert_eq!(row["event_id"], article.id().as_str());
+ assert_eq!(
+ row["author_pubkey"],
+ FixtureKey::Seller.public_key().as_str()
+ );
+ assert_eq!(row["d"], "harvest-notes");
+ assert_eq!(row["created_at"], 1_714_125_060_u64);
+ assert_eq!(row["updated_at"], 1_714_125_060_u64);
+ assert_eq!(row["published_at"], 1_714_125_000_u64);
+ assert_eq!(row["title"], "Harvest notes");
+ assert_eq!(row["image"], "https://radroots.test/harvest.jpg");
+ assert_eq!(row["summary"], "Long-form harvest field notes.");
+ assert_eq!(row["content"], "The storage carrots held well.");
+ assert_eq!(row["tags"][0], "carrots");
+ assert_eq!(row["tags"][1], "csa");
+ assert_eq!(row["referenced_events"][0], "4".repeat(EventId::HEX_LENGTH));
+ assert_eq!(
+ row["referenced_pubkeys"][0],
+ FixtureKey::Buyer.public_key().as_str()
+ );
+ assert_eq!(row["hidden"], false);
+ assert_eq!(row["deleted"], false);
+
+ let topics = store
+ .long_form_topic_rows(&long_form_key)
+ .await
+ .expect("topic rows");
+ assert_eq!(topics.len(), 2);
+ assert_eq!(topics[0]["topic"], "carrots");
+ assert_eq!(topics[1]["topic"], "csa");
+ assert_eq!(
+ store
+ .query_long_form_projections(
+ &LongFormProjectionQuery::new()
+ .with_author_pubkey(FixtureKey::Seller.public_key().as_str())
+ .with_topic("CSA")
+ .with_limit(5),
+ )
+ .await
+ .expect("long-form query")
+ .len(),
+ 1
+ );
+
+ let search = store
+ .search_document_row(&long_form_key)
+ .await
+ .expect("search row")
+ .expect("search row exists");
+ assert_eq!(search["doc_type"], "long_form");
+ assert_eq!(search["kind"], u64::from(NIP23_LONG_FORM_KIND));
+ assert_eq!(search["title"], "Harvest notes");
+ assert_eq!(search["body"], "The storage carrots held well.");
+ assert_eq!(search["status"], "published");
+ assert_eq!(search["visible"], true);
+ assert_eq!(
+ store
+ .query_search_documents(
+ &SearchDocumentQuery::new()
+ .with_text("carrots")
+ .with_doc_type("long_form")
+ .with_visible(true),
+ )
+ .await
+ .expect("search query")
+ .len(),
+ 1
+ );
+ }
+
+ #[tokio::test]
+ async fn long_form_projection_tracks_replacement_moderation_and_deletion() {
+ let store = memory_store().await;
+ store
+ .apply_plan(&base_migration_plan())
+ .await
+ .expect("apply plan");
+ let first = long_form_article(
+ 1_714_125_070,
+ "harvest-notes",
+ "First harvest notes",
+ "First body.",
+ &["carrots"],
+ );
+ let second = long_form_article(
+ 1_714_125_071,
+ "harvest-notes",
+ "Updated harvest notes",
+ "Updated body.",
+ &["storage"],
+ );
+ let long_form_key = format!(
+ "30023:{}:harvest-notes",
+ second.unsigned().pubkey().as_str()
+ );
+ let admin_pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH);
+ store
+ .store_raw_event(&StoredEvent::new(
+ second.clone(),
+ UnixTimestamp::new(1_714_125_072),
+ ))
+ .await
+ .expect("raw article");
+
+ assert_eq!(
+ store
+ .project_long_form(&first, UnixTimestamp::new(1_714_125_073))
+ .await
+ .expect("project first"),
+ LongFormProjectionOutcome::Projected
+ );
+ assert_eq!(
+ store
+ .project_long_form(&second, UnixTimestamp::new(1_714_125_074))
+ .await
+ .expect("project second"),
+ LongFormProjectionOutcome::Projected
+ );
+ assert_eq!(
+ store
+ .project_long_form(&first, UnixTimestamp::new(1_714_125_075))
+ .await
+ .expect("stale first"),
+ LongFormProjectionOutcome::Ineligible
+ );
+ assert_eq!(
+ store
+ .long_form_current_row(&long_form_key)
+ .await
+ .expect("row")
+ .expect("row exists")["event_id"],
+ second.id().as_str()
+ );
+ assert_eq!(
+ store
+ .long_form_topic_rows(&long_form_key)
+ .await
+ .expect("topics")[0]["topic"],
+ "storage"
+ );
+
+ assert_eq!(
+ store
+ .hide_event(
+ second.id(),
+ "long-form moderation",
+ "admin_api",
+ &admin_pubkey,
+ UnixTimestamp::new(1_714_125_076),
+ )
+ .await
+ .expect("hide article"),
+ HiddenEventOutcome::Hidden
+ );
+ assert!(
+ store
+ .query_long_form_projections(&LongFormProjectionQuery::new())
+ .await
+ .expect("hidden query")
+ .is_empty()
+ );
+ assert_eq!(
+ store
+ .long_form_current_row(&long_form_key)
+ .await
+ .expect("row")
+ .expect("row exists")["hidden"],
+ true
+ );
+ assert_eq!(
+ store
+ .long_form_topic_rows(&long_form_key)
+ .await
+ .expect("topics")[0]["hidden"],
+ true
+ );
+ assert_eq!(
+ store
+ .search_document_row(&long_form_key)
+ .await
+ .expect("search row")
+ .expect("search exists")["visible"],
+ false
+ );
+
+ assert_eq!(
+ store
+ .unhide_event(
+ second.id(),
+ "long-form restored",
+ &admin_pubkey,
+ UnixTimestamp::new(1_714_125_077),
+ )
+ .await
+ .expect("unhide article"),
+ HiddenEventOutcome::Unhidden
+ );
+ assert_eq!(
+ store
+ .query_long_form_projections(&LongFormProjectionQuery::new())
+ .await
+ .expect("visible query")
+ .len(),
+ 1
+ );
+ assert_eq!(
+ store
+ .search_document_row(&long_form_key)
+ .await
+ .expect("search row")
+ .expect("search exists")["visible"],
+ true
+ );
+
+ let deletion = build_fixture_event_from_parts(
+ FixtureKey::Seller,
+ 1_714_125_078,
+ 5,
+ vec![vec!["e".to_owned(), second.id().as_str().to_owned()]],
+ "",
+ )
+ .expect("deletion event");
+ assert_eq!(
+ store
+ .apply_deletion_markers(&deletion)
+ .await
+ .expect("delete article"),
+ DeletionMarkerOutcome::Applied { targets: 1 }
+ );
+ assert!(
+ store
+ .query_long_form_projections(&LongFormProjectionQuery::new())
+ .await
+ .expect("deleted query")
+ .is_empty()
+ );
+ assert_eq!(
+ store
+ .long_form_current_row(&long_form_key)
+ .await
+ .expect("row")
+ .expect("row exists")["deleted"],
+ true
+ );
+ assert_eq!(
+ store
+ .long_form_topic_rows(&long_form_key)
+ .await
+ .expect("topics")[0]["deleted"],
+ true
+ );
+ assert_eq!(
+ store
+ .search_document_row(&long_form_key)
+ .await
+ .expect("search row")
+ .expect("search exists")["visible"],
+ false
+ );
+ }
+
+ #[tokio::test]
async fn hidden_event_overlay_excludes_events_from_public_read_models() {
let store = memory_store().await;
store
@@ -6535,6 +7192,46 @@ mod tests {
.expect("reaction event")
}
+ fn long_form_article(
+ created_at: u64,
+ d: &str,
+ title: &str,
+ content: &str,
+ topics: &[&str],
+ ) -> Event {
+ let buyer_pubkey = FixtureKey::Buyer.public_key().as_str().to_owned();
+ let referenced_address = format!("30023:{buyer_pubkey}:soil-notes");
+ let mut tags = vec![
+ vec!["d".to_owned(), d.to_owned()],
+ vec!["title".to_owned(), title.to_owned()],
+ vec![
+ "summary".to_owned(),
+ "Long-form harvest field notes.".to_owned(),
+ ],
+ vec![
+ "image".to_owned(),
+ "https://radroots.test/harvest.jpg".to_owned(),
+ ],
+ vec!["published_at".to_owned(), "1714125000".to_owned()],
+ vec!["e".to_owned(), "4".repeat(EventId::HEX_LENGTH)],
+ vec!["a".to_owned(), referenced_address],
+ vec!["p".to_owned(), buyer_pubkey],
+ ];
+ tags.extend(
+ topics
+ .iter()
+ .map(|topic| vec!["t".to_owned(), (*topic).to_owned()]),
+ );
+ build_fixture_event_from_parts(
+ FixtureKey::Seller,
+ created_at,
+ u64::from(NIP23_LONG_FORM_KIND),
+ tags,
+ content,
+ )
+ .expect("long-form article")
+ }
+
fn synthetic_event(
id_digit: &str,
sig_digit: &str,