Skip to main content

vector_core/db/
miniapps.rs

1//! Mini Apps database operations.
2//!
3//! This module handles:
4//! - Mini App history (recently opened apps)
5//! - Mini App favorites
6//! - Mini App permissions (per-app permission grants)
7//! - Marketplace app version tracking
8
9use serde::{Deserialize, Serialize};
10
11// ============================================================================
12// Mini Apps History Functions
13// ============================================================================
14
15/// Mini App history entry
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct MiniAppHistoryEntry {
18    pub id: i64,
19    pub name: String,
20    pub src_url: String,
21    pub attachment_ref: String,
22    pub open_count: i64,
23    pub last_opened_at: i64,
24    pub is_favorite: bool,
25    /// Comma-separated list of categories (e.g., "game" or "app")
26    pub categories: String,
27    /// Optional marketplace app ID for linking to marketplace
28    pub marketplace_id: Option<String>,
29    /// Installed version of the app (for marketplace apps)
30    pub installed_version: Option<String>,
31}
32
33/// Record a Mini App being opened (upsert - insert or update)
34/// If the same name+src_url combo exists, update the attachment_ref, increment count, and update timestamp
35pub fn record_miniapp_opened(
36    name: String,
37    src_url: String,
38    attachment_ref: String,
39) -> Result<(), String> {
40    record_miniapp_opened_with_metadata(name, src_url, attachment_ref, String::new(), None, None)
41}
42
43/// Record a Mini App being opened with additional metadata (categories, marketplace_id, and version)
44pub fn record_miniapp_opened_with_metadata(
45    name: String,
46    src_url: String,
47    attachment_ref: String,
48    categories: String,
49    marketplace_id: Option<String>,
50    installed_version: Option<String>,
51) -> Result<(), String> {
52    let conn = super::get_write_connection_guard_static()?;
53
54    let now = web_time::SystemTime::now()
55        .duration_since(web_time::UNIX_EPOCH)
56        .unwrap()
57        .as_secs() as i64;
58
59    // Use INSERT OR REPLACE with a subquery to preserve/increment open_count
60    // Uses UNIQUE(name) constraint - same app name always updates the existing entry
61    conn.execute(
62        r#"
63        INSERT INTO miniapps_history (name, src_url, attachment_ref, open_count, last_opened_at, categories, marketplace_id, installed_version)
64        VALUES (?1, ?2, ?3, 1, ?4, ?5, ?6, ?7)
65        ON CONFLICT(name) DO UPDATE SET
66            src_url = excluded.src_url,
67            attachment_ref = excluded.attachment_ref,
68            open_count = open_count + 1,
69            last_opened_at = excluded.last_opened_at,
70            categories = CASE WHEN excluded.categories != '' THEN excluded.categories ELSE categories END,
71            marketplace_id = CASE WHEN excluded.marketplace_id IS NOT NULL THEN excluded.marketplace_id ELSE marketplace_id END,
72            installed_version = CASE WHEN excluded.installed_version IS NOT NULL THEN excluded.installed_version ELSE installed_version END
73        "#,
74        rusqlite::params![name, src_url, attachment_ref, now, categories, marketplace_id, installed_version],
75    ).map_err(|e| format!("Failed to record Mini App opened: {}", e))?;
76
77
78    Ok(())
79}
80
81/// Get Mini Apps history sorted by last opened (most recent first)
82pub fn get_miniapps_history(
83    limit: Option<i64>,
84) -> Result<Vec<MiniAppHistoryEntry>, String> {
85    let conn = super::get_db_connection_guard_static()?;
86
87    let limit_val = limit.unwrap_or(50);
88
89    let mut stmt = conn.prepare(
90        r#"
91        SELECT id, name, src_url, attachment_ref, open_count, last_opened_at, is_favorite, categories, marketplace_id, installed_version
92        FROM miniapps_history
93        ORDER BY is_favorite DESC, last_opened_at DESC
94        LIMIT ?1
95        "#
96    ).map_err(|e| format!("Failed to prepare Mini Apps history query: {}", e))?;
97
98    let result: Vec<MiniAppHistoryEntry> = {
99        let entries = stmt.query_map(rusqlite::params![limit_val], |row| {
100            Ok(MiniAppHistoryEntry {
101                id: row.get(0)?,
102                name: row.get(1)?,
103                src_url: row.get(2)?,
104                attachment_ref: row.get(3)?,
105                open_count: row.get(4)?,
106                last_opened_at: row.get(5)?,
107                is_favorite: row.get::<_, i64>(6)? != 0,
108                categories: row.get(7)?,
109                marketplace_id: row.get(8)?,
110                installed_version: row.get(9)?,
111            })
112        }).map_err(|e| format!("Failed to query Mini Apps history: {}", e))?;
113
114        entries.filter_map(|e| e.ok()).collect()
115    };
116
117    Ok(result)
118}
119
120/// Toggle the favorite status of a Mini App by its ID
121pub fn toggle_miniapp_favorite(
122    id: i64,
123) -> Result<bool, String> {
124    let conn = super::get_write_connection_guard_static()?;
125
126    // Toggle the is_favorite value and return the new state
127    conn.execute(
128        "UPDATE miniapps_history SET is_favorite = NOT is_favorite WHERE id = ?1",
129        rusqlite::params![id],
130    ).map_err(|e| format!("Failed to toggle Mini App favorite: {}", e))?;
131
132    // Get the new favorite state
133    let new_state: bool = conn.query_row(
134        "SELECT is_favorite FROM miniapps_history WHERE id = ?1",
135        rusqlite::params![id],
136        |row| row.get::<_, i64>(0).map(|v| v != 0)
137    ).map_err(|e| format!("Failed to get Mini App favorite state: {}", e))?;
138
139
140    Ok(new_state)
141}
142
143/// Set the favorite status of a Mini App by its ID
144pub fn set_miniapp_favorite(
145    id: i64,
146    is_favorite: bool,
147) -> Result<(), String> {
148    let conn = super::get_write_connection_guard_static()?;
149
150    conn.execute(
151        "UPDATE miniapps_history SET is_favorite = ?1 WHERE id = ?2",
152        rusqlite::params![if is_favorite { 1 } else { 0 }, id],
153    ).map_err(|e| format!("Failed to set Mini App favorite: {}", e))?;
154
155
156    Ok(())
157}
158
159/// Remove a Mini App from history by name
160pub fn remove_miniapp_from_history(
161    name: &str,
162) -> Result<(), String> {
163    let conn = super::get_write_connection_guard_static()?;
164
165    conn.execute(
166        "DELETE FROM miniapps_history WHERE name = ?1",
167        rusqlite::params![name],
168    ).map_err(|e| format!("Failed to remove Mini App from history: {}", e))?;
169
170
171    Ok(())
172}
173
174/// Remove history entries whose package file no longer exists on disk (e.g.
175/// after a storage clear deleted the .xdc). A dead ref renders a broken tile
176/// that can only ever open to a file-not-found error.
177#[cfg(not(target_arch = "wasm32"))]
178pub fn prune_dangling_miniapp_history() -> Result<u32, String> {
179    let conn = super::get_write_connection_guard_static()?;
180
181    let rows: Vec<(i64, Option<String>)> = {
182        let mut stmt = conn.prepare(
183            "SELECT id, attachment_ref FROM miniapps_history"
184        ).map_err(|e| format!("Failed to query Mini Apps history: {}", e))?;
185        let mapped = stmt.query_map([], |row| {
186            Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?))
187        }).map_err(|e| format!("Failed to iterate Mini Apps history: {}", e))?;
188        mapped.filter_map(|r| r.ok()).collect()
189    };
190
191    let mut removed = 0u32;
192    for (id, path) in rows {
193        // attachment_ref is the local package path; entries without one can't be validated
194        let Some(path) = path else { continue };
195        if path.is_empty() {
196            continue;
197        }
198        // Fail closed: only prune on a definite "missing". A stat error
199        // (permissions, unmounted Android storage) must not delete valid rows
200        if !matches!(std::path::Path::new(&path).try_exists(), Ok(false)) {
201            continue;
202        }
203        conn.execute(
204            "DELETE FROM miniapps_history WHERE id = ?1",
205            rusqlite::params![id],
206        ).map_err(|e| format!("Failed to prune Mini App history entry: {}", e))?;
207        removed += 1;
208    }
209
210    Ok(removed)
211}
212
213/// OPFS can't be probed synchronously, so the web never prunes.
214#[cfg(target_arch = "wasm32")]
215pub fn prune_dangling_miniapp_history() -> Result<u32, String> {
216    Ok(0)
217}
218
219/// Update the installed version for a marketplace app
220pub fn update_miniapp_version(
221    marketplace_id: &str,
222    version: &str,
223) -> Result<(), String> {
224    let conn = super::get_write_connection_guard_static()?;
225
226    conn.execute(
227        "UPDATE miniapps_history SET installed_version = ?1 WHERE marketplace_id = ?2",
228        rusqlite::params![version, marketplace_id],
229    ).map_err(|e| format!("Failed to update Mini App version: {}", e))?;
230
231
232    Ok(())
233}
234
235/// Get the installed version for a marketplace app by its marketplace_id
236pub fn get_miniapp_installed_version(
237    marketplace_id: &str,
238) -> Result<Option<String>, String> {
239    let conn = super::get_db_connection_guard_static()?;
240
241    let result = conn.query_row(
242        "SELECT installed_version FROM miniapps_history WHERE marketplace_id = ?1",
243        rusqlite::params![marketplace_id],
244        |row| row.get::<_, Option<String>>(0)
245    );
246
247
248
249    match result {
250        Ok(version) => Ok(version),
251        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
252        Err(e) => Err(format!("Failed to get Mini App installed version: {}", e)),
253    }
254}
255
256/// Backfill marketplace_id + installed_version for history entries that predate tracking.
257/// Matches the blossom hash in src_url filenames against the marketplace cache.
258/// Only hash-based matching is used — name matching is intentionally avoided to prevent
259/// phishing when public publishing is enabled.
260pub fn backfill_marketplace_ids(apps: &[crate::webxdc::MarketplaceApp]) -> Result<u32, String> {
261    let conn = super::get_write_connection_guard_static()?;
262
263    // Get history entries missing marketplace_id
264    let mut stmt = conn.prepare(
265        "SELECT id, src_url FROM miniapps_history WHERE marketplace_id IS NULL"
266    ).map_err(|e| format!("Failed to query orphan history: {}", e))?;
267
268    let orphans: Vec<(i64, String)> = stmt.query_map([], |row| {
269        Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
270    }).map_err(|e| format!("Failed to iterate orphans: {}", e))?
271      .filter_map(|r| r.ok())
272      .collect();
273
274    let mut updated = 0u32;
275    for (id, src_url) in &orphans {
276        // Extract hash from filename (e.g. ".../c7069f82...6fae.xdc" → "c7069f82...6fae")
277        let hash = src_url
278            .rsplit('/')
279            .next()
280            .and_then(|f| f.strip_suffix(".xdc"));
281        let hash = match hash {
282            Some(h) => h,
283            None => continue,
284        };
285
286        // Exact hash match → user has this exact file from the marketplace
287        if let Some(app) = apps.iter().find(|a| a.blossom_hash == hash) {
288            conn.execute(
289                "UPDATE miniapps_history SET marketplace_id = ?1, installed_version = ?2 WHERE id = ?3",
290                rusqlite::params![app.id, app.version, id],
291            ).map_err(|e| format!("Failed to backfill marketplace_id: {}", e))?;
292            updated += 1;
293        }
294    }
295
296    Ok(updated)
297}
298
299// ============================================================================
300// Mini App Permissions Functions
301// ============================================================================
302
303/// Get all granted permissions for a Mini App by its file hash
304/// Returns a comma-separated string of granted permission names
305pub fn get_miniapp_granted_permissions(
306    file_hash: &str,
307) -> Result<String, String> {
308    let conn = super::get_db_connection_guard_static()?;
309
310    let mut stmt = conn.prepare(
311        "SELECT permission FROM miniapp_permissions WHERE file_hash = ?1 AND granted = 1"
312    ).map_err(|e| format!("Failed to prepare permission query: {}", e))?;
313
314    let permissions: Vec<String> = stmt.query_map(rusqlite::params![file_hash], |row| {
315        row.get::<_, String>(0)
316    })
317    .map_err(|e| format!("Failed to query permissions: {}", e))?
318    .filter_map(|r| r.ok())
319    .collect();
320
321    Ok(permissions.join(","))
322}
323
324/// Set the granted status of a permission for a Mini App by its file hash
325pub fn set_miniapp_permission(
326    file_hash: &str,
327    permission: &str,
328    granted: bool,
329) -> Result<(), String> {
330    let conn = super::get_write_connection_guard_static()?;
331
332    let now = web_time::SystemTime::now()
333        .duration_since(web_time::UNIX_EPOCH)
334        .unwrap()
335        .as_secs() as i64;
336
337    conn.execute(
338        r#"
339        INSERT INTO miniapp_permissions (file_hash, permission, granted, granted_at)
340        VALUES (?1, ?2, ?3, ?4)
341        ON CONFLICT(file_hash, permission) DO UPDATE SET
342            granted = excluded.granted,
343            granted_at = CASE WHEN excluded.granted = 1 THEN excluded.granted_at ELSE granted_at END
344        "#,
345        rusqlite::params![file_hash, permission, granted as i32, if granted { Some(now) } else { None::<i64> }],
346    ).map_err(|e| format!("Failed to set Mini App permission: {}", e))?;
347
348
349    Ok(())
350}
351
352/// Set multiple permissions at once for a Mini App by its file hash
353/// Uses a transaction to ensure all permissions are set atomically
354pub fn set_miniapp_permissions(
355    file_hash: &str,
356    permissions: &[(&str, bool)],
357) -> Result<(), String> {
358    let mut conn = super::get_write_connection_guard_static()?;
359
360    let now = web_time::SystemTime::now()
361        .duration_since(web_time::UNIX_EPOCH)
362        .unwrap()
363        .as_secs() as i64;
364
365    let tx = conn.transaction()
366        .map_err(|e| format!("Failed to start transaction: {}", e))?;
367
368    for (permission, granted) in permissions {
369        tx.execute(
370            r#"
371            INSERT INTO miniapp_permissions (file_hash, permission, granted, granted_at)
372            VALUES (?1, ?2, ?3, ?4)
373            ON CONFLICT(file_hash, permission) DO UPDATE SET
374                granted = excluded.granted,
375                granted_at = CASE WHEN excluded.granted = 1 THEN excluded.granted_at ELSE granted_at END
376            "#,
377            rusqlite::params![file_hash, permission, *granted as i32, if *granted { Some(now) } else { None::<i64> }],
378        ).map_err(|e| format!("Failed to set Mini App permission: {}", e))?;
379    }
380
381    tx.commit()
382        .map_err(|e| format!("Failed to commit permission changes: {}", e))?;
383
384    Ok(())
385}
386
387/// Check if an app has been prompted for permissions yet (by file hash)
388/// Returns true if any permission record exists for this hash
389pub fn has_miniapp_permission_prompt(
390    file_hash: &str,
391) -> Result<bool, String> {
392    let conn = super::get_db_connection_guard_static()?;
393
394    let exists: bool = conn.query_row(
395        "SELECT EXISTS(SELECT 1 FROM miniapp_permissions WHERE file_hash = ?1)",
396        rusqlite::params![file_hash],
397        |row| row.get(0)
398    ).map_err(|e| format!("Failed to check permission prompt: {}", e))?;
399
400
401    Ok(exists)
402}
403
404/// Revoke all permissions for a Mini App by its file hash
405pub fn revoke_all_miniapp_permissions(
406    file_hash: &str,
407) -> Result<(), String> {
408    let conn = super::get_write_connection_guard_static()?;
409
410    conn.execute(
411        "DELETE FROM miniapp_permissions WHERE file_hash = ?1",
412        rusqlite::params![file_hash],
413    ).map_err(|e| format!("Failed to revoke Mini App permissions: {}", e))?;
414
415
416    Ok(())
417}
418
419// ============================================================================
420// Marketplace Cache Functions
421// ============================================================================
422
423/// Save marketplace apps to the SQLite cache (full replace).
424/// Upserts all provided apps and deletes any IDs not in the new set.
425pub fn save_marketplace_cache(apps: &[crate::webxdc::MarketplaceApp]) -> Result<(), String> {
426    let mut conn = super::get_write_connection_guard_static()?;
427
428    let now = web_time::SystemTime::now()
429        .duration_since(web_time::UNIX_EPOCH)
430        .unwrap()
431        .as_secs() as i64;
432
433    let tx = conn.transaction()
434        .map_err(|e| format!("Failed to start marketplace cache transaction: {}", e))?;
435
436    // Upsert each app
437    for app in apps {
438        let json = serde_json::to_string(app)
439            .map_err(|e| format!("Failed to serialize marketplace app {}: {}", app.id, e))?;
440        tx.execute(
441            "INSERT OR REPLACE INTO marketplace_cache (id, data, fetched_at) VALUES (?1, ?2, ?3)",
442            rusqlite::params![app.id, json, now],
443        ).map_err(|e| format!("Failed to upsert marketplace app {}: {}", app.id, e))?;
444    }
445
446    // Delete any cached apps that are no longer in the fetched set
447    if !apps.is_empty() {
448        let placeholders: Vec<String> = (1..=apps.len()).map(|i| format!("?{}", i)).collect();
449        let sql = format!(
450            "DELETE FROM marketplace_cache WHERE id NOT IN ({})",
451            placeholders.join(",")
452        );
453        let ids: Vec<&str> = apps.iter().map(|a| a.id.as_str()).collect();
454        let params: Vec<&dyn rusqlite::types::ToSql> = ids.iter().map(|s| s as &dyn rusqlite::types::ToSql).collect();
455        tx.execute(&sql, params.as_slice())
456            .map_err(|e| format!("Failed to clean stale marketplace cache entries: {}", e))?;
457    }
458
459    tx.commit()
460        .map_err(|e| format!("Failed to commit marketplace cache: {}", e))?;
461
462    Ok(())
463}
464
465/// Load all marketplace apps from the SQLite cache.
466pub fn load_marketplace_cache() -> Result<Vec<crate::webxdc::MarketplaceApp>, String> {
467    let conn = super::get_db_connection_guard_static()?;
468
469    let mut stmt = conn.prepare(
470        "SELECT data FROM marketplace_cache"
471    ).map_err(|e| format!("Failed to prepare marketplace cache query: {}", e))?;
472
473    let apps: Vec<crate::webxdc::MarketplaceApp> = stmt.query_map([], |row| {
474        let json: String = row.get(0)?;
475        serde_json::from_str(&json).map_err(|e| {
476            rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
477        })
478    })
479    .map_err(|e| format!("Failed to query marketplace cache: {}", e))?
480    .filter_map(|r| r.ok())
481    .collect();
482
483    Ok(apps)
484}
485
486/// Copy all permissions from one file hash to another (for app updates)
487pub fn copy_miniapp_permissions(
488    old_hash: &str,
489    new_hash: &str,
490) -> Result<(), String> {
491    let conn = super::get_write_connection_guard_static()?;
492
493    // Copy all permission records from old hash to new hash
494    conn.execute(
495        r#"
496        INSERT OR REPLACE INTO miniapp_permissions (file_hash, permission, granted, granted_at)
497        SELECT ?2, permission, granted, granted_at
498        FROM miniapp_permissions
499        WHERE file_hash = ?1
500        "#,
501        rusqlite::params![old_hash, new_hash],
502    ).map_err(|e| format!("Failed to copy Mini App permissions: {}", e))?;
503
504
505    Ok(())
506}
507
508// ============================================================================
509// Peer Advertisement Queries (for realtime channel discovery)
510// ============================================================================
511
512/// A persisted peer advertisement record from SQLite.
513pub struct PeerAdvertisementRecord {
514    pub npub: String,
515    pub node_addr_encoded: String,
516}
517
518/// Get active peer advertisements for a topic.
519///
520/// Returns advertisements where the sender's most recent event for this topic
521/// is a "peer-advertisement" (not invalidated by a subsequent "peer-left").
522/// Excludes our own npub. Uses `reference_id` = topic_encoded (indexed).
523pub fn get_active_peer_advertisements(
524    topic_encoded: &str,
525    my_npub: &str,
526) -> Result<Vec<PeerAdvertisementRecord>, String> {
527    let conn = super::get_db_connection_guard_static()?;
528
529    // Latest ad per npub, dropped when a peer-left exists at the SAME OR LATER second —
530    // timestamps are second-granular, so an open-then-instant-close lands ad and left in
531    // the same second and the tombstone must win the tie (else a phantom "active" player
532    // survives for the hours-later rediscovery this table exists for).
533    let mut stmt = conn.prepare(
534        r#"
535        SELECT e.npub, e.tags
536        FROM events e
537        INNER JOIN (
538            SELECT npub, MAX(created_at) as max_ts
539            FROM events
540            WHERE kind = 30078
541              AND reference_id = ?1
542              AND content = 'peer-advertisement'
543              AND npub IS NOT NULL
544              AND npub != ?2
545            GROUP BY npub
546        ) latest ON e.npub = latest.npub AND e.created_at = latest.max_ts
547        WHERE e.kind = 30078
548          AND e.reference_id = ?1
549          AND e.content = 'peer-advertisement'
550          AND NOT EXISTS (
551              SELECT 1 FROM events l
552              WHERE l.kind = 30078
553                AND l.reference_id = ?1
554                AND l.npub = e.npub
555                AND l.content = 'peer-left'
556                AND l.created_at >= e.created_at
557          )
558        "#
559    ).map_err(|e| format!("Failed to prepare peer advertisement query: {}", e))?;
560
561    let records = stmt.query_map(
562        rusqlite::params![topic_encoded, my_npub],
563        |row| {
564            let npub: String = row.get(0)?;
565            let tags_json: String = row.get(1)?;
566
567            // Extract node_addr from tags JSON
568            let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
569            let node_addr = tags.iter()
570                .find(|t| t.first().map(|s| s.as_str()) == Some("webxdc-node-addr"))
571                .and_then(|t| t.get(1))
572                .cloned()
573                .unwrap_or_default();
574
575            Ok(PeerAdvertisementRecord {
576                npub,
577                node_addr_encoded: node_addr,
578            })
579        }
580    ).map_err(|e| format!("Failed to query peer advertisements: {}", e))?;
581
582    Ok(records.filter_map(|r| r.ok()).filter(|r| !r.node_addr_encoded.is_empty()).collect())
583}
584
585/// [`get_active_peer_advertisements`] limited to one chat, newest first, at most
586/// `limit`. A topic is one chat's session; an advertisement for it that arrived
587/// through any other chat (a stranger's DM, a channel whose ban dropped the live
588/// signal) names nobody who is playing here.
589pub fn get_active_peer_advertisements_in(
590    topic_encoded: &str,
591    chat_identifier: &str,
592    my_npub: &str,
593    limit: usize,
594) -> Result<Vec<PeerAdvertisementRecord>, String> {
595    let Ok(chat) = super::id_cache::get_chat_id_by_identifier(chat_identifier) else {
596        return Ok(Vec::new());
597    };
598    let conn = super::get_db_connection_guard_static()?;
599    let mut stmt = conn.prepare(
600        r#"
601        SELECT e.npub, e.tags
602        FROM events e
603        INNER JOIN (
604            SELECT npub, MAX(created_at) as max_ts
605            FROM events
606            WHERE kind = 30078 AND reference_id = ?1 AND chat_id = ?3
607              AND content = 'peer-advertisement' AND npub IS NOT NULL AND npub != ?2
608            GROUP BY npub
609        ) latest ON e.npub = latest.npub AND e.created_at = latest.max_ts
610        WHERE e.kind = 30078 AND e.reference_id = ?1 AND e.chat_id = ?3
611          AND e.content = 'peer-advertisement'
612          AND NOT EXISTS (
613              SELECT 1 FROM events l
614              WHERE l.kind = 30078 AND l.reference_id = ?1 AND l.chat_id = ?3
615                AND l.npub = e.npub AND l.content = 'peer-left' AND l.created_at >= e.created_at
616          )
617        ORDER BY e.created_at DESC
618        LIMIT ?4
619        "#,
620    ).map_err(|e| format!("Failed to prepare peer advertisement query: {}", e))?;
621    let records = stmt.query_map(rusqlite::params![topic_encoded, my_npub, chat, limit as i64], |row| {
622        let npub: String = row.get(0)?;
623        let tags: Vec<Vec<String>> = serde_json::from_str(&row.get::<_, String>(1)?).unwrap_or_default();
624        let node_addr_encoded = tags
625            .iter()
626            .find(|t| t.first().map(|s| s.as_str()) == Some("webxdc-node-addr"))
627            .and_then(|t| t.get(1))
628            .cloned()
629            .unwrap_or_default();
630        Ok(PeerAdvertisementRecord { npub, node_addr_encoded })
631    }).map_err(|e| format!("Failed to query peer advertisements: {}", e))?;
632    Ok(records.filter_map(|r| r.ok()).filter(|r| !r.node_addr_encoded.is_empty()).collect())
633}
634
635/// Is this peer signal still the CURRENT word on `npub`'s participation in `topic`?
636/// Gates the LIVE side of signal handling (session-peer add/remove, Iroh feed, lobby
637/// emit) so a replayed or out-of-order historical event can't resurrect a departed
638/// player (ad after left) or evict an active one (stale left after a newer ad).
639/// Tie-break mirrors `get_active_peer_advertisements`: on equal seconds the tombstone wins.
640pub fn peer_signal_is_current(
641    topic_encoded: &str,
642    npub: &str,
643    created_at: u64,
644    is_advertisement: bool,
645) -> Result<bool, String> {
646    let conn = super::get_db_connection_guard_static()?;
647    // An AD is superseded by ANY strictly-newer signal or a same-second left;
648    // a LEFT only by a strictly-newer ad.
649    let sql = if is_advertisement {
650        r#"SELECT EXISTS(
651            SELECT 1 FROM events
652            WHERE kind = 30078 AND reference_id = ?1 AND npub = ?2
653              AND ((content IN ('peer-advertisement','peer-left') AND created_at > ?3)
654                   OR (content = 'peer-left' AND created_at = ?3))
655        )"#
656    } else {
657        r#"SELECT EXISTS(
658            SELECT 1 FROM events
659            WHERE kind = 30078 AND reference_id = ?1 AND npub = ?2
660              AND content = 'peer-advertisement' AND created_at > ?3
661        )"#
662    };
663    let superseded: bool = conn
664        .query_row(sql, rusqlite::params![topic_encoded, npub, created_at as i64], |row| row.get(0))
665        .map_err(|e| format!("Failed to check peer signal currency: {}", e))?;
666    Ok(!superseded)
667}