BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add owner-scoped search bindings with keyword and semantic modes

+872 -95
+4 -4
docs/tasks/06-search.md
··· 44 44 45 45 #### Search Result Context 46 46 47 - - [ ] Implement `search_posts(query, mode, limit)`: 47 + - [x] Implement `search_posts(query, mode, limit)`: 48 48 - `keyword`: FTS5 MATCH query (always available) 49 49 - `semantic`: embed query string → vec similarity search (requires embeddings enabled) 50 50 - `hybrid`: run both, merge via reciprocal rank fusion (falls back to keyword-only if embeddings disabled) 51 - - [ ] `get_sync_status(did)` → last sync time, post counts, cursor state 52 - - [ ] Model management: download `nomic-embed-text-v1.5` ONNX on first use to `<app_data_dir>/models/` (skipped when embeddings disabled) 53 - - [ ] Background sync: trigger after login, then every 15 min 51 + - [x] `get_sync_status(did)` → last sync time, post counts, cursor state 52 + - [x] Model management: download `nomic-embed-text-v1.5` ONNX on first use to `<app_data_dir>/models/` (skipped when embeddings disabled) 53 + - [x] Background sync: trigger after login, then every 15 min 54 54 55 55 ### Frontend 56 56
+8 -1
src-tauri/src/commands/search.rs
··· 1 1 #![allow(clippy::needless_pass_by_value)] 2 2 3 3 use super::super::error::AppError; 4 - use super::super::search::{self, SyncStatus}; 4 + use super::super::search::{self, PostResult, SyncStatus}; 5 5 use super::super::state::AppState; 6 6 use serde_json::Value; 7 7 use tauri::{AppHandle, State}; ··· 11 11 query: String, sort: Option<String>, limit: Option<u32>, cursor: Option<String>, state: State<'_, AppState>, 12 12 ) -> Result<Value, AppError> { 13 13 search::search_posts_network(query, sort, limit, cursor, &state).await 14 + } 15 + 16 + #[tauri::command] 17 + pub fn search_posts( 18 + query: String, mode: String, limit: u32, app: AppHandle, state: State<'_, AppState>, 19 + ) -> Result<Vec<PostResult>, AppError> { 20 + search::search_posts(query, mode, limit, &app, &state) 14 21 } 15 22 16 23 #[tauri::command]
+5
src-tauri/src/db.rs
··· 42 42 Migration::new(4, "account_avatars", include_str!("migrations/004_account_avatars.sql")), 43 43 Migration::new(5, "sync_state", include_str!("migrations/005_sync_state.sql")), 44 44 Migration::new(6, "app_settings", include_str!("migrations/006_app_settings.sql")), 45 + Migration::new( 46 + 7, 47 + "search_owner_scope", 48 + include_str!("migrations/007_search_owner_scope.sql"), 49 + ), 45 50 ]; 46 51 47 52 pub fn initialize_database(app: &AppHandle) -> Result<DbPool, AppError> {
+2
src-tauri/src/lib.rs
··· 29 29 30 30 AppState::spawn_token_refresh_task(app.handle().clone()); 31 31 notifications::spawn_notification_poll_task(app.handle().clone()); 32 + search::spawn_search_sync_task(app.handle().clone()); 32 33 33 34 let app_handle = app.handle().clone(); 34 35 app.deep_link().on_open_url(move |event| { ··· 100 101 cmd::explorer::export_repo_car, 101 102 cmd::explorer::query_labels, 102 103 cmd::search::search_posts_network, 104 + cmd::search::search_posts, 103 105 cmd::search::search_actors, 104 106 cmd::search::search_starter_packs, 105 107 cmd::search::sync_posts,
+104
src-tauri/src/migrations/007_search_owner_scope.sql
··· 1 + DROP TRIGGER IF EXISTS posts_ai; 2 + DROP TRIGGER IF EXISTS posts_ad; 3 + DROP TRIGGER IF EXISTS posts_au; 4 + 5 + ALTER TABLE posts RENAME TO posts_legacy; 6 + 7 + DROP TABLE IF EXISTS posts_fts; 8 + DROP TABLE IF EXISTS posts_vec; 9 + 10 + CREATE TABLE posts ( 11 + storage_key TEXT PRIMARY KEY, 12 + owner_did TEXT NOT NULL, 13 + uri TEXT NOT NULL, 14 + cid TEXT NOT NULL, 15 + author_did TEXT NOT NULL, 16 + author_handle TEXT, 17 + text TEXT, 18 + created_at TEXT, 19 + indexed_at TEXT DEFAULT CURRENT_TIMESTAMP, 20 + json_record TEXT, 21 + source TEXT NOT NULL, 22 + UNIQUE(owner_did, source, uri) 23 + ); 24 + 25 + CREATE VIRTUAL TABLE posts_fts USING fts5( 26 + text, 27 + content=posts, 28 + content_rowid=rowid 29 + ); 30 + 31 + CREATE VIRTUAL TABLE posts_vec USING vec0( 32 + storage_key TEXT PRIMARY KEY, 33 + embedding float[768] 34 + ); 35 + 36 + CREATE TRIGGER posts_ai AFTER INSERT ON posts BEGIN 37 + INSERT INTO posts_fts(rowid, text) VALUES (new.rowid, new.text); 38 + END; 39 + 40 + CREATE TRIGGER posts_ad AFTER DELETE ON posts BEGIN 41 + INSERT INTO posts_fts(posts_fts, rowid, text) 42 + VALUES('delete', old.rowid, old.text); 43 + END; 44 + 45 + CREATE TRIGGER posts_au AFTER UPDATE ON posts BEGIN 46 + INSERT INTO posts_fts(posts_fts, rowid, text) 47 + VALUES('delete', old.rowid, old.text); 48 + INSERT INTO posts_fts(rowid, text) VALUES (new.rowid, new.text); 49 + END; 50 + 51 + WITH migrated_posts AS ( 52 + SELECT 53 + CASE 54 + WHEN ( 55 + SELECT COUNT(DISTINCT ss.did) 56 + FROM sync_state ss 57 + WHERE ss.source = legacy.source 58 + ) = 1 THEN COALESCE(( 59 + SELECT ss.did 60 + FROM sync_state ss 61 + WHERE ss.source = legacy.source 62 + LIMIT 1 63 + ), '') 64 + ELSE '' 65 + END AS owner_did, 66 + legacy.uri, 67 + legacy.cid, 68 + legacy.author_did, 69 + legacy.author_handle, 70 + legacy.text, 71 + legacy.created_at, 72 + legacy.indexed_at, 73 + legacy.json_record, 74 + legacy.source 75 + FROM posts_legacy legacy 76 + ) 77 + INSERT INTO posts( 78 + storage_key, 79 + owner_did, 80 + uri, 81 + cid, 82 + author_did, 83 + author_handle, 84 + text, 85 + created_at, 86 + indexed_at, 87 + json_record, 88 + source 89 + ) 90 + SELECT 91 + owner_did || '|' || source || '|' || uri, 92 + owner_did, 93 + uri, 94 + cid, 95 + author_did, 96 + author_handle, 97 + text, 98 + created_at, 99 + indexed_at, 100 + json_record, 101 + source 102 + FROM migrated_posts; 103 + 104 + DROP TABLE posts_legacy;
+749 -90
src-tauri/src/search.rs
··· 11 11 use jacquard::xrpc::XrpcClient; 12 12 use rusqlite::{params, Connection, OptionalExtension}; 13 13 use serde::Serialize; 14 + use std::collections::HashMap; 14 15 use std::path::PathBuf; 15 16 use std::sync::Arc; 17 + use std::time::{Duration, Instant}; 16 18 use tauri::{AppHandle, Manager}; 17 19 use tauri_plugin_log::log; 18 20 21 + const DEFAULT_RRF_K: f64 = 60.0; 22 + const SEARCH_SYNC_CHECK_INTERVAL: Duration = Duration::from_secs(5); 23 + const SEARCH_SYNC_INTERVAL: Duration = Duration::from_secs(15 * 60); 24 + 19 25 #[derive(Debug, Serialize)] 20 26 #[serde(rename_all = "camelCase")] 21 27 pub struct SyncStatus { ··· 25 31 pub last_synced_at: Option<String>, 26 32 } 27 33 34 + #[derive(Clone, Debug, Serialize)] 35 + #[serde(rename_all = "camelCase")] 36 + pub struct PostResult { 37 + pub uri: String, 38 + pub cid: String, 39 + pub author_did: String, 40 + pub author_handle: Option<String>, 41 + pub text: Option<String>, 42 + pub created_at: Option<String>, 43 + pub source: String, 44 + pub score: f64, 45 + } 46 + 47 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 48 + enum SearchMode { 49 + Keyword, 50 + Semantic, 51 + Hybrid, 52 + } 53 + 54 + #[derive(Clone, Debug)] 55 + struct SearchRow { 56 + storage_key: String, 57 + post: PostResult, 58 + } 59 + 28 60 fn validate_query(query: &str) -> Result<()> { 29 61 if query.trim().is_empty() { 30 62 return Err(AppError::validation("search query must not be empty")); ··· 32 64 Ok(()) 33 65 } 34 66 67 + fn validate_limit(limit: u32) -> Result<usize> { 68 + match limit { 69 + 0 => Err(AppError::validation("search limit must be greater than zero")), 70 + _ => Ok(limit as usize), 71 + } 72 + } 73 + 74 + fn validate_search_mode(mode: &str) -> Result<SearchMode> { 75 + match mode { 76 + "keyword" => Ok(SearchMode::Keyword), 77 + "semantic" => Ok(SearchMode::Semantic), 78 + "hybrid" => Ok(SearchMode::Hybrid), 79 + _ => Err(AppError::validation( 80 + "search mode must be 'keyword', 'semantic', or 'hybrid'", 81 + )), 82 + } 83 + } 84 + 35 85 fn validate_source(source: &str) -> Result<()> { 36 86 match source { 37 87 "like" | "bookmark" => Ok(()), ··· 39 89 } 40 90 } 41 91 92 + fn storage_key(owner_did: &str, source: &str, uri: &str) -> String { 93 + format!("{owner_did}|{source}|{uri}") 94 + } 95 + 96 + fn active_session_did(state: &AppState) -> Result<Option<String>> { 97 + Ok(state 98 + .active_session 99 + .read() 100 + .map_err(|error| { 101 + log::error!("active_session poisoned: {error}"); 102 + AppError::StatePoisoned("active_session") 103 + })? 104 + .as_ref() 105 + .map(|session| session.did.clone())) 106 + } 107 + 42 108 async fn get_session(state: &AppState) -> Result<Arc<LazuriteOAuthSession>> { 43 109 let did = state 44 110 .active_session ··· 95 161 96 162 /// Upsert a single `FeedViewPost` JSON item into the `posts` table. 97 163 /// On conflict (same uri) updates mutable fields but keeps indexed_at. 98 - fn db_upsert_post(conn: &Connection, feed_item: &serde_json::Value, source: &str) -> Result<()> { 164 + fn db_upsert_post(conn: &Connection, owner_did: &str, feed_item: &serde_json::Value, source: &str) -> Result<()> { 99 165 let post = feed_item.get("post").unwrap_or(feed_item); 100 166 101 167 let uri = post ··· 119 185 let text = record.and_then(|r| r.get("text")).and_then(|v| v.as_str()); 120 186 let created_at = record.and_then(|r| r.get("createdAt")).and_then(|v| v.as_str()); 121 187 let json_record = record.map(|r| r.to_string()); 188 + let storage_key = storage_key(owner_did, source, uri); 122 189 123 190 conn.execute( 124 - "INSERT INTO posts(uri, cid, author_did, author_handle, text, created_at, json_record, source) 125 - VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) 126 - ON CONFLICT(uri) DO UPDATE SET 191 + "INSERT INTO posts(storage_key, owner_did, uri, cid, author_did, author_handle, text, created_at, json_record, source) 192 + VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) 193 + ON CONFLICT(storage_key) DO UPDATE SET 127 194 cid = excluded.cid, 128 195 author_handle = excluded.author_handle, 129 196 text = excluded.text, 197 + created_at = excluded.created_at, 130 198 json_record = excluded.json_record", 131 199 params![ 200 + storage_key, 201 + owner_did, 132 202 uri, 133 203 cid, 134 204 author_did, ··· 142 212 Ok(()) 143 213 } 144 214 145 - fn db_post_count(conn: &Connection, source: &str) -> Result<i64> { 146 - conn.query_row("SELECT COUNT(*) FROM posts WHERE source = ?1", params![source], |row| { 147 - row.get(0) 148 - }) 215 + fn db_post_count(conn: &Connection, owner_did: &str, source: &str) -> Result<i64> { 216 + conn.query_row( 217 + "SELECT COUNT(*) FROM posts WHERE owner_did = ?1 AND source = ?2", 218 + params![owner_did, source], 219 + |row| row.get(0), 220 + ) 149 221 .map_err(AppError::from) 150 222 } 151 223 152 - fn db_sync_status(conn: &Connection, source: &str) -> Result<SyncStatus> { 153 - let post_count = db_post_count(conn, source)?; 224 + fn db_sync_status(conn: &Connection, did: &str, source: &str) -> Result<SyncStatus> { 225 + let post_count = db_post_count(conn, did, source)?; 154 226 let (cursor, last_synced_at) = conn 155 227 .query_row( 156 - "SELECT cursor, last_synced_at FROM sync_state WHERE source = ?1", 157 - params![source], 228 + "SELECT cursor, last_synced_at FROM sync_state WHERE did = ?1 AND source = ?2", 229 + params![did, source], 158 230 |row| Ok((row.get::<_, Option<String>>(0)?, row.get::<_, Option<String>>(1)?)), 159 231 ) 160 232 .optional()? ··· 306 378 { 307 379 let conn = state.auth_store.lock_connection()?; 308 380 for item in &feed { 309 - db_upsert_post(&conn, item, &source)?; 381 + db_upsert_post(&conn, &did, item, &source)?; 310 382 } 311 383 db_save_sync_state(&conn, &did, &source, next_cursor.as_deref())?; 312 384 } ··· 326 398 } 327 399 328 400 let conn = state.auth_store.lock_connection()?; 329 - db_sync_status(&conn, &source) 401 + db_sync_status(&conn, &did, &source) 330 402 } 331 403 332 404 /// Returns sync status for all sources for the given DID. 333 405 pub fn get_sync_status(did: &str, state: &AppState) -> Result<Vec<SyncStatus>> { 334 406 let conn = state.auth_store.lock_connection()?; 335 - let mut stmt = conn.prepare( 336 - "SELECT ss.source, 337 - COUNT(p.uri) AS post_count, 338 - ss.cursor, 339 - ss.last_synced_at 340 - FROM sync_state ss 341 - LEFT JOIN posts p ON p.source = ss.source 342 - WHERE ss.did = ?1 343 - GROUP BY ss.source", 344 - )?; 407 + ["like", "bookmark"] 408 + .into_iter() 409 + .map(|source| db_sync_status(&conn, did, source)) 410 + .collect() 411 + } 345 412 346 - let rows = stmt.query_map(params![did], |row| { 347 - Ok(SyncStatus { 348 - source: row.get(0)?, 349 - post_count: row.get(1)?, 350 - cursor: row.get(2)?, 351 - last_synced_at: row.get(3)?, 352 - }) 353 - })?; 413 + const EMBED_BATCH_SIZE: usize = 32; 354 414 355 - rows.collect::<rusqlite::Result<Vec<_>>>().map_err(AppError::from) 415 + fn build_embedding_model(models_dir: PathBuf) -> Result<TextEmbedding> { 416 + TextEmbedding::try_new( 417 + TextInitOptions::new(EmbeddingModel::NomicEmbedTextV15) 418 + .with_cache_dir(models_dir) 419 + .with_show_download_progress(false), 420 + ) 421 + .map_err(|error| AppError::validation(format!("failed to init embedding model: {error}"))) 356 422 } 357 423 358 - const EMBED_BATCH_SIZE: usize = 32; 359 - 360 424 fn resolve_models_dir(app: &AppHandle) -> Result<PathBuf> { 361 425 let mut dir = app 362 426 .path() ··· 387 451 Ok(()) 388 452 } 389 453 390 - /// Returns (uri, text) for posts that have no embedding yet. 454 + fn db_keyword_search(conn: &Connection, owner_did: &str, query: &str, limit: usize) -> Result<Vec<SearchRow>> { 455 + let match_query = build_fts_match_query(query); 456 + let mut stmt = conn.prepare( 457 + "SELECT p.storage_key, 458 + p.uri, 459 + p.cid, 460 + p.author_did, 461 + p.author_handle, 462 + p.text, 463 + p.created_at, 464 + p.source, 465 + bm25(posts_fts) AS rank 466 + FROM posts_fts 467 + JOIN posts p ON p.rowid = posts_fts.rowid 468 + WHERE p.owner_did = ?1 469 + AND posts_fts MATCH ?2 470 + ORDER BY rank ASC, p.created_at DESC, p.uri ASC 471 + LIMIT ?3", 472 + )?; 473 + 474 + let rows = stmt.query_map( 475 + params![owner_did, match_query, limit as i64], 476 + search_row_from_keyword_row, 477 + )?; 478 + rows.collect::<rusqlite::Result<Vec<_>>>().map_err(AppError::from) 479 + } 480 + 481 + fn db_semantic_search( 482 + conn: &Connection, owner_did: &str, query_embedding: &[f32], limit: usize, 483 + ) -> Result<Vec<SearchRow>> { 484 + let bytes: Vec<u8> = query_embedding.iter().flat_map(|f| f.to_le_bytes()).collect(); 485 + let mut stmt = conn.prepare( 486 + "SELECT p.storage_key, 487 + p.uri, 488 + p.cid, 489 + p.author_did, 490 + p.author_handle, 491 + p.text, 492 + p.created_at, 493 + p.source, 494 + v.distance 495 + FROM posts_vec v 496 + JOIN posts p ON p.storage_key = v.storage_key 497 + WHERE p.owner_did = ?1 498 + AND v.embedding MATCH ?2 499 + AND v.k = ?3 500 + ORDER BY v.distance ASC, p.created_at DESC, p.uri ASC 501 + ", 502 + )?; 503 + 504 + let rows = stmt.query_map( 505 + params![owner_did, bytes.as_slice(), limit as i64], 506 + search_row_from_semantic_row, 507 + )?; 508 + rows.collect::<rusqlite::Result<Vec<_>>>().map_err(AppError::from) 509 + } 510 + 511 + fn search_row_from_keyword_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<SearchRow> { 512 + let raw_rank = row.get::<_, f64>(8)?; 513 + Ok(SearchRow { 514 + storage_key: row.get(0)?, 515 + post: PostResult { 516 + uri: row.get(1)?, 517 + cid: row.get(2)?, 518 + author_did: row.get(3)?, 519 + author_handle: row.get(4)?, 520 + text: row.get(5)?, 521 + created_at: row.get(6)?, 522 + source: row.get(7)?, 523 + score: -raw_rank, 524 + }, 525 + }) 526 + } 527 + 528 + fn search_row_from_semantic_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<SearchRow> { 529 + let distance = row.get::<_, f64>(8)?; 530 + Ok(SearchRow { 531 + storage_key: row.get(0)?, 532 + post: PostResult { 533 + uri: row.get(1)?, 534 + cid: row.get(2)?, 535 + author_did: row.get(3)?, 536 + author_handle: row.get(4)?, 537 + text: row.get(5)?, 538 + created_at: row.get(6)?, 539 + source: row.get(7)?, 540 + score: 1.0 / (1.0 + distance), 541 + }, 542 + }) 543 + } 544 + 545 + fn build_fts_match_query(query: &str) -> String { 546 + let tokens: Vec<String> = query 547 + .split_whitespace() 548 + .filter(|token| !token.is_empty()) 549 + .map(|token| format!("\"{}\"", token.replace('"', "\"\""))) 550 + .collect(); 551 + 552 + if tokens.is_empty() { 553 + format!("\"{}\"", query.trim().replace('"', "\"\"")) 554 + } else { 555 + tokens.join(" AND ") 556 + } 557 + } 558 + 559 + fn rrf_merge(keyword_rows: Vec<SearchRow>, semantic_rows: Vec<SearchRow>, limit: usize) -> Vec<PostResult> { 560 + let mut fused: HashMap<String, SearchRow> = HashMap::new(); 561 + let mut scores: HashMap<String, f64> = HashMap::new(); 562 + 563 + for rows in [keyword_rows, semantic_rows] { 564 + for (rank, row) in rows.into_iter().enumerate() { 565 + let score = 1.0 / (DEFAULT_RRF_K + rank as f64 + 1.0); 566 + scores 567 + .entry(row.storage_key.clone()) 568 + .and_modify(|value| *value += score) 569 + .or_insert(score); 570 + fused.entry(row.storage_key.clone()).or_insert(row); 571 + } 572 + } 573 + 574 + let mut rows: Vec<SearchRow> = fused 575 + .into_iter() 576 + .filter_map(|(key, mut row)| { 577 + scores.get(&key).map(|score| { 578 + row.post.score = *score; 579 + row 580 + }) 581 + }) 582 + .collect(); 583 + 584 + rows.sort_by(|left, right| { 585 + right 586 + .post 587 + .score 588 + .total_cmp(&left.post.score) 589 + .then_with(|| right.post.created_at.cmp(&left.post.created_at)) 590 + .then_with(|| left.post.uri.cmp(&right.post.uri)) 591 + }); 592 + 593 + rows.into_iter().take(limit).map(|row| row.post).collect() 594 + } 595 + 596 + fn run_local_search( 597 + conn: &Connection, owner_did: &str, query: &str, mode: SearchMode, limit: usize, embeddings_enabled: bool, 598 + query_embedding: Option<&[f32]>, 599 + ) -> Result<Vec<PostResult>> { 600 + match mode { 601 + SearchMode::Keyword => { 602 + db_keyword_search(conn, owner_did, query, limit).map(|rows| rows.into_iter().map(|row| row.post).collect()) 603 + } 604 + SearchMode::Semantic => { 605 + if !embeddings_enabled { 606 + return Err(AppError::validation( 607 + "semantic search is unavailable while embeddings are disabled", 608 + )); 609 + } 610 + 611 + let query_embedding = 612 + query_embedding.ok_or_else(|| AppError::validation("semantic search query embedding missing"))?; 613 + db_semantic_search(conn, owner_did, query_embedding, limit) 614 + .map(|rows| rows.into_iter().map(|row| row.post).collect()) 615 + } 616 + SearchMode::Hybrid => { 617 + let candidate_limit = limit.saturating_mul(4).min(100); 618 + let keyword_rows = db_keyword_search(conn, owner_did, query, candidate_limit)?; 619 + 620 + if !embeddings_enabled { 621 + return Ok(keyword_rows.into_iter().take(limit).map(|row| row.post).collect()); 622 + } 623 + 624 + let Some(query_embedding) = query_embedding else { 625 + return Err(AppError::validation("hybrid search query embedding missing")); 626 + }; 627 + 628 + let semantic_rows = db_semantic_search(conn, owner_did, query_embedding, candidate_limit)?; 629 + Ok(rrf_merge(keyword_rows, semantic_rows, limit)) 630 + } 631 + } 632 + } 633 + 634 + fn embed_query_text(query: &str, models_dir: PathBuf) -> Result<Vec<f32>> { 635 + let mut model = build_embedding_model(models_dir)?; 636 + let embeddings = model 637 + .embed(vec![query.to_owned()], Some(1)) 638 + .map_err(|error| AppError::validation(format!("embedding error: {error}")))?; 639 + 640 + embeddings 641 + .into_iter() 642 + .next() 643 + .ok_or_else(|| AppError::validation("embedding model returned no query embedding")) 644 + } 645 + 646 + /// Returns (storage_key, text) for posts that have no embedding yet. 391 647 fn db_posts_without_embeddings(conn: &Connection) -> Result<Vec<(String, String)>> { 392 648 let mut stmt = conn.prepare( 393 - "SELECT p.uri, p.text 649 + "SELECT p.storage_key, p.text 394 650 FROM posts p 395 651 WHERE p.text IS NOT NULL 396 652 AND p.text != '' 397 - AND p.uri NOT IN (SELECT uri FROM posts_vec)", 653 + AND p.storage_key NOT IN (SELECT storage_key FROM posts_vec)", 398 654 )?; 399 655 400 656 let rows = stmt.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))?; 401 657 rows.collect::<rusqlite::Result<Vec<_>>>().map_err(AppError::from) 402 658 } 403 659 404 - /// Returns (uri, text) for ALL posts that have non-empty text. 660 + /// Returns (storage_key, text) for ALL posts that have non-empty text. 405 661 fn db_all_posts_with_text(conn: &Connection) -> Result<Vec<(String, String)>> { 406 - let mut stmt = conn.prepare("SELECT uri, text FROM posts WHERE text IS NOT NULL AND text != ''")?; 662 + let mut stmt = conn.prepare("SELECT storage_key, text FROM posts WHERE text IS NOT NULL AND text != ''")?; 407 663 408 664 let rows = stmt.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))?; 409 665 rows.collect::<rusqlite::Result<Vec<_>>>().map_err(AppError::from) 410 666 } 411 667 412 - fn db_upsert_embedding(conn: &Connection, uri: &str, embedding: &[f32]) -> Result<()> { 668 + fn db_upsert_embedding(conn: &Connection, storage_key: &str, embedding: &[f32]) -> Result<()> { 413 669 let bytes: Vec<u8> = embedding.iter().flat_map(|f| f.to_le_bytes()).collect(); 414 670 conn.execute( 415 - "INSERT OR REPLACE INTO posts_vec(uri, embedding) VALUES(?1, ?2)", 416 - params![uri, bytes.as_slice()], 671 + "INSERT OR REPLACE INTO posts_vec(storage_key, embedding) VALUES(?1, ?2)", 672 + params![storage_key, bytes.as_slice()], 417 673 )?; 418 674 Ok(()) 419 675 } ··· 423 679 return Ok(0); 424 680 } 425 681 426 - let mut model = TextEmbedding::try_new( 427 - TextInitOptions::new(EmbeddingModel::NomicEmbedTextV15) 428 - .with_cache_dir(models_dir) 429 - .with_show_download_progress(false), 430 - ) 431 - .map_err(|error| AppError::validation(format!("failed to init embedding model: {error}")))?; 682 + let mut model = build_embedding_model(models_dir)?; 432 683 433 684 let mut total = 0usize; 434 685 ··· 439 690 .map_err(|error| AppError::validation(format!("embedding error: {error}")))?; 440 691 441 692 let conn = state.auth_store.lock_connection()?; 442 - for ((uri, _), embedding) in chunk.iter().zip(embeddings.iter()) { 443 - db_upsert_embedding(&conn, uri, embedding)?; 693 + for ((storage_key, _), embedding) in chunk.iter().zip(embeddings.iter()) { 694 + db_upsert_embedding(&conn, storage_key, embedding)?; 444 695 } 445 696 total += chunk.len(); 446 697 } ··· 448 699 Ok(total) 449 700 } 450 701 702 + pub fn search_posts( 703 + query: String, mode: String, limit: u32, app: &AppHandle, state: &AppState, 704 + ) -> Result<Vec<PostResult>> { 705 + validate_query(&query)?; 706 + let limit = validate_limit(limit)?; 707 + let mode = validate_search_mode(&mode)?; 708 + let owner_did = active_session_did(state)?.ok_or_else(|| AppError::validation("no active account"))?; 709 + 710 + let embeddings_enabled = { 711 + let conn = state.auth_store.lock_connection()?; 712 + db_get_embeddings_enabled(&conn)? 713 + }; 714 + 715 + let query_embedding = match mode { 716 + SearchMode::Keyword => None, 717 + SearchMode::Semantic | SearchMode::Hybrid if embeddings_enabled => { 718 + let models_dir = resolve_models_dir(app)?; 719 + Some(embed_query_text(&query, models_dir)?) 720 + } 721 + SearchMode::Semantic => { 722 + return Err(AppError::validation( 723 + "semantic search is unavailable while embeddings are disabled", 724 + )); 725 + } 726 + SearchMode::Hybrid => None, 727 + }; 728 + 729 + let conn = state.auth_store.lock_connection()?; 730 + run_local_search( 731 + &conn, 732 + &owner_did, 733 + &query, 734 + mode, 735 + limit, 736 + embeddings_enabled, 737 + query_embedding.as_deref(), 738 + ) 739 + } 740 + 451 741 /// Embed all posts that do not yet have an embedding. Skipped when embeddings are disabled. 452 742 pub fn embed_pending_posts(app: &AppHandle, state: &AppState) -> Result<usize> { 453 743 let enabled = { ··· 493 783 db_set_embeddings_enabled(&conn, enabled) 494 784 } 495 785 786 + fn sync_due(active_did: Option<&str>, last_synced_did: Option<&str>, last_synced_at: Option<Instant>) -> bool { 787 + match active_did { 788 + None => false, 789 + Some(did) if Some(did) != last_synced_did => true, 790 + Some(_) => last_synced_at 791 + .map(|instant| instant.elapsed() >= SEARCH_SYNC_INTERVAL) 792 + .unwrap_or(true), 793 + } 794 + } 795 + 796 + /// Keeps the active account's local search index warm by syncing likes on login/account switch 797 + /// and then re-syncing every 15 minutes. Embeddings are refreshed for newly synced posts. 798 + pub fn spawn_search_sync_task(app: AppHandle) { 799 + tauri::async_runtime::spawn(async move { 800 + let mut last_synced_did: Option<String> = None; 801 + let mut last_synced_at: Option<Instant> = None; 802 + 803 + loop { 804 + let state = app.state::<AppState>(); 805 + let active_did = match active_session_did(&state) { 806 + Ok(value) => value, 807 + Err(error) => { 808 + log::warn!("search sync failed to read active session: {error}"); 809 + tokio::time::sleep(SEARCH_SYNC_CHECK_INTERVAL).await; 810 + continue; 811 + } 812 + }; 813 + 814 + if active_did.is_none() { 815 + last_synced_did = None; 816 + last_synced_at = None; 817 + tokio::time::sleep(SEARCH_SYNC_CHECK_INTERVAL).await; 818 + continue; 819 + } 820 + 821 + if sync_due(active_did.as_deref(), last_synced_did.as_deref(), last_synced_at) { 822 + let did = active_did.clone().unwrap_or_default(); 823 + match sync_posts(did.clone(), "like".to_owned(), &state).await { 824 + Ok(status) => { 825 + log::info!( 826 + "background search sync complete for {} likes: {} post(s)", 827 + did, 828 + status.post_count 829 + ); 830 + if let Err(error) = embed_pending_posts(&app, &state) { 831 + log::warn!("background embedding pass failed for {did}: {error}"); 832 + } 833 + last_synced_did = Some(did); 834 + last_synced_at = Some(Instant::now()); 835 + } 836 + Err(error) => { 837 + log::warn!("background search sync failed: {error}"); 838 + } 839 + } 840 + } 841 + 842 + tokio::time::sleep(SEARCH_SYNC_CHECK_INTERVAL).await; 843 + } 844 + }); 845 + } 846 + 496 847 #[cfg(test)] 497 848 mod tests { 498 849 use super::{ 499 - db_get_embeddings_enabled, db_load_sync_cursor, db_post_count, db_save_sync_state, db_set_embeddings_enabled, 500 - db_upsert_post, validate_query, validate_source, 850 + build_fts_match_query, db_get_embeddings_enabled, db_load_sync_cursor, db_post_count, db_save_sync_state, 851 + db_semantic_search, db_set_embeddings_enabled, db_sync_status, db_upsert_embedding, db_upsert_post, 852 + run_local_search, storage_key, sync_due, validate_limit, validate_query, validate_search_mode, validate_source, 853 + SearchMode, 501 854 }; 502 - use rusqlite::Connection; 855 + use rusqlite::{ffi::sqlite3_auto_extension, Connection}; 856 + use sqlite_vec::sqlite3_vec_init; 503 857 504 - /// Minimal schema for unit tests w/o FTS/vec tables. 505 858 fn test_db() -> Connection { 859 + unsafe { 860 + sqlite3_auto_extension(Some(std::mem::transmute(sqlite3_vec_init as *const ()))); 861 + } 862 + 506 863 let conn = Connection::open_in_memory().expect("in-memory db should open"); 507 864 conn.execute_batch( 508 865 "CREATE TABLE posts ( 509 - uri TEXT PRIMARY KEY, 866 + storage_key TEXT PRIMARY KEY, 867 + owner_did TEXT NOT NULL, 868 + uri TEXT NOT NULL, 510 869 cid TEXT NOT NULL, 511 870 author_did TEXT NOT NULL, 512 871 author_handle TEXT, ··· 514 873 created_at TEXT, 515 874 indexed_at TEXT DEFAULT CURRENT_TIMESTAMP, 516 875 json_record TEXT, 517 - source TEXT NOT NULL 876 + source TEXT NOT NULL, 877 + UNIQUE(owner_did, source, uri) 878 + ); 879 + CREATE VIRTUAL TABLE posts_fts USING fts5( 880 + text, 881 + content=posts, 882 + content_rowid=rowid 883 + ); 884 + CREATE VIRTUAL TABLE posts_vec USING vec0( 885 + storage_key TEXT PRIMARY KEY, 886 + embedding float[3] 518 887 ); 888 + CREATE TRIGGER posts_ai AFTER INSERT ON posts BEGIN 889 + INSERT INTO posts_fts(rowid, text) VALUES (new.rowid, new.text); 890 + END; 891 + CREATE TRIGGER posts_ad AFTER DELETE ON posts BEGIN 892 + INSERT INTO posts_fts(posts_fts, rowid, text) 893 + VALUES('delete', old.rowid, old.text); 894 + END; 895 + CREATE TRIGGER posts_au AFTER UPDATE ON posts BEGIN 896 + INSERT INTO posts_fts(posts_fts, rowid, text) 897 + VALUES('delete', old.rowid, old.text); 898 + INSERT INTO posts_fts(rowid, text) VALUES (new.rowid, new.text); 899 + END; 519 900 CREATE TABLE sync_state ( 520 901 did TEXT NOT NULL, 521 902 source TEXT NOT NULL, ··· 532 913 conn 533 914 } 534 915 535 - fn feed_item(uri: &str, cid: &str, did: &str, handle: &str, text: &str) -> serde_json::Value { 916 + fn feed_item(uri: &str, cid: &str, did: &str, handle: &str, text: &str, created_at: &str) -> serde_json::Value { 536 917 serde_json::json!({ 537 918 "post": { 538 919 "uri": uri, 539 920 "cid": cid, 540 921 "author": { "did": did, "handle": handle }, 541 - "record": { "$type": "app.bsky.feed.post", "text": text, "createdAt": "2024-01-01T00:00:00Z" } 922 + "record": { "$type": "app.bsky.feed.post", "text": text, "createdAt": created_at } 542 923 } 543 924 }) 544 925 } 545 926 927 + fn insert_post(conn: &Connection, owner_did: &str, uri: &str, source: &str, text: &str, created_at: &str) { 928 + let item = feed_item(uri, "cid", "did:plc:author", "author.test", text, created_at); 929 + db_upsert_post(conn, owner_did, &item, source).expect("post should insert"); 930 + } 931 + 932 + fn insert_embedding(conn: &Connection, owner_did: &str, source: &str, uri: &str, embedding: &[f32]) { 933 + let key = storage_key(owner_did, source, uri); 934 + db_upsert_embedding(conn, &key, embedding).expect("embedding should insert"); 935 + } 936 + 546 937 #[test] 547 938 fn empty_query_is_rejected() { 548 939 assert!(validate_query("").is_err()); ··· 559 950 } 560 951 561 952 #[test] 953 + fn zero_limit_is_rejected() { 954 + assert!(validate_limit(0).is_err()); 955 + } 956 + 957 + #[test] 958 + fn non_zero_limit_is_accepted() { 959 + assert_eq!(validate_limit(5).unwrap(), 5); 960 + } 961 + 962 + #[test] 562 963 fn single_char_query_is_accepted() { 563 964 assert!(validate_query("a").is_ok()); 564 965 } ··· 575 976 } 576 977 577 978 #[test] 979 + fn valid_search_modes_are_accepted() { 980 + assert_eq!(validate_search_mode("keyword").unwrap(), SearchMode::Keyword); 981 + assert_eq!(validate_search_mode("semantic").unwrap(), SearchMode::Semantic); 982 + assert_eq!(validate_search_mode("hybrid").unwrap(), SearchMode::Hybrid); 983 + } 984 + 985 + #[test] 986 + fn unknown_search_mode_is_rejected() { 987 + assert!(validate_search_mode("network").is_err()); 988 + } 989 + 990 + #[test] 578 991 fn unknown_source_is_rejected() { 579 992 assert!(validate_source("repost").is_err()); 580 993 assert!(validate_source("").is_err()); ··· 628 1041 } 629 1042 630 1043 #[test] 631 - fn upsert_inserts_new_post() { 1044 + fn build_fts_match_query_quotes_each_token() { 1045 + assert_eq!(build_fts_match_query("rust sqlite"), "\"rust\" AND \"sqlite\""); 1046 + } 1047 + 1048 + #[test] 1049 + fn upsert_inserts_new_post_for_owner_and_source() { 632 1050 let conn = test_db(); 633 - let item = feed_item( 1051 + insert_post( 1052 + &conn, 1053 + "did:plc:alice", 634 1054 "at://did:plc:a/app.bsky.feed.post/1", 635 - "cid1", 636 - "did:plc:a", 637 - "alice", 638 - "hello", 1055 + "like", 1056 + "hello world", 1057 + "2024-01-01T00:00:00Z", 639 1058 ); 640 - db_upsert_post(&conn, &item, "like").unwrap(); 641 - assert_eq!(db_post_count(&conn, "like").unwrap(), 1); 1059 + assert_eq!(db_post_count(&conn, "did:plc:alice", "like").unwrap(), 1); 642 1060 } 643 1061 644 1062 #[test] 645 - fn upsert_is_idempotent_for_same_uri() { 1063 + fn upsert_is_scoped_by_owner_did() { 646 1064 let conn = test_db(); 647 1065 let item = feed_item( 648 1066 "at://did:plc:a/app.bsky.feed.post/1", ··· 650 1068 "did:plc:a", 651 1069 "alice", 652 1070 "hello", 1071 + "2024-01-01T00:00:00Z", 653 1072 ); 654 - db_upsert_post(&conn, &item, "like").unwrap(); 655 - db_upsert_post(&conn, &item, "like").unwrap(); 656 - assert_eq!(db_post_count(&conn, "like").unwrap(), 1); 1073 + db_upsert_post(&conn, "did:plc:alice", &item, "like").unwrap(); 1074 + db_upsert_post(&conn, "did:plc:bob", &item, "like").unwrap(); 1075 + assert_eq!(db_post_count(&conn, "did:plc:alice", "like").unwrap(), 1); 1076 + assert_eq!(db_post_count(&conn, "did:plc:bob", "like").unwrap(), 1); 657 1077 } 658 1078 659 1079 #[test] ··· 665 1085 "did:plc:a", 666 1086 "alice", 667 1087 "original", 1088 + "2024-01-01T00:00:00Z", 668 1089 ); 669 - db_upsert_post(&conn, &original, "like").unwrap(); 1090 + db_upsert_post(&conn, "did:plc:alice", &original, "like").unwrap(); 670 1091 671 1092 let updated = feed_item( 672 1093 "at://did:plc:a/app.bsky.feed.post/1", ··· 674 1095 "did:plc:a", 675 1096 "alice", 676 1097 "updated", 1098 + "2024-01-02T00:00:00Z", 677 1099 ); 678 - db_upsert_post(&conn, &updated, "like").unwrap(); 1100 + db_upsert_post(&conn, "did:plc:alice", &updated, "like").unwrap(); 679 1101 680 1102 let text: String = conn 681 1103 .query_row( 682 - "SELECT text FROM posts WHERE uri = ?1", 683 - ["at://did:plc:a/app.bsky.feed.post/1"], 1104 + "SELECT text FROM posts WHERE storage_key = ?1", 1105 + [storage_key( 1106 + "did:plc:alice", 1107 + "like", 1108 + "at://did:plc:a/app.bsky.feed.post/1", 1109 + )], 684 1110 |r| r.get(0), 685 1111 ) 686 1112 .unwrap(); ··· 696 1122 "did:plc:a", 697 1123 "alice", 698 1124 "hi", 1125 + "2024-01-01T00:00:00Z", 699 1126 ); 700 - db_upsert_post(&conn, &item, "bookmark").unwrap(); 1127 + db_upsert_post(&conn, "did:plc:alice", &item, "bookmark").unwrap(); 701 1128 let source: String = conn 702 1129 .query_row( 703 - "SELECT source FROM posts WHERE uri = ?1", 704 - ["at://did:plc:a/app.bsky.feed.post/1"], 1130 + "SELECT source FROM posts WHERE storage_key = ?1", 1131 + [storage_key( 1132 + "did:plc:alice", 1133 + "bookmark", 1134 + "at://did:plc:a/app.bsky.feed.post/1", 1135 + )], 705 1136 |r| r.get(0), 706 1137 ) 707 1138 .unwrap(); ··· 712 1143 fn upsert_rejects_item_missing_uri() { 713 1144 let conn = test_db(); 714 1145 let bad = serde_json::json!({ "post": { "cid": "cid1", "author": { "did": "x" } } }); 715 - assert!(db_upsert_post(&conn, &bad, "like").is_err()); 1146 + assert!(db_upsert_post(&conn, "did:plc:alice", &bad, "like").is_err()); 716 1147 } 717 1148 718 1149 #[test] 719 - fn post_count_is_per_source() { 1150 + fn post_count_is_per_owner_and_source() { 720 1151 let conn = test_db(); 721 - db_upsert_post( 1152 + insert_post( 722 1153 &conn, 723 - &feed_item("at://a/app.bsky.feed.post/1", "c1", "did:plc:a", "a", "t"), 1154 + "did:plc:alice", 1155 + "at://a/app.bsky.feed.post/1", 724 1156 "like", 725 - ) 726 - .unwrap(); 727 - db_upsert_post( 1157 + "rust sqlite", 1158 + "2024-01-01T00:00:00Z", 1159 + ); 1160 + insert_post( 728 1161 &conn, 729 - &feed_item("at://a/app.bsky.feed.post/2", "c2", "did:plc:a", "a", "t"), 1162 + "did:plc:alice", 1163 + "at://a/app.bsky.feed.post/2", 730 1164 "bookmark", 731 - ) 732 - .unwrap(); 733 - assert_eq!(db_post_count(&conn, "like").unwrap(), 1); 734 - assert_eq!(db_post_count(&conn, "bookmark").unwrap(), 1); 1165 + "saved post", 1166 + "2024-01-02T00:00:00Z", 1167 + ); 1168 + insert_post( 1169 + &conn, 1170 + "did:plc:bob", 1171 + "at://a/app.bsky.feed.post/3", 1172 + "like", 1173 + "other account", 1174 + "2024-01-03T00:00:00Z", 1175 + ); 1176 + assert_eq!(db_post_count(&conn, "did:plc:alice", "like").unwrap(), 1); 1177 + assert_eq!(db_post_count(&conn, "did:plc:alice", "bookmark").unwrap(), 1); 1178 + assert_eq!(db_post_count(&conn, "did:plc:bob", "like").unwrap(), 1); 735 1179 } 736 1180 737 1181 #[test] ··· 766 1210 db_set_embeddings_enabled(&conn, false).unwrap(); 767 1211 db_set_embeddings_enabled(&conn, false).unwrap(); 768 1212 assert!(!db_get_embeddings_enabled(&conn).unwrap()); 1213 + } 1214 + 1215 + #[test] 1216 + fn keyword_search_returns_owner_scoped_matches() { 1217 + let conn = test_db(); 1218 + insert_post( 1219 + &conn, 1220 + "did:plc:alice", 1221 + "at://alice/app.bsky.feed.post/1", 1222 + "like", 1223 + "rust sqlite vectors", 1224 + "2024-01-01T00:00:00Z", 1225 + ); 1226 + insert_post( 1227 + &conn, 1228 + "did:plc:bob", 1229 + "at://bob/app.bsky.feed.post/1", 1230 + "like", 1231 + "rust sqlite vectors", 1232 + "2024-01-02T00:00:00Z", 1233 + ); 1234 + 1235 + let results = run_local_search( 1236 + &conn, 1237 + "did:plc:alice", 1238 + "rust sqlite", 1239 + SearchMode::Keyword, 1240 + 10, 1241 + true, 1242 + None, 1243 + ) 1244 + .expect("keyword search should succeed"); 1245 + 1246 + assert_eq!(results.len(), 1); 1247 + assert_eq!(results[0].uri, "at://alice/app.bsky.feed.post/1"); 1248 + } 1249 + 1250 + #[test] 1251 + fn semantic_search_returns_nearest_embeddings() { 1252 + let conn = test_db(); 1253 + insert_post( 1254 + &conn, 1255 + "did:plc:alice", 1256 + "at://alice/app.bsky.feed.post/1", 1257 + "like", 1258 + "rust vectors", 1259 + "2024-01-01T00:00:00Z", 1260 + ); 1261 + insert_post( 1262 + &conn, 1263 + "did:plc:alice", 1264 + "at://alice/app.bsky.feed.post/2", 1265 + "like", 1266 + "sql joins", 1267 + "2024-01-02T00:00:00Z", 1268 + ); 1269 + insert_embedding( 1270 + &conn, 1271 + "did:plc:alice", 1272 + "like", 1273 + "at://alice/app.bsky.feed.post/1", 1274 + &[1.0, 0.0, 0.0], 1275 + ); 1276 + insert_embedding( 1277 + &conn, 1278 + "did:plc:alice", 1279 + "like", 1280 + "at://alice/app.bsky.feed.post/2", 1281 + &[0.0, 1.0, 0.0], 1282 + ); 1283 + 1284 + let results = 1285 + db_semantic_search(&conn, "did:plc:alice", &[1.0, 0.0, 0.0], 10).expect("semantic search should succeed"); 1286 + 1287 + assert_eq!(results.len(), 2); 1288 + assert_eq!(results[0].post.uri, "at://alice/app.bsky.feed.post/1"); 1289 + assert!(results[0].post.score > results[1].post.score); 1290 + } 1291 + 1292 + #[test] 1293 + fn semantic_search_requires_embeddings_when_disabled() { 1294 + let conn = test_db(); 1295 + let error = run_local_search( 1296 + &conn, 1297 + "did:plc:alice", 1298 + "rust", 1299 + SearchMode::Semantic, 1300 + 10, 1301 + false, 1302 + Some(&[1.0, 0.0, 0.0]), 1303 + ) 1304 + .expect_err("semantic search should fail when embeddings are disabled"); 1305 + 1306 + assert!(error.to_string().contains("semantic search is unavailable")); 1307 + } 1308 + 1309 + #[test] 1310 + fn hybrid_search_falls_back_to_keyword_when_embeddings_are_disabled() { 1311 + let conn = test_db(); 1312 + insert_post( 1313 + &conn, 1314 + "did:plc:alice", 1315 + "at://alice/app.bsky.feed.post/1", 1316 + "like", 1317 + "rust sqlite", 1318 + "2024-01-01T00:00:00Z", 1319 + ); 1320 + 1321 + let results = run_local_search(&conn, "did:plc:alice", "rust", SearchMode::Hybrid, 10, false, None) 1322 + .expect("hybrid fallback should succeed"); 1323 + 1324 + assert_eq!(results.len(), 1); 1325 + assert_eq!(results[0].uri, "at://alice/app.bsky.feed.post/1"); 1326 + } 1327 + 1328 + #[test] 1329 + fn hybrid_search_merges_keyword_and_semantic_results() { 1330 + let conn = test_db(); 1331 + insert_post( 1332 + &conn, 1333 + "did:plc:alice", 1334 + "at://alice/app.bsky.feed.post/1", 1335 + "like", 1336 + "rust sqlite search", 1337 + "2024-01-01T00:00:00Z", 1338 + ); 1339 + insert_post( 1340 + &conn, 1341 + "did:plc:alice", 1342 + "at://alice/app.bsky.feed.post/2", 1343 + "like", 1344 + "semantic-only match", 1345 + "2024-01-02T00:00:00Z", 1346 + ); 1347 + insert_embedding( 1348 + &conn, 1349 + "did:plc:alice", 1350 + "like", 1351 + "at://alice/app.bsky.feed.post/1", 1352 + &[0.5, 0.5, 0.0], 1353 + ); 1354 + insert_embedding( 1355 + &conn, 1356 + "did:plc:alice", 1357 + "like", 1358 + "at://alice/app.bsky.feed.post/2", 1359 + &[1.0, 0.0, 0.0], 1360 + ); 1361 + 1362 + let results = run_local_search( 1363 + &conn, 1364 + "did:plc:alice", 1365 + "rust", 1366 + SearchMode::Hybrid, 1367 + 10, 1368 + true, 1369 + Some(&[1.0, 0.0, 0.0]), 1370 + ) 1371 + .expect("hybrid search should succeed"); 1372 + 1373 + let uris: Vec<&str> = results.iter().map(|result| result.uri.as_str()).collect(); 1374 + assert!(uris.contains(&"at://alice/app.bsky.feed.post/1")); 1375 + assert!(uris.contains(&"at://alice/app.bsky.feed.post/2")); 1376 + } 1377 + 1378 + #[test] 1379 + fn sync_status_returns_counts_for_both_sources_per_did() { 1380 + let conn = test_db(); 1381 + insert_post( 1382 + &conn, 1383 + "did:plc:alice", 1384 + "at://alice/app.bsky.feed.post/1", 1385 + "like", 1386 + "liked post", 1387 + "2024-01-01T00:00:00Z", 1388 + ); 1389 + insert_post( 1390 + &conn, 1391 + "did:plc:alice", 1392 + "at://alice/app.bsky.feed.post/2", 1393 + "bookmark", 1394 + "saved post", 1395 + "2024-01-02T00:00:00Z", 1396 + ); 1397 + insert_post( 1398 + &conn, 1399 + "did:plc:bob", 1400 + "at://bob/app.bsky.feed.post/3", 1401 + "like", 1402 + "bob post", 1403 + "2024-01-03T00:00:00Z", 1404 + ); 1405 + db_save_sync_state(&conn, "did:plc:alice", "like", Some("cursor-like")).unwrap(); 1406 + 1407 + let like_status = db_sync_status(&conn, "did:plc:alice", "like").unwrap(); 1408 + let bookmark_status = db_sync_status(&conn, "did:plc:alice", "bookmark").unwrap(); 1409 + 1410 + assert_eq!(like_status.post_count, 1); 1411 + assert_eq!(like_status.cursor.as_deref(), Some("cursor-like")); 1412 + assert_eq!(bookmark_status.post_count, 1); 1413 + assert!(bookmark_status.cursor.is_none()); 1414 + } 1415 + 1416 + #[test] 1417 + fn sync_due_is_true_for_new_active_account() { 1418 + assert!(sync_due(Some("did:plc:alice"), None, None)); 1419 + } 1420 + 1421 + #[test] 1422 + fn sync_due_is_false_when_recent_sync_exists() { 1423 + assert!(!sync_due( 1424 + Some("did:plc:alice"), 1425 + Some("did:plc:alice"), 1426 + Some(std::time::Instant::now()), 1427 + )); 769 1428 } 770 1429 }