this repo has no description

refactor: Cleaned up and refactored matchers

Signed-off-by: Nick Gerakines <12125+ngerakines@users.noreply.github.com>

+389 -243
+1 -2
Cargo.toml
··· 9 9 10 10 [features] 11 11 default = [] 12 - rhai = ["dep:rhai"] 13 12 14 13 [dependencies] 15 14 anyhow = "1.0.88" ··· 42 41 tracing = { version = "0.1.40", features = ["async-await", "log", "valuable"] } 43 42 zstd = "0.13.2" 44 43 reqwest = { version = "0.12.9", features = ["json", "zstd", "rustls-tls"] } 45 - rhai = { version = "1.20.0", features = ["serde", "std", "sync"], optional = true} 44 + rhai = { version = "1.20.0", features = ["serde", "std", "sync"]}
+9
docs/playbook-dry-run.md
··· 1 + # Playbook: Dry Run 2 + 3 + To test matchers, you can run in a dry-run mode using an in-memory database. 4 + 5 + ```shell 6 + export DATABASE_URL=sqlite://file::memory:?cache=shared 7 + export RUST_LOG=supercell=debug,warning 8 + ``` 9 +
+16
etc/rhai_ngerakines_activity.rhai
··· 1 + if event.did != "did:plc:cbkjy5n7bk3ax2wplmtjofq2" { 2 + return false; 3 + } 4 + 5 + let rtype = event?.commit?.record["$type"]; 6 + switch rtype { 7 + "app.bsky.feed.post" => { 8 + return build_aturi(event); 9 + } 10 + "app.bsky.feed.like" => { 11 + return event?.commit?.record?.subject?.uri ?? false; 12 + } 13 + _ => { } 14 + } 15 + 16 + false
+3
migrations/20241111011116_feed_content_score.down.sql
··· 1 + -- Add down migration script here 2 + 3 + ALTER TABLE feed_content DROP COLUMN score;
+3
migrations/20241111011116_feed_content_score.up.sql
··· 1 + -- Add up migration script here 2 + 3 + ALTER TABLE feed_content ADD COLUMN score INT NOT NULL DEFAULT 0;
+80 -1
src/config.rs
··· 1 1 use std::collections::HashSet; 2 + use std::fmt; 3 + use std::marker::PhantomData; 4 + use std::str::FromStr; 2 5 3 6 use anyhow::{anyhow, Result}; 4 - use serde::Deserialize; 7 + use serde::de::{self, MapAccess, Visitor}; 8 + use serde::{Deserialize, Deserializer}; 5 9 6 10 #[derive(Clone, Deserialize)] 7 11 pub struct Feeds { 8 12 pub feeds: Vec<Feed>, 9 13 } 10 14 15 + #[derive(Clone, Debug, Deserialize)] 16 + #[serde(tag = "type")] 17 + pub enum FeedQuery { 18 + #[serde(rename = "simple")] 19 + Simple {}, 20 + 21 + #[serde(rename = "popular")] 22 + Popular { 23 + #[serde(default)] 24 + age_floor: i64, 25 + 26 + #[serde(default)] 27 + gravity: f64, 28 + }, 29 + } 30 + 11 31 #[derive(Clone, Deserialize)] 12 32 pub struct Feed { 13 33 pub uri: String, ··· 22 42 23 43 #[serde(default)] 24 44 pub deny: Option<String>, 45 + 46 + #[serde(default, deserialize_with = "string_or_struct")] 47 + pub query: FeedQuery, 25 48 26 49 pub matchers: Vec<Matcher>, 27 50 } ··· 276 299 &self.0 277 300 } 278 301 } 302 + 303 + impl Default for FeedQuery { 304 + fn default() -> Self { 305 + FeedQuery::Simple {} 306 + } 307 + } 308 + 309 + impl FromStr for FeedQuery { 310 + type Err = anyhow::Error; 311 + 312 + fn from_str(value: &str) -> Result<Self, Self::Err> { 313 + match value { 314 + "simple" => Ok(FeedQuery::Simple {}), 315 + "popular" => Ok(FeedQuery::Popular { 316 + age_floor: 0, 317 + gravity: 2.0, 318 + }), 319 + _ => Err(anyhow!("unsupported query")), 320 + } 321 + } 322 + } 323 + 324 + fn string_or_struct<'de, T, D>(deserializer: D) -> Result<T, D::Error> 325 + where 326 + T: Deserialize<'de> + FromStr<Err = anyhow::Error>, 327 + D: Deserializer<'de>, 328 + { 329 + struct StringOrStruct<T>(PhantomData<fn() -> T>); 330 + 331 + impl<'de, T> Visitor<'de> for StringOrStruct<T> 332 + where 333 + T: Deserialize<'de> + FromStr<Err = anyhow::Error>, 334 + { 335 + type Value = T; 336 + 337 + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 338 + formatter.write_str("string or FeedQuery") 339 + } 340 + 341 + fn visit_str<E>(self, value: &str) -> Result<T, E> 342 + where 343 + E: de::Error, 344 + { 345 + FromStr::from_str(value).map_err(|_| de::Error::custom("cannot deserialize field")) 346 + } 347 + 348 + fn visit_map<M>(self, map: M) -> Result<T, M::Error> 349 + where 350 + M: MapAccess<'de>, 351 + { 352 + Deserialize::deserialize(de::value::MapAccessDeserializer::new(map)) 353 + } 354 + } 355 + 356 + deserializer.deserialize_any(StringOrStruct(PhantomData)) 357 + }
+11 -7
src/consumer.rs
··· 11 11 12 12 use crate::config; 13 13 use crate::matcher::FeedMatchers; 14 + use crate::matcher::Match; 15 + use crate::matcher::MatchOperation; 14 16 use crate::storage; 15 17 use crate::storage::consumer_control_get; 16 18 use crate::storage::consumer_control_insert; ··· 176 178 let event_value = event_value.unwrap(); 177 179 178 180 for feed_matcher in self.feed_matchers.0.iter() { 179 - if let Some(match_result) = feed_matcher.matches(&event_value) { 181 + if let Some(Match(op, aturi)) = feed_matcher.matches(&event_value) { 180 182 tracing::debug!(feed_id = ?feed_matcher.feed, "matched event"); 181 - if match_result.matched { 182 - let feed_content = storage::model::FeedContent{ 183 - feed_id: feed_matcher.feed.clone(), 184 - uri: match_result.aturi, 185 - indexed_at: event.clone().time_us, 186 - }; 183 + let feed_content = storage::model::FeedContent{ 184 + feed_id: feed_matcher.feed.clone(), 185 + uri: aturi, 186 + indexed_at: event.clone().time_us, 187 + score: 1, 188 + }; 189 + if op == MatchOperation::Upsert { 187 190 feed_content_insert(&self.pool, &feed_content).await?; 188 191 } 192 + 189 193 } 190 194 } 191 195 }
+194 -191
src/matcher.rs
··· 2 2 3 3 use serde_json_path::JsonPath; 4 4 5 + use rhai::{ 6 + serde::to_dynamic, 7 + CustomType, Dynamic, Engine, Scope, TypeBuilder, AST, 8 + }; 9 + use std::{collections::HashMap, path::PathBuf, str::FromStr}; 10 + 5 11 use crate::config; 6 12 7 - #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] 8 - pub struct MatcherResult { 9 - pub matched: bool, 10 - pub aturi: String, 13 + #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq)] 14 + pub enum MatchOperation { 15 + #[default] 16 + Upsert, 17 + Update, 11 18 } 12 19 13 - impl PartialEq<bool> for MatcherResult { 14 - fn eq(&self, other: &bool) -> bool { 15 - self.matched == *other 16 - } 17 - } 20 + #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, CustomType)] 21 + pub struct Match(pub MatchOperation, pub String); 18 22 19 - impl MatcherResult { 20 - fn get_matched(&mut self) -> bool { 21 - self.matched 22 - } 23 - 24 - fn set_matched(&mut self, value: bool) { 25 - self.matched = value; 26 - } 27 - 28 - fn get_aturi(&mut self) -> String { 29 - self.aturi.clone() 30 - } 31 - 32 - fn set_aturi(&mut self, value: String) { 33 - self.aturi = value; 23 + impl Match { 24 + fn upsert(aturi: &str) -> Self { 25 + Self(MatchOperation::Upsert, aturi.to_string()) 34 26 } 35 27 } 36 28 37 29 pub trait Matcher: Sync + Send { 38 - fn matches(&self, value: &serde_json::Value) -> Result<MatcherResult>; 30 + fn matches(&self, value: &serde_json::Value) -> Result<Option<Match>>; 39 31 } 40 32 41 33 pub struct FeedMatcher { ··· 75 67 as Box<dyn Matcher>); 76 68 } 77 69 78 - #[cfg(feature = "rhai")] 79 70 config::Matcher::Rhai { script } => { 80 - matchers 81 - .push(Box::new(rhai::RhaiMatcher::new(script)?) as Box<dyn Matcher>); 82 - } 83 - 84 - #[cfg(not(feature = "rhai"))] 85 - config::Matcher::Rhai { .. } => { 86 - return Err(anyhow!("rhai not enabled in this build")) 71 + matchers.push(Box::new(RhaiMatcher::new(script)?) as Box<dyn Matcher>); 87 72 } 88 73 } 89 74 } ··· 96 81 } 97 82 98 83 impl FeedMatcher { 99 - pub(crate) fn matches(&self, value: &serde_json::Value) -> Option<MatcherResult> { 84 + pub(crate) fn matches(&self, value: &serde_json::Value) -> Option<Match> { 100 85 for matcher in self.matchers.iter() { 101 86 let result = matcher.matches(value); 102 87 if let Err(err) = result { ··· 104 89 continue; 105 90 } 106 91 let result = result.unwrap(); 107 - if result.matched { 108 - return Some(result); 92 + if result.is_some() { 93 + return result; 109 94 } 110 95 } 111 96 None ··· 137 122 } 138 123 139 124 impl Matcher for EqualsMatcher { 140 - fn matches(&self, value: &serde_json::Value) -> Result<MatcherResult> { 125 + fn matches(&self, value: &serde_json::Value) -> Result<Option<Match>> { 141 126 let nodes = self.path.query(value).all(); 142 127 143 128 let string_nodes = nodes ··· 152 137 .collect::<Vec<String>>(); 153 138 154 139 if string_nodes.iter().any(|value| value == &self.expected) { 155 - let aturi = extract_aturi(self.aturi_path.as_ref(), value).ok_or(anyhow!( 156 - "matcher matched but could not create at-uri: {:?}", 157 - value 158 - ))?; 159 - Ok(MatcherResult { 160 - matched: true, 161 - aturi, 162 - }) 140 + extract_aturi(self.aturi_path.as_ref(), value) 141 + .map(|value| Some(Match::upsert(&value))) 142 + .ok_or(anyhow!( 143 + "matcher matched but could not create at-uri: {:?}", 144 + value 145 + )) 163 146 } else { 164 - Ok(MatcherResult::default()) 147 + Ok(None) 165 148 } 166 149 } 167 150 } ··· 191 174 } 192 175 193 176 impl Matcher for PrefixMatcher { 194 - fn matches(&self, value: &serde_json::Value) -> Result<MatcherResult> { 177 + fn matches(&self, value: &serde_json::Value) -> Result<Option<Match>> { 195 178 let nodes = self.path.query(value).all(); 196 179 197 180 let string_nodes = nodes ··· 209 192 .iter() 210 193 .any(|value| value.starts_with(&self.prefix)); 211 194 if found { 212 - let aturi = extract_aturi(self.aturi_path.as_ref(), value).ok_or(anyhow!( 213 - "matcher matched but could not create at-uri: {:?}", 214 - value 215 - ))?; 216 - Ok(MatcherResult { 217 - matched: true, 218 - aturi, 219 - }) 195 + extract_aturi(self.aturi_path.as_ref(), value) 196 + .map(|value| Some(Match::upsert(&value))) 197 + .ok_or(anyhow!( 198 + "matcher matched but could not create at-uri: {:?}", 199 + value 200 + )) 220 201 } else { 221 - Ok(MatcherResult::default()) 202 + Ok(None) 222 203 } 223 204 } 224 205 } ··· 248 229 } 249 230 250 231 impl Matcher for SequenceMatcher { 251 - fn matches(&self, value: &serde_json::Value) -> Result<MatcherResult> { 232 + fn matches(&self, value: &serde_json::Value) -> Result<Option<Match>> { 252 233 let nodes = self.path.query(value).all(); 253 234 254 235 let string_nodes = nodes ··· 282 263 } 283 264 284 265 if last_found != -1 && found_index == self.expected.len() - 1 { 285 - let aturi = extract_aturi(self.aturi_path.as_ref(), value).ok_or(anyhow!( 286 - "matcher matched but could not create at-uri: {:?}", 287 - value 288 - ))?; 289 - return Ok(MatcherResult { 290 - matched: true, 291 - aturi, 292 - }); 266 + return extract_aturi(self.aturi_path.as_ref(), value) 267 + .map(|value| Some(Match::upsert(&value))) 268 + .ok_or(anyhow!( 269 + "matcher matched but could not create at-uri: {:?}", 270 + value 271 + )); 293 272 } 294 273 } 295 274 296 - Ok(MatcherResult::default()) 275 + Ok(None) 297 276 } 298 277 } 299 278 ··· 346 325 None 347 326 } 348 327 349 - #[cfg(feature = "rhai")] 350 - pub mod rhai { 328 + pub struct RhaiMatcher { 329 + source: String, 330 + engine: Engine, 331 + ast: AST, 332 + } 351 333 352 - use super::{Matcher, MatcherResult}; 353 - use anyhow::{anyhow, Context, Result}; 334 + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] 335 + #[serde(untagged)] 336 + pub enum MaybeMatch { 337 + Match(Match), 354 338 355 - use rhai::{serde::to_dynamic, Dynamic, Engine, Scope, AST}; 356 - use std::{path::PathBuf, str::FromStr}; 339 + #[serde(untagged)] 340 + Other { 341 + #[serde(flatten)] 342 + extra: HashMap<String, serde_json::Value>, 343 + }, 344 + } 357 345 358 - pub struct RhaiMatcher { 359 - source: String, 360 - engine: Engine, 361 - ast: AST, 346 + impl MaybeMatch { 347 + pub fn into_match(self) -> Option<Match> { 348 + match self { 349 + MaybeMatch::Match(m) => Some(m), 350 + _ => None, 351 + } 362 352 } 353 + } 363 354 364 - impl RhaiMatcher { 365 - pub(crate) fn new(source: &str) -> Result<Self> { 366 - let mut engine = Engine::new(); 367 - engine 368 - .register_type_with_name::<MatcherResult>("MatcherResult") 369 - .register_get_set( 370 - "matched", 371 - MatcherResult::get_matched, 372 - MatcherResult::set_matched, 373 - ) 374 - .register_get_set("aturi", MatcherResult::get_aturi, MatcherResult::set_aturi) 375 - .register_fn("new_matcher_result", MatcherResult::default) 376 - .register_fn("build_aturi", build_aturi); 377 - let ast = engine 378 - .compile_file(PathBuf::from_str(source)?) 379 - .context("cannot compile script")?; 380 - Ok(Self { 381 - source: source.to_string(), 382 - engine, 383 - ast, 384 - }) 385 - } 355 + impl RhaiMatcher { 356 + pub(crate) fn new(source: &str) -> Result<Self> { 357 + let mut engine = Engine::new(); 358 + engine 359 + .build_type::<Match>() 360 + .register_fn("build_aturi", build_aturi) 361 + .register_fn("new_match", Match::upsert); 362 + let ast = engine 363 + .compile_file(PathBuf::from_str(source)?) 364 + .context("cannot compile script")?; 365 + Ok(Self { 366 + source: source.to_string(), 367 + engine, 368 + ast, 369 + }) 386 370 } 371 + } 387 372 388 - impl Matcher for RhaiMatcher { 389 - fn matches(&self, value: &serde_json::Value) -> Result<MatcherResult> { 390 - let mut scope = Scope::new(); 391 - let value_map = to_dynamic(value); 392 - if let Err(err) = value_map { 393 - tracing::error!(source = ?self.source, error = ?err, "error converting value to dynamic"); 394 - return Ok(MatcherResult::default()); 395 - } 396 - let value_map = value_map.unwrap(); 397 - scope.push("event", value_map); 373 + fn dynamic_to_match(value: Dynamic) -> Result<Option<Match>> { 374 + if value.is_bool() || value.is_int() { 375 + return Ok(None); 376 + } 377 + if let Some(aturi) = value.clone().try_cast::<String>() { 378 + return Ok(Some(Match::upsert(&aturi))); 379 + } 380 + if let Some(match_value) = value.try_cast::<Match>() { 381 + return Ok(Some(match_value)); 382 + } 383 + Err(anyhow!("unsupported return value type: must be int, string, or match")) 384 + } 398 385 399 - self.engine 400 - .eval_ast_with_scope::<MatcherResult>(&mut scope, &self.ast) 401 - .context("error evaluating script") 386 + impl Matcher for RhaiMatcher { 387 + fn matches(&self, value: &serde_json::Value) -> Result<Option<Match>> { 388 + let mut scope = Scope::new(); 389 + let value_map = to_dynamic(value); 390 + if let Err(err) = value_map { 391 + tracing::error!(source = ?self.source, error = ?err, "error converting value to dynamic"); 392 + return Ok(None); 402 393 } 394 + let value_map = value_map.unwrap(); 395 + scope.push("event", value_map); 396 + 397 + self.engine 398 + .eval_ast_with_scope::<Dynamic>(&mut scope, &self.ast) 399 + .context("error evaluating script") 400 + .and_then(dynamic_to_match) 403 401 } 402 + } 404 403 405 - fn build_aturi_maybe(event: Dynamic) -> Result<String> { 406 - println!("{event:?}"); 407 - let event = event.as_map_ref().map_err(|err| anyhow!(err))?; 404 + fn build_aturi_maybe(event: Dynamic) -> Result<String> { 405 + let event = event.as_map_ref().map_err(|err| anyhow!(err))?; 408 406 409 - let commit = event 410 - .get("commit") 411 - .ok_or(anyhow!("no commit on event"))? 412 - .as_map_ref() 413 - .map_err(|err| anyhow!(err))?; 414 - let record = commit 415 - .get("record") 416 - .ok_or(anyhow!("no record on event commit"))? 417 - .as_map_ref() 418 - .map_err(|err| anyhow!(err))?; 407 + let commit = event 408 + .get("commit") 409 + .ok_or(anyhow!("no commit on event"))? 410 + .as_map_ref() 411 + .map_err(|err| anyhow!(err))?; 412 + let record = commit 413 + .get("record") 414 + .ok_or(anyhow!("no record on event commit"))? 415 + .as_map_ref() 416 + .map_err(|err| anyhow!(err))?; 419 417 420 - let rtype = record 421 - .get("$type") 422 - .ok_or(anyhow!("no $type on event commit record"))? 423 - .as_immutable_string_ref() 424 - .map_err(|err| anyhow!(err))?; 418 + let rtype = record 419 + .get("$type") 420 + .ok_or(anyhow!("no $type on event commit record"))? 421 + .as_immutable_string_ref() 422 + .map_err(|err| anyhow!(err))?; 425 423 426 - match rtype.as_str() { 427 - "app.bsky.feed.post" => { 428 - let did = event 429 - .get("did") 430 - .ok_or(anyhow!("no did on event"))? 431 - .as_immutable_string_ref() 432 - .map_err(|err| anyhow!(err))?; 433 - let collection = commit 434 - .get("collection") 435 - .ok_or(anyhow!("no collection on event"))? 436 - .as_immutable_string_ref() 437 - .map_err(|err| anyhow!(err))?; 438 - let rkey = commit 439 - .get("rkey") 440 - .ok_or(anyhow!("no rkey on event commit"))? 441 - .as_immutable_string_ref() 442 - .map_err(|err| anyhow!(err))?; 424 + match rtype.as_str() { 425 + "app.bsky.feed.post" => { 426 + let did = event 427 + .get("did") 428 + .ok_or(anyhow!("no did on event"))? 429 + .as_immutable_string_ref() 430 + .map_err(|err| anyhow!(err))?; 431 + let collection = commit 432 + .get("collection") 433 + .ok_or(anyhow!("no collection on event"))? 434 + .as_immutable_string_ref() 435 + .map_err(|err| anyhow!(err))?; 436 + let rkey = commit 437 + .get("rkey") 438 + .ok_or(anyhow!("no rkey on event commit"))? 439 + .as_immutable_string_ref() 440 + .map_err(|err| anyhow!(err))?; 443 441 444 - Ok(format!( 445 - "at://{}/{}/{}", 446 - did.as_str(), 447 - collection.as_str(), 448 - rkey.as_str() 449 - )) 450 - } 451 - _ => Err(anyhow!("no aturi for event")), 442 + Ok(format!( 443 + "at://{}/{}/{}", 444 + did.as_str(), 445 + collection.as_str(), 446 + rkey.as_str() 447 + )) 452 448 } 449 + _ => Err(anyhow!("no aturi for event")), 453 450 } 451 + } 454 452 455 - fn build_aturi(event: Dynamic) -> String { 456 - let aturi = build_aturi_maybe(event); 457 - if let Err(err) = aturi { 458 - println!("error {err:?}"); 459 - return "".into(); 460 - } 461 - aturi.unwrap() 453 + fn build_aturi(event: Dynamic) -> String { 454 + let aturi = build_aturi_maybe(event); 455 + if let Err(err) = aturi { 456 + tracing::warn!(error = ?err, "error creating at-uri"); 457 + return "".into(); 462 458 } 459 + aturi.unwrap() 463 460 } 464 461 465 462 #[cfg(test)] 466 463 mod tests { 467 464 468 465 use super::*; 466 + use anyhow::{anyhow, Result}; 467 + use std::path::PathBuf; 469 468 470 469 #[test] 471 - fn equals_matcher() { 470 + fn equals_matcher() -> Result<()> { 472 471 let raw_json = r#"{ 473 472 "did": "did:plc:tgudj2fjm77pzkuawquqhsxm", 474 473 "time_us": 1730491093829414, ··· 505 504 506 505 for (path, expected, result) in tests { 507 506 let matcher = EqualsMatcher::new(expected, path, &None).expect("matcher is valid"); 508 - assert_eq!(matcher.matches(&value).expect("match ok"), result); 507 + let maybe_match = matcher.matches(&value)?; 508 + assert_eq!(maybe_match.is_some(), result); 509 509 } 510 + 511 + Ok(()) 510 512 } 511 513 512 514 #[test] 513 - fn prefix_matcher() { 515 + fn prefix_matcher() -> Result<()> { 514 516 let raw_json = r#"{ 515 517 "did": "did:plc:tgudj2fjm77pzkuawquqhsxm", 516 518 "time_us": 1730491093829414, ··· 553 555 554 556 for (path, prefix, result) in tests { 555 557 let matcher = PrefixMatcher::new(prefix, path, &None).expect("matcher is valid"); 556 - assert_eq!(matcher.matches(&value).expect("match ok"), result); 558 + let maybe_match = matcher.matches(&value)?; 559 + assert_eq!(maybe_match.is_some(), result); 557 560 } 561 + 562 + Ok(()) 558 563 } 559 564 560 565 #[test] 561 - fn sequence_matcher() { 566 + fn sequence_matcher() -> Result<()> { 562 567 let raw_json = r#"{ 563 568 "did": "did:plc:tgudj2fjm77pzkuawquqhsxm", 564 569 "time_us": 1730491093829414, ··· 620 625 621 626 for (path, values, result) in tests { 622 627 let matcher = SequenceMatcher::new(&values, path, &None).expect("matcher is valid"); 623 - assert_eq!(matcher.matches(&value).expect("match ok"), result); 628 + let maybe_match = matcher.matches(&value)?; 629 + assert_eq!(maybe_match.is_some(), result); 624 630 } 631 + 632 + Ok(()) 625 633 } 626 634 627 635 #[test] 628 - fn sequence_matcher_edge_case_1() { 636 + fn sequence_matcher_edge_case_1() -> Result<()> { 629 637 let raw_json = r#"{"text": "Stellwerkstörung. Und Signalstörung. Und der Alternativzug ist auch ausgefallen. Und überhaupt."}"#; 630 638 let value: serde_json::Value = serde_json::from_str(raw_json).expect("json is valid"); 631 639 let matcher = SequenceMatcher::new( 632 640 &vec!["smoke".to_string(), "signal".to_string()], 633 641 "$.text", 634 642 &None, 635 - ) 636 - .expect("matcher is valid"); 637 - assert_eq!(matcher.matches(&value).expect("match ok"), false); 638 - } 639 - } 640 - 641 - #[cfg(all(test, feature = "rhai"))] 642 - mod rhaitests { 643 + )?; 644 + let maybe_match = matcher.matches(&value)?; 645 + assert_eq!(maybe_match.is_some(), false); 643 646 644 - use super::rhai::*; 645 - use super::*; 646 - use anyhow::{anyhow, Result}; 647 - use std::path::PathBuf; 647 + Ok(()) 648 + } 648 649 649 - #[cfg(feature = "rhai")] 650 650 #[test] 651 651 fn rhai_matcher() -> Result<()> { 652 652 let testdata = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata"); 653 653 654 - let tests = vec![ 654 + let tests: Vec<(&str, Vec<(&str, bool, &str)>)> = vec![ 655 655 ( 656 656 "post1.json", 657 - [ 657 + vec![ 658 + ("rhai_match_nothing.rhai", false, ""), 658 659 ( 659 660 "rhai_match_everything.rhai", 661 + true, 662 + "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/app.bsky.feed.post/3laadb7behk25", 663 + ), 664 + ( 665 + "rhai_match_everything_simple.rhai", 660 666 true, 661 667 "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/app.bsky.feed.post/3laadb7behk25", 662 668 ), ··· 675 681 ), 676 682 ( 677 683 "post2.json", 678 - [ 684 + vec![ 679 685 ( 680 686 "rhai_match_everything.rhai", 681 687 true, ··· 714 720 .context("could not construct matcher")?; 715 721 let result = matcher.matches(&value)?; 716 722 assert_eq!( 717 - result.matched, matched, 723 + result.is_some_and(|e| e.1 == aturi), 724 + matched, 718 725 "matched {}: {}", 719 - input_json, matcher_file_name 720 - ); 721 - assert_eq!( 722 - result.aturi, aturi, 723 - "aturi {}: {}", 724 - input_json, matcher_file_name 726 + input_json, 727 + matcher_file_name 725 728 ); 726 729 } 727 730 }
+39 -5
src/storage.rs
··· 15 15 pub feed_id: String, 16 16 pub uri: String, 17 17 pub indexed_at: i64, 18 + pub score: i32, 18 19 } 19 20 } 20 21 21 - pub async fn feed_content_insert( 22 - pool: &StoragePool, 23 - feed_content: &model::FeedContent, 24 - ) -> Result<()> { 22 + pub async fn feed_content_insert(pool: &StoragePool, feed_content: &FeedContent) -> Result<()> { 25 23 let mut tx = pool.begin().await.context("failed to begin transaction")?; 26 24 27 25 let now = Utc::now(); 28 - sqlx::query("INSERT OR REPLACE INTO feed_content (feed_id, uri, indexed_at, updated_at) VALUES (?, ?, ?, ?)") 26 + sqlx::query("INSERT OR REPLACE INTO feed_content (feed_id, uri, indexed_at, updated_at, score) VALUES (?, ?, ?, ?, ?)") 29 27 .bind(&feed_content.feed_id) 30 28 .bind(&feed_content.uri) 31 29 .bind(feed_content.indexed_at) 32 30 .bind(now) 31 + .bind(feed_content.score) 33 32 .execute(tx.as_mut()) 34 33 .await.context("failed to insert feed content record")?; 35 34 ··· 37 36 } 38 37 39 38 pub async fn feed_content_paginate( 39 + pool: &StoragePool, 40 + feed_uri: &str, 41 + limit: Option<u16>, 42 + cursor: Option<i64>, 43 + ) -> Result<Vec<FeedContent>> { 44 + let mut tx = pool.begin().await.context("failed to begin transaction")?; 45 + 46 + let limit = limit.unwrap_or(20).clamp(1, 100); 47 + 48 + let results = if let Some(indexed_at) = cursor { 49 + let query = "SELECT * FROM feed_content WHERE feed_id = ? AND indexed_at < ? ORDER BY indexed_at DESC LIMIT ?"; 50 + 51 + sqlx::query_as::<_, FeedContent>(query) 52 + .bind(feed_uri) 53 + .bind(indexed_at) 54 + .bind(limit) 55 + .fetch_all(tx.as_mut()) 56 + .await? 57 + } else { 58 + let query = "SELECT * FROM feed_content WHERE feed_id = ? ORDER BY indexed_at DESC LIMIT ?"; 59 + 60 + sqlx::query_as::<_, FeedContent>(query) 61 + .bind(feed_uri) 62 + .bind(limit) 63 + .fetch_all(tx.as_mut()) 64 + .await? 65 + }; 66 + 67 + tx.commit().await.context("failed to commit transaction")?; 68 + 69 + Ok(results) 70 + } 71 + 72 + pub async fn feed_content_paginate_popular( 40 73 pool: &StoragePool, 41 74 feed_uri: &str, 42 75 limit: Option<u16>, ··· 180 213 uri: "at://did:plc:qadlgs4xioohnhi2jg54mqds/app.bsky.feed.post/3la3bqjg4hx2n" 181 214 .to_string(), 182 215 indexed_at: 1730673934229172_i64, 216 + score: 1, 183 217 }; 184 218 super::feed_content_insert(&pool, &record) 185 219 .await
+2 -8
testdata/rhai_match_everything.rhai
··· 1 - let result = new_matcher_result(); 2 - result.matched = true; 3 - 4 - if result.matched { 5 - result.aturi = build_aturi(event); 6 - } 7 - 8 - result 1 + let aturi = build_aturi(event); 2 + return new_match(aturi);
+1
testdata/rhai_match_everything_simple.rhai
··· 1 + build_aturi(event)
+19
testdata/rhai_match_liked.rhai
··· 1 + 2 + let result = new_matcher_result(); 3 + 4 + let rtype = event?.commit?.record["$type"]; 5 + 6 + switch rtype { 7 + "app.bsky.feed.like" => { 8 + result.matched = true; 9 + } 10 + // noop 11 + _ => { } 12 + } 13 + 14 + if result.matched { 15 + result.aturi = build_aturi(event); 16 + } 17 + 18 + 19 + result
+1
testdata/rhai_match_nothing.rhai
··· 1 + false
+4 -11
testdata/rhai_match_poster.rhai
··· 1 - 2 - let result = new_matcher_result(); 3 - 4 1 let rtype = event?.commit?.record["$type"]; 5 - 6 2 switch rtype { 7 3 "app.bsky.feed.post" => { 8 - result.matched = event.did == "did:plc:cbkjy5n7bk3ax2wplmtjofq2"; 4 + if event.did == "did:plc:cbkjy5n7bk3ax2wplmtjofq2" { 5 + return build_aturi(event); 6 + } 9 7 } 10 8 _ => { } 11 9 } 12 - 13 - if result.matched { 14 - result.aturi = build_aturi(event); 15 - } 16 - 17 - result 10 + false
+4 -8
testdata/rhai_match_reply_root.rhai
··· 1 - let result = new_matcher_result(); 2 - 3 1 let rtype = event?.commit?.record["$type"]; 4 2 5 3 if rtype != "app.bsky.feed.post" { 6 - return result; 4 + return false; 7 5 } 8 6 9 7 let root_uri = event?.commit?.record?.reply?.root?.uri; 10 8 11 - result.matched = `${root_uri}`.starts_with("at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/app.bsky.feed.post/"); 12 - 13 - if result.matched { 14 - result.aturi = build_aturi(event); 9 + if `${root_uri}`.starts_with("at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/app.bsky.feed.post/") { 10 + return build_aturi(event); 15 11 } 16 12 17 - result 13 + false
+2 -10
testdata/rhai_match_type.rhai
··· 1 - 2 - let result = new_matcher_result(); 3 - 4 1 let rtype = event?.commit?.record["$type"]; 5 2 6 3 switch rtype { 7 4 "app.bsky.feed.post" => { 8 - result.matched = true; 5 + return build_aturi(event); 9 6 } 10 7 // noop 11 8 _ => { } 12 9 } 13 10 14 - if result.matched { 15 - result.aturi = build_aturi(event); 16 - } 17 - 18 - 19 - result 11 + false