Skip to main content

vector_core/db/
events.rs

1//! Event storage — save_event for the flat event architecture.
2
3use crate::stored_event::{StoredEvent, event_kind};
4use rusqlite::OptionalExtension;
5use crate::crypto::maybe_encrypt;
6use crate::types::{Message, Attachment, Reaction};
7
8/// Save a StoredEvent to the events table.
9///
10/// Primary storage function for the flat event architecture.
11/// Conditionally encrypts message/edit content based on user setting.
12/// Uses INSERT OR REPLACE with COALESCE to preserve existing wrapper_event_id.
13/// Conditionally encrypt an event's content per kind (messages/edits are encrypted at rest). Async,
14/// so callers run it BEFORE opening a sync transaction.
15async fn encrypt_event_content(event: &StoredEvent) -> String {
16    if event.kind == event_kind::CHAT_MESSAGE
17        || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE
18        || event.kind == event_kind::MESSAGE_EDIT
19    {
20        maybe_encrypt(event.content.clone()).await
21    } else {
22        event.content.clone()
23    }
24}
25
26/// Upsert the event row onto the given connection or transaction (so it can commit atomically with
27/// its attachment rows). `content` must already be encrypted (see `encrypt_event_content`).
28///
29/// UPSERT (not INSERT OR REPLACE) so a re-save (reaction/edit) UPDATES in place and PRESERVES the
30/// rowid. get_messages_around's (created_at, received_at, rowid) cursor needs a stable final
31/// tiebreak to page through same-timestamp bursts; INSERT OR REPLACE churns the rowid and drops rows.
32fn insert_event_row(conn: &rusqlite::Connection, event: &StoredEvent, content: &str, tags_json: &str) -> Result<(), String> {
33    // prepare_cached: this is the hottest write statement in the app — the cache lives on the
34    // connection, so bulk-sync batches and every realtime save skip the SQL re-parse.
35    let mut stmt = conn.prepare_cached(
36        r#"
37        INSERT INTO events (
38            id, kind, chat_id, user_id, content, tags, reference_id,
39            created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata
40        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
41        ON CONFLICT(id) DO UPDATE SET
42            kind = excluded.kind, chat_id = excluded.chat_id, user_id = excluded.user_id,
43            content = excluded.content, tags = excluded.tags, reference_id = excluded.reference_id,
44            created_at = excluded.created_at, received_at = excluded.received_at,
45            mine = excluded.mine, pending = excluded.pending, failed = excluded.failed,
46            wrapper_event_id = COALESCE(excluded.wrapper_event_id, events.wrapper_event_id),
47            npub = excluded.npub, preview_metadata = excluded.preview_metadata
48        "#,
49    ).map_err(|e| format!("prepare save event: {}", e))?;
50    stmt.execute(
51        rusqlite::params![
52            event.id, event.kind as i32, event.chat_id, event.user_id, content, tags_json,
53            event.reference_id, event.created_at as i64, event.received_at as i64,
54            event.mine as i32, event.pending as i32, event.failed as i32,
55            event.wrapper_event_id, event.npub, event.preview_metadata,
56        ],
57    ).map_err(|e| format!("Failed to save event: {}", e))?;
58    Ok(())
59}
60
61pub async fn save_event(event: &StoredEvent) -> Result<(), String> {
62    let tags_json = serde_json::to_string(&event.tags).unwrap_or_else(|_| "[]".to_string());
63    let content = encrypt_event_content(event).await;
64    let conn = super::get_write_connection_guard_static()?;
65    insert_event_row(&conn, event, &content, &tags_json)
66}
67
68/// Extract persisted `["bot", npub]` routing tags (the write side lives in
69/// `message_to_stored_event`).
70fn extract_bot_tags(tags: &[Vec<String>]) -> Vec<String> {
71    tags.iter()
72        .filter(|t| t.len() >= 2 && t[0] == "bot")
73        .map(|t| t[1].clone())
74        .collect()
75}
76
77/// Parse the NIP-40 `["expiration", <unix secs>]` tag, if present. Drives the
78/// self-destruct countdown + purge for messages rehydrated from the DB.
79fn extract_expiration_tag(tags: &[Vec<String>]) -> Option<u64> {
80    tags.iter()
81        .find(|t| t.len() >= 2 && t[0] == "expiration")
82        .and_then(|t| t[1].parse::<u64>().ok())
83}
84
85/// Check if an event exists in the database.
86pub fn event_exists(event_id: &str) -> Result<bool, String> {
87    let conn = super::get_db_connection_guard_static()?;
88    event_exists_on(&conn, event_id)
89}
90
91/// `event_exists` against a caller-held connection or transaction — an in-transaction check
92/// sees the batch's own uncommitted rows, which the pooled read connection cannot.
93fn event_exists_on(conn: &rusqlite::Connection, event_id: &str) -> Result<bool, String> {
94    let mut stmt = conn.prepare_cached("SELECT EXISTS(SELECT 1 FROM events WHERE id = ?1)")
95        .map_err(|e| format!("prepare event existence: {}", e))?;
96    stmt.query_row(rusqlite::params![event_id], |row| row.get(0))
97        .map_err(|e| format!("Failed to check event existence: {}", e))
98}
99
100/// Build the kind=7 StoredEvent for a reaction (shared by the single-save and batch paths).
101fn reaction_to_stored_event(
102    reaction: &Reaction,
103    chat_id: i64,
104    user_id: Option<i64>,
105    mine: bool,
106    wrapper_event_id: Option<String>,
107) -> StoredEvent {
108    // Persist the NIP-30 emoji tag alongside the `e` reference so the
109    // image URL survives reload — pure `arrEmojiPacks` lookup would
110    // fail when the user hasn't yet opened the picker or unsubscribed.
111    let mut tags: Vec<Vec<String>> = vec![
112        vec!["e".to_string(), reaction.reference_id.clone()],
113    ];
114    if let Some(url) = &reaction.emoji_url {
115        if reaction.emoji.starts_with(':') && reaction.emoji.ends_with(':') && reaction.emoji.len() >= 3 {
116            let shortcode = &reaction.emoji[1..reaction.emoji.len() - 1];
117            if !shortcode.is_empty() && !url.is_empty() {
118                tags.push(vec!["emoji".to_string(), shortcode.to_string(), url.clone()]);
119            }
120        }
121    }
122    StoredEvent {
123        id: reaction.id.clone(),
124        kind: event_kind::REACTION,
125        chat_id,
126        user_id,
127        content: reaction.emoji.clone(),
128        tags,
129        reference_id: Some(reaction.reference_id.clone()),
130        created_at: std::time::SystemTime::now()
131            .duration_since(std::time::UNIX_EPOCH)
132            .map(|d| d.as_secs()).unwrap_or(0),
133        received_at: std::time::SystemTime::now()
134            .duration_since(std::time::UNIX_EPOCH)
135            .map(|d| d.as_millis() as u64).unwrap_or(0),
136        mine,
137        pending: false,
138        failed: false,
139        wrapper_event_id,
140        npub: Some(reaction.author_id.clone()),
141        preview_metadata: None,
142    }
143}
144
145/// Save a reaction as a kind=7 event referencing the message.
146pub async fn save_reaction_event(
147    reaction: &Reaction,
148    chat_id: i64,
149    user_id: Option<i64>,
150    mine: bool,
151    wrapper_event_id: Option<String>,
152) -> Result<(), String> {
153    let event = reaction_to_stored_event(reaction, chat_id, user_id, mine, wrapper_event_id);
154    save_event(&event).await
155}
156
157// ============================================================================
158// save_message — Message → StoredEvent → DB
159// ============================================================================
160
161/// Save a single message to the database.
162///
163/// Converts Message to StoredEvent and saves via the flat event architecture.
164/// Also saves reactions as separate kind=7 events.
165pub async fn save_message(chat_id: &str, message: &Message) -> Result<(), String> {
166    let chat_int_id = super::id_cache::get_or_create_chat_id(chat_id)?;
167
168    let user_int_id = if let Some(ref npub_str) = message.npub {
169        super::id_cache::get_or_create_user_id(npub_str)?
170    } else {
171        None
172    };
173
174    let event = message_to_stored_event(message, chat_int_id, user_int_id);
175
176    // Commit the event row and its attachment rows (the dedicated table is the sole source of truth;
177    // pre-migration events keep their legacy tag as an un-read fallback) in ONE transaction, so a
178    // file message can never persist without its attachments — new events have no tag to fall back
179    // on. Encrypt first: encryption is async and can't run inside the sync transaction.
180    let tags_json = serde_json::to_string(&event.tags).unwrap_or_else(|_| "[]".to_string());
181    let content = encrypt_event_content(&event).await;
182    {
183        let conn = super::get_write_connection_guard_static()?;
184        let tx = conn.unchecked_transaction().map_err(|e| format!("save_message tx: {e}"))?;
185        insert_event_row(&tx, &event, &content, &tags_json)?;
186        super::attachments::insert_attachment_rows(&tx, &message.id, &message.attachments)?;
187        tx.commit().map_err(|e| format!("save_message commit: {e}"))?;
188    }
189
190    // Save reactions as separate kind=7 events
191    for reaction in &message.reactions {
192        if !event_exists(&reaction.id)? {
193            let user_id = super::id_cache::get_or_create_user_id(&reaction.author_id)?;
194            let is_mine = super::get_current_account()
195                .map(|npub| reaction.author_id == npub)
196                .unwrap_or(false);
197            save_reaction_event(reaction, chat_int_id, user_id, is_mine, None).await?;
198        }
199    }
200
201    Ok(())
202}
203
204/// One fully-prepared batch row: the message, its encrypted event row, its prepared
205/// reaction rows, and (DM stream only) the gift-wrap ledger entry that must commit in the
206/// SAME transaction — everything phase 2 needs with zero async work and zero id_cache calls.
207struct BatchRow<'a> {
208    message: &'a Message,
209    event: StoredEvent,
210    content: String,
211    tags_json: String,
212    reactions: Vec<(StoredEvent, String)>,
213    /// `(wrapper_id_bytes, wrapper_created_at)` — written to `processed_wrappers` only
214    /// AFTER this row lands. The ledger is the negentropy fingerprint set: a wrapper
215    /// ledgered before its row commits marks the message "have" forever if the row is lost.
216    wrapper: Option<([u8; 32], u64)>,
217}
218
219/// Phase 1 of a batched save (async): resolve ids, build StoredEvents, encrypt contents.
220/// ALL id_cache lookups happen here — get_or_create can write a fresh chat/user row, so it
221/// must never run while phase 2 holds the write-connection guard.
222async fn prepare_batch_rows<'a>(
223    chat_id: &str,
224    messages: &[(&'a Message, Option<([u8; 32], u64)>)],
225    rows: &mut Vec<BatchRow<'a>>,
226) -> Result<(), String> {
227    let chat_int_id = super::id_cache::get_or_create_chat_id(chat_id)?;
228    let my_npub = super::get_current_account();
229    for (message, wrapper) in messages {
230        let user_int_id = match &message.npub {
231            Some(npub_str) => super::id_cache::get_or_create_user_id(npub_str)?,
232            None => None,
233        };
234        let event = message_to_stored_event(message, chat_int_id, user_int_id);
235        let tags_json = serde_json::to_string(&event.tags).unwrap_or_else(|_| "[]".to_string());
236        let content = encrypt_event_content(&event).await;
237        let mut reactions: Vec<(StoredEvent, String)> = Vec::with_capacity(message.reactions.len());
238        for reaction in &message.reactions {
239            let user_id = super::id_cache::get_or_create_user_id(&reaction.author_id)?;
240            let is_mine = my_npub.as_deref().map(|n| reaction.author_id == n).unwrap_or(false);
241            let rev = reaction_to_stored_event(reaction, chat_int_id, user_id, is_mine, None);
242            let rtags = serde_json::to_string(&rev.tags).unwrap_or_else(|_| "[]".to_string());
243            reactions.push((rev, rtags));
244        }
245        rows.push(BatchRow { message, event, content, tags_json, reactions, wrapper: *wrapper });
246    }
247    Ok(())
248}
249
250/// Phase 2 of a batched save (sync): ONE transaction for every event + attachment + reaction
251/// + wrapper-ledger row. Insert order follows slice order, preserving the rowid tiebreak
252/// that same-timestamp pagination depends on. A poison message SKIPS (logged) rather than
253/// aborting the batch — one bad row must not lose the other 49. Returns how many messages
254/// were written.
255fn write_batch_rows(rows: &[BatchRow<'_>]) -> Result<usize, String> {
256    let conn = super::get_write_connection_guard_static()?;
257    let tx = conn.unchecked_transaction().map_err(|e| format!("batch tx: {e}"))?;
258    let mut saved = 0usize;
259    for row in rows {
260        // Per-row savepoint = save_message's per-message atomicity inside the batch: a
261        // failed event/attachment write unwinds THIS row completely — including partial
262        // attachment upserts onto a pre-existing row, which a bare DELETE would destroy
263        // (a re-saved old file message must keep its download record on a transient error).
264        tx.execute_batch("SAVEPOINT batch_row").map_err(|e| format!("batch savepoint: {e}"))?;
265        let row_written = insert_event_row(&tx, &row.event, &row.content, &row.tags_json)
266            .and_then(|_| super::attachments::insert_attachment_rows(&tx, &row.message.id, &row.message.attachments));
267        if let Err(e) = row_written {
268            crate::log_warn!("[DB] batch skip {}: {}", &row.message.id[..8.min(row.message.id.len())], e);
269            let _ = tx.execute_batch("ROLLBACK TO batch_row; RELEASE batch_row");
270            continue;
271        }
272        saved += 1;
273        for (rev, rtags) in &row.reactions {
274            // Exists-check ON the tx so a reaction already inserted earlier in this batch dedups
275            // (a fresh reaction row must not clobber one that arrived with a wrapper id).
276            if event_exists_on(&tx, &rev.id).unwrap_or(true) {
277                continue;
278            }
279            if let Err(e) = insert_event_row(&tx, rev, &rev.content, rtags) {
280                crate::log_warn!("[DB] batch reaction {}: {}", &rev.id[..8.min(rev.id.len())], e);
281            }
282        }
283        // Ledger the gift-wrap only now that its row is in the tx — commit lands both or neither.
284        if let Some((wrapper_id, wrapper_created_at)) = &row.wrapper {
285            let mut stmt = tx.prepare_cached(
286                "INSERT OR IGNORE INTO processed_wrappers (wrapper_id, wrapper_created_at, transport) VALUES (?1, ?2, ?3)",
287            ).map_err(|e| format!("prepare wrapper ledger: {e}"))?;
288            if let Err(e) = stmt.execute(rusqlite::params![
289                &wrapper_id[..], *wrapper_created_at as i64, super::wrappers::TRANSPORT_NIP17,
290            ]) {
291                crate::log_warn!("[DB] batch wrapper ledger {}: {}", &row.message.id[..8.min(row.message.id.len())], e);
292            }
293        }
294        tx.execute_batch("RELEASE batch_row").map_err(|e| format!("batch release: {e}"))?;
295    }
296    tx.commit().map_err(|e| format!("batch commit: {e}"))?;
297    Ok(saved)
298}
299
300/// Save many messages for one chat in a SINGLE transaction — the bulk-sync persist path
301/// (community backfill pages, negentropy catch-up). One commit amortizes the per-transaction
302/// WAL overhead across the whole page instead of paying it per message.
303///
304/// Structure mirrors `save_message` exactly: contents are encrypted FIRST (encryption is
305/// async and can't run inside the sync transaction), then one transaction writes every event
306/// row + its attachment rows + its reaction rows (kind-7 content is never encrypted at rest,
307/// so reactions are tx-safe).
308///
309/// `session`: phase 1 awaits through encryption + id resolution, so a swap can land inside
310/// it — when provided, the guard is re-checked between the phases and a stale batch is
311/// dropped before it can write into the next account's DB.
312pub async fn save_messages_batch(
313    chat_id: &str,
314    messages: &[&Message],
315    session: Option<&crate::state::SessionGuard>,
316) -> Result<usize, String> {
317    if messages.is_empty() {
318        return Ok(0);
319    }
320    let with_wrappers: Vec<(&Message, Option<([u8; 32], u64)>)> =
321        messages.iter().map(|m| (*m, None)).collect();
322    let mut rows = Vec::with_capacity(messages.len());
323    prepare_batch_rows(chat_id, &with_wrappers, &mut rows).await?;
324    if session.is_some_and(|s| !s.is_valid()) {
325        return Ok(0);
326    }
327    write_batch_rows(&rows)
328}
329
330/// Multi-chat variant for the DM sync stream: gift-wrapped messages span many contacts, and
331/// splitting per chat would give back most of the batching win. Each message may carry its
332/// gift-wrap ledger entry, committed in the SAME transaction right after its row (see
333/// `BatchRow::wrapper`). Groups keep their slice order; everything lands in ONE transaction.
334pub async fn save_messages_batch_multi(
335    groups: &[(String, Vec<(&Message, Option<([u8; 32], u64)>)>)],
336    session: Option<&crate::state::SessionGuard>,
337) -> Result<usize, String> {
338    let total: usize = groups.iter().map(|(_, m)| m.len()).sum();
339    if total == 0 {
340        return Ok(0);
341    }
342    let mut rows = Vec::with_capacity(total);
343    for (chat_id, messages) in groups {
344        prepare_batch_rows(chat_id, messages, &mut rows).await?;
345    }
346    if session.is_some_and(|s| !s.is_valid()) {
347        return Ok(0);
348    }
349    write_batch_rows(&rows)
350}
351
352/// Convert a Message to a StoredEvent.
353fn message_to_stored_event(message: &Message, chat_id: i64, user_id: Option<i64>) -> StoredEvent {
354    let kind = if !message.attachments.is_empty() {
355        event_kind::FILE_ATTACHMENT
356    } else {
357        event_kind::PRIVATE_DIRECT_MESSAGE
358    };
359
360    let mut tags: Vec<Vec<String>> = Vec::new();
361
362    // Millisecond precision tag
363    let ms = message.at % 1000;
364    if ms > 0 {
365        tags.push(vec!["ms".to_string(), ms.to_string()]);
366    }
367
368    // Reply reference
369    if !message.replied_to.is_empty() {
370        tags.push(vec![
371            "e".to_string(),
372            message.replied_to.clone(),
373            "".to_string(),
374            "reply".to_string(),
375        ]);
376    }
377
378    // Attachments are stored in the dedicated `attachments` table (see save_message), not a tag.
379
380    // NIP-30 emoji tags — persist so reload from DB still renders the
381    // custom emoji image instead of the literal `:shortcode:`.
382    for et in &message.emoji_tags {
383        tags.push(vec!["emoji".to_string(), et.shortcode.clone(), et.url.clone()]);
384    }
385
386    // Bot routing targets (npubs) — persist so the passive "ran /cmd with
387    // Bot" render survives a reload.
388    for npub in &message.addressed_bots {
389        tags.push(vec!["bot".to_string(), npub.clone()]);
390    }
391
392    // NIP-40 self-destruct expiry — persist so the countdown + purge survive a
393    // reload. Rides the same tags column as every other message-shaped tag.
394    if let Some(exp) = message.expiration {
395        tags.push(vec!["expiration".to_string(), exp.to_string()]);
396    }
397
398    let preview_metadata = message.preview_metadata.as_ref()
399        .and_then(|m| serde_json::to_string(m).ok());
400
401    StoredEvent {
402        id: message.id.clone(),
403        kind,
404        chat_id,
405        user_id,
406        content: message.content.clone(),
407        tags,
408        reference_id: None,
409        created_at: message.at / 1000,
410        received_at: std::time::SystemTime::now()
411            .duration_since(std::time::UNIX_EPOCH)
412            .map(|d| d.as_millis() as u64)
413            .unwrap_or(0),
414        mine: message.mine,
415        pending: message.pending,
416        failed: message.failed,
417        wrapper_event_id: message.wrapper_event_id.clone(),
418        npub: message.npub.clone(),
419        preview_metadata,
420    }
421}
422
423/// Save a PIVX payment event, resolving chat_id from conversation identifier.
424pub async fn save_pivx_payment_event(
425    conversation_id: &str,
426    mut event: StoredEvent,
427) -> Result<(), String> {
428    event.chat_id = super::id_cache::get_or_create_chat_id(conversation_id)?;
429    save_event(&event).await
430}
431
432/// Save a system event (member joined/left/removed) with dedup.
433/// Returns true if inserted, false if duplicate.
434pub async fn save_system_event_by_id(
435    event_id: &str,
436    conversation_id: &str,
437    event_type: crate::stored_event::SystemEventType,
438    member_npub: &str,
439    member_name: Option<&str>,
440) -> Result<bool, String> {
441    let now_secs = std::time::SystemTime::now()
442        .duration_since(std::time::UNIX_EPOCH)
443        .map(|d| d.as_secs()).unwrap_or(0);
444    save_system_event_at(event_id, conversation_id, event_type, member_npub, member_name, now_secs, None, None).await
445}
446
447/// Like [`save_system_event_by_id`] but stamps `created_at` from the event's own authenticated timestamp
448/// (clamped to not exceed local now, since the inner author sets it) so a HISTORICALLY-synced presence
449/// (join/leave) sorts at the time it happened, not at ingest-time now. `received_at` stays local now.
450pub async fn save_system_event_at(
451    event_id: &str,
452    conversation_id: &str,
453    event_type: crate::stored_event::SystemEventType,
454    member_npub: &str,
455    member_name: Option<&str>,
456    created_at_secs: u64,
457    // Join attribution (public invites): who minted the link the member joined via, and its label.
458    // Stored as queryable tags so per-link join counts fall out of a tag scan.
459    invited_by: Option<&str>,
460    invited_label: Option<&str>,
461) -> Result<bool, String> {
462    let chat_id = super::id_cache::get_or_create_chat_id(conversation_id)?;
463
464    let now_secs = std::time::SystemTime::now()
465        .duration_since(std::time::UNIX_EPOCH)
466        .map(|d| d.as_secs()).unwrap_or(0);
467    // Author-set timestamp: clamp forward so a future-dated event can't jump ahead of real activity.
468    let created_at = created_at_secs.min(now_secs);
469
470    let display_name = member_name.unwrap_or(member_npub);
471    let content = event_type.display_message(display_name);
472
473    let mut tags: Vec<Vec<String>> = vec![
474        vec!["d".to_string(), "system-event".to_string()],
475        vec!["event-type".to_string(), event_type.as_u8().to_string()],
476        vec!["member".to_string(), member_npub.to_string()],
477    ];
478    if let Some(by) = invited_by {
479        tags.push(vec!["invited-by".to_string(), by.to_string()]);
480        if let Some(l) = invited_label.filter(|l| !l.is_empty()) {
481            tags.push(vec!["invited-label".to_string(), l.to_string()]);
482        }
483    }
484    let tags_json = serde_json::to_string(&tags)
485        .map_err(|e| format!("Failed to serialize tags: {}", e))?;
486
487    let conn = super::get_write_connection_guard_static()?;
488    let rows = conn.execute(
489        r#"INSERT OR IGNORE INTO events (
490            id, kind, chat_id, user_id, content, tags, reference_id,
491            created_at, received_at, mine, pending, failed, wrapper_event_id, npub
492        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)"#,
493        rusqlite::params![
494            event_id,
495            event_kind::APPLICATION_SPECIFIC as i32,
496            chat_id, None::<i64>, content, tags_json, None::<String>,
497            created_at as i64, now_secs as i64,
498            0, 0, 0, None::<String>, member_npub,
499        ],
500    ).map_err(|e| format!("Failed to save system event: {}", e))?;
501
502    Ok(rows > 0)
503}
504
505/// Save a message edit as a kind=16 event referencing the original message.
506pub async fn save_edit_event(
507    edit_id: &str,
508    message_id: &str,
509    new_content: &str,
510    emoji_tags: &[crate::types::EmojiTag],
511    chat_id: i64,
512    user_id: Option<i64>,
513    npub: &str,
514) -> Result<(), String> {
515    let now = std::time::SystemTime::now()
516        .duration_since(std::time::UNIX_EPOCH).unwrap();
517
518    // Carry NIP-30 emoji tags so a reload renders the edit's custom emoji image
519    // (the reload fold reads the latest edit's tags, not the original message's).
520    let mut tags = vec![
521        vec!["e".to_string(), message_id.to_string(), "".to_string(), "edit".to_string()],
522    ];
523    for et in emoji_tags {
524        tags.push(vec!["emoji".to_string(), et.shortcode.clone(), et.url.clone()]);
525    }
526
527    let event = StoredEvent {
528        id: edit_id.to_string(),
529        kind: event_kind::MESSAGE_EDIT,
530        chat_id,
531        user_id,
532        content: new_content.to_string(),
533        tags,
534        reference_id: Some(message_id.to_string()),
535        created_at: now.as_secs(),
536        received_at: now.as_millis() as u64,
537        mine: true,
538        pending: false,
539        failed: false,
540        wrapper_event_id: None,
541        npub: Some(npub.to_string()),
542        preview_metadata: None,
543    };
544
545    save_event(&event).await
546}
547
548/// Delete an event from the events table by ID.
549pub async fn delete_event(event_id: &str) -> Result<(), String> {
550    let conn = super::get_write_connection_guard_static()?;
551    // If this row is a chat's read marker, retreat it to the newest surviving event before it FIRST.
552    // A deleted marker would leave `last_read` dangling and collapse the unread anchor (badge stuck
553    // at 99+). The UPDATE fires only when this chat's marker is exactly the row being deleted.
554    if let Ok(Some((chat_row, at))) = conn.query_row(
555        "SELECT chat_id, created_at FROM events WHERE id = ?1",
556        rusqlite::params![event_id],
557        |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)),
558    ).optional() {
559        conn.execute(
560            "UPDATE chats SET last_read = COALESCE(( \
561                 SELECT id FROM events WHERE chat_id = ?1 AND id != ?2 AND created_at <= ?3 \
562                 ORDER BY created_at DESC, id DESC LIMIT 1), '') \
563             WHERE id = ?1 AND last_read = ?2",
564            rusqlite::params![chat_row, event_id, at],
565        ).map_err(|e| format!("read-marker retreat: {e}"))?;
566    }
567    conn.execute(
568        "DELETE FROM events WHERE id = ?1",
569        rusqlite::params![event_id],
570    ).map_err(|e| format!("Failed to delete event: {}", e))?;
571    Ok(())
572}
573
574/// The stored author (npub) of an event, or `None` if the row (or DB) is absent. Lets the
575/// out-of-window moderation-hide path authorize against a paged-out message's real author.
576pub fn event_author(event_id: &str) -> Result<Option<String>, String> {
577    let conn = match super::get_db_connection_guard_static() {
578        Ok(c) => c,
579        Err(_) => return Ok(None),
580    };
581    conn.query_row(
582        "SELECT npub FROM events WHERE id = ?1",
583        rusqlite::params![event_id],
584        |row| row.get::<_, Option<String>>(0),
585    )
586    .optional()
587    .map(|o| o.flatten())
588    .map_err(|e| format!("Failed to read event author: {}", e))
589}
590
591/// The owning chat identifier, `mine` flag, and stored author (npub) of an event, or
592/// `None` if the row (or DB) is absent. Lets delete-affordance resolution give paged-out
593/// rows the same verdict as resident ones — residency is a cache detail, not a verdict.
594pub fn event_delete_context(event_id: &str) -> Result<Option<(String, bool, Option<String>)>, String> {
595    let conn = match super::get_db_connection_guard_static() {
596        Ok(c) => c,
597        Err(_) => return Ok(None),
598    };
599    conn.query_row(
600        "SELECT c.chat_identifier, e.mine, e.npub \
601         FROM events e JOIN chats c ON c.id = e.chat_id \
602         WHERE e.id = ?1",
603        rusqlite::params![event_id],
604        |row| {
605            Ok((
606                row.get::<_, String>(0)?,
607                row.get::<_, i32>(1)? != 0,
608                row.get::<_, Option<String>>(2)?,
609            ))
610        },
611    )
612    .optional()
613    .map_err(|e| format!("Failed to read event delete context: {}", e))
614}
615
616/// Check if a message/event exists in the database. Returns false if DB unavailable.
617pub fn message_exists_in_db(message_id: &str) -> Result<bool, String> {
618    let conn = match super::get_db_connection_guard_static() {
619        Ok(c) => c,
620        Err(_) => return Ok(false),
621    };
622    conn.query_row(
623        "SELECT EXISTS(SELECT 1 FROM events WHERE id = ?1)",
624        rusqlite::params![message_id],
625        |row| row.get(0),
626    ).map_err(|e| format!("Failed to check event existence: {}", e))
627}
628
629/// Check if a wrapper (giftwrap) event ID exists. Returns false if DB unavailable.
630pub fn wrapper_event_exists(wrapper_event_id: &str) -> Result<bool, String> {
631    let conn = match super::get_db_connection_guard_static() {
632        Ok(c) => c,
633        Err(_) => return Ok(false),
634    };
635    conn.query_row(
636        "SELECT EXISTS(SELECT 1 FROM events WHERE wrapper_event_id = ?1)",
637        rusqlite::params![wrapper_event_id],
638        |row| row.get(0),
639    ).map_err(|e| format!("Failed to check wrapper event existence: {}", e))
640}
641
642/// Update the wrapper event ID for an existing event.
643/// Returns true if updated, false if event already had a wrapper_id.
644pub fn update_wrapper_event_id(event_id: &str, wrapper_event_id: &str) -> Result<bool, String> {
645    let conn = match super::get_write_connection_guard_static() {
646        Ok(c) => c,
647        Err(_) => return Ok(false),
648    };
649    let rows = conn.execute(
650        "UPDATE events SET wrapper_event_id = ?1 WHERE id = ?2 AND (wrapper_event_id IS NULL OR wrapper_event_id = '')",
651        rusqlite::params![wrapper_event_id, event_id],
652    ).map_err(|e| format!("Failed to update wrapper event ID: {}", e))?;
653    Ok(rows > 0)
654}
655
656/// Get message count for a chat.
657pub fn get_chat_message_count(chat_id: i64) -> Result<usize, String> {
658    let conn = super::get_db_connection_guard_static()?;
659    // Must count the SAME kinds get_message_views returns (community chat 9, DM 14, file 15). A
660    // narrower set under-counts vs. the rows actually loaded, which latches the frontend cache's
661    // `isFullyLoaded` flag true and wedges the local back-pager — community channels then never
662    // page DB history past the first screen.
663    let count: i64 = conn.query_row(
664        &format!(
665            "SELECT COUNT(*) FROM events WHERE chat_id = ?1 AND kind IN ({}, {}, {})",
666            event_kind::CHAT_MESSAGE, event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT
667        ),
668        rusqlite::params![chat_id],
669        |row| row.get(0),
670    ).map_err(|e| format!("Failed to count messages: {}", e))?;
671    Ok(count as usize)
672}
673
674/// Get PIVX payment events for a chat.
675pub fn get_pivx_payments_for_chat(conversation_id: &str) -> Result<Vec<StoredEvent>, String> {
676    let conn = super::get_db_connection_guard_static()?;
677    let chat_id: i64 = conn.query_row(
678        "SELECT id FROM chats WHERE chat_identifier = ?1",
679        rusqlite::params![conversation_id], |row| row.get(0)
680    ).map_err(|_| "Chat not found")?;
681
682    let mut stmt = conn.prepare(
683        "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
684         created_at, received_at, mine, pending, failed, wrapper_event_id, npub \
685         FROM events WHERE chat_id = ?1 AND kind = ?2 ORDER BY created_at ASC, received_at ASC"
686    ).map_err(|e| format!("Failed to prepare: {}", e))?;
687
688    let rows = stmt.query_map(
689        rusqlite::params![chat_id, event_kind::APPLICATION_SPECIFIC as i32],
690        |row| {
691            let tags_json: String = row.get(5)?;
692            let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
693            Ok(StoredEvent {
694                id: row.get(0)?, kind: row.get::<_, i32>(1)? as u16,
695                chat_id: row.get(2)?, user_id: row.get(3)?, content: row.get(4)?,
696                tags, reference_id: row.get(6)?,
697                created_at: row.get::<_, i64>(7)? as u64, received_at: row.get::<_, i64>(8)? as u64,
698                mine: row.get::<_, i32>(9)? != 0, pending: row.get::<_, i32>(10)? != 0,
699                failed: row.get::<_, i32>(11)? != 0, wrapper_event_id: row.get(12)?,
700                npub: row.get(13)?, preview_metadata: None,
701            })
702        }
703    ).map_err(|e| format!("Failed to query: {}", e))?;
704
705    let mut payments = Vec::new();
706    for row in rows {
707        let event = row.map_err(|e| format!("Failed to read event: {}", e))?;
708        if event.tags.iter().any(|t| t.len() >= 2 && t[0] == "d" && t[1] == "pivx-payment") {
709            payments.push(event);
710        }
711    }
712    Ok(payments)
713}
714
715/// Get system events (member joined/left) for a chat.
716pub fn get_system_events_for_chat(conversation_id: &str) -> Result<Vec<StoredEvent>, String> {
717    let conn = super::get_db_connection_guard_static()?;
718    let chat_id: i64 = conn.query_row(
719        "SELECT id FROM chats WHERE chat_identifier = ?1",
720        rusqlite::params![conversation_id], |row| row.get(0)
721    ).map_err(|_| "Chat not found")?;
722
723    let mut stmt = conn.prepare(
724        "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
725         created_at, received_at, mine, pending, failed, wrapper_event_id, npub \
726         FROM events WHERE chat_id = ?1 AND kind = ?2 ORDER BY created_at ASC, received_at ASC"
727    ).map_err(|e| format!("Failed to prepare: {}", e))?;
728
729    let rows = stmt.query_map(
730        rusqlite::params![chat_id, event_kind::APPLICATION_SPECIFIC as i32],
731        |row| {
732            let tags_json: String = row.get(5)?;
733            let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
734            Ok(StoredEvent {
735                id: row.get(0)?, kind: row.get::<_, i32>(1)? as u16,
736                chat_id: row.get(2)?, user_id: row.get(3)?, content: row.get(4)?,
737                tags, reference_id: row.get(6)?,
738                created_at: row.get::<_, i64>(7)? as u64, received_at: row.get::<_, i64>(8)? as u64,
739                mine: row.get::<_, i32>(9)? != 0, pending: row.get::<_, i32>(10)? != 0,
740                failed: row.get::<_, i32>(11)? != 0, wrapper_event_id: row.get(12)?,
741                npub: row.get(13)?, preview_metadata: None,
742            })
743        }
744    ).map_err(|e| format!("Failed to query: {}", e))?;
745
746    let mut events = Vec::new();
747    for row in rows {
748        let event = row.map_err(|e| format!("Failed to read event: {}", e))?;
749        if event.tags.iter().any(|t| t.len() >= 2 && t[0] == "d" && t[1] == "system-event") {
750            events.push(event);
751        }
752    }
753    Ok(events)
754}
755
756// ============================================================================
757// Event Read Operations
758// ============================================================================
759
760/// Helper to parse a SQLite row into a StoredEvent.
761fn parse_event_row(row: &rusqlite::Row) -> rusqlite::Result<StoredEvent> {
762    let tags_json: String = row.get(5)?;
763    let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
764
765    Ok(StoredEvent {
766        id: row.get(0)?,
767        kind: row.get::<_, i32>(1)? as u16,
768        chat_id: row.get(2)?,
769        user_id: row.get(3)?,
770        content: row.get(4)?,
771        tags,
772        reference_id: row.get(6)?,
773        created_at: row.get::<_, i64>(7)? as u64,
774        received_at: row.get::<_, i64>(8)? as u64,
775        mine: row.get::<_, i32>(9)? != 0,
776        pending: row.get::<_, i32>(10)? != 0,
777        failed: row.get::<_, i32>(11)? != 0,
778        wrapper_event_id: row.get(12)?,
779        npub: row.get(13)?,
780        preview_metadata: row.get(14)?,
781    })
782}
783
784/// Get events for a chat with pagination, optionally filtered by kind.
785/// Message/edit content is decrypted via maybe_decrypt.
786pub async fn get_events(
787    chat_id: i64,
788    kinds: Option<&[u16]>,
789    limit: usize,
790    offset: usize,
791) -> Result<Vec<StoredEvent>, String> {
792    let events: Vec<StoredEvent> = {
793        let conn = super::get_db_connection_guard_static()?;
794
795        if let Some(k) = kinds {
796            let kind_placeholders: String = (0..k.len())
797                .map(|i| format!("?{}", i + 2))
798                .collect::<Vec<_>>()
799                .join(",");
800            let limit_param = k.len() + 2;
801            let offset_param = k.len() + 3;
802
803            let sql = format!(
804                "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
805                 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
806                 FROM events WHERE chat_id = ?1 AND kind IN ({}) \
807                 ORDER BY created_at DESC, received_at DESC \
808                 LIMIT ?{} OFFSET ?{}",
809                kind_placeholders, limit_param, offset_param
810            );
811
812            let mut stmt = conn.prepare(&sql)
813                .map_err(|e| format!("Failed to prepare events query: {}", e))?;
814
815            match k.len() {
816                1 => {
817                    let rows = stmt.query_map(
818                        rusqlite::params![chat_id, k[0] as i32, limit as i64, offset as i64],
819                        parse_event_row
820                    ).map_err(|e| format!("Failed to query events: {}", e))?;
821                    rows.filter_map(|r| r.ok()).collect()
822                },
823                2 => {
824                    let rows = stmt.query_map(
825                        rusqlite::params![chat_id, k[0] as i32, k[1] as i32, limit as i64, offset as i64],
826                        parse_event_row
827                    ).map_err(|e| format!("Failed to query events: {}", e))?;
828                    rows.filter_map(|r| r.ok()).collect()
829                },
830                3 => {
831                    let rows = stmt.query_map(
832                        rusqlite::params![chat_id, k[0] as i32, k[1] as i32, k[2] as i32, limit as i64, offset as i64],
833                        parse_event_row
834                    ).map_err(|e| format!("Failed to query events: {}", e))?;
835                    rows.filter_map(|r| r.ok()).collect()
836                },
837                _ => return Err("Unsupported number of kinds".to_string()),
838            }
839        } else {
840            let mut stmt = conn.prepare(
841                "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
842                 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
843                 FROM events WHERE chat_id = ?1 \
844                 ORDER BY created_at DESC, received_at DESC \
845                 LIMIT ?2 OFFSET ?3"
846            ).map_err(|e| format!("Failed to prepare events query: {}", e))?;
847
848            let rows = stmt.query_map(
849                rusqlite::params![chat_id, limit as i64, offset as i64],
850                parse_event_row
851            ).map_err(|e| format!("Failed to query events: {}", e))?;
852            rows.filter_map(|r| r.ok()).collect()
853        }
854    };
855
856    // Decrypt message content
857    let mut decrypted = Vec::with_capacity(events.len());
858    for mut event in events {
859        if event.kind == event_kind::CHAT_MESSAGE || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE {
860            event.content = crate::crypto::maybe_decrypt(event.content).await
861                .unwrap_or_else(|_| "[Decryption failed]".to_string());
862        }
863        decrypted.push(event);
864    }
865
866    Ok(decrypted)
867}
868
869/// Get events that reference specific message IDs (reactions, edits).
870pub async fn get_related_events(
871    reference_ids: &[String],
872) -> Result<Vec<StoredEvent>, String> {
873    if reference_ids.is_empty() {
874        return Ok(Vec::new());
875    }
876
877    let conn = super::get_db_connection_guard_static()?;
878
879    let placeholders: String = reference_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
880    let sql = format!(
881        "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
882         created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
883         FROM events WHERE reference_id IN ({}) \
884         ORDER BY created_at ASC, received_at ASC",
885        placeholders
886    );
887
888    let mut stmt = conn.prepare(&sql)
889        .map_err(|e| format!("Failed to prepare related events query: {}", e))?;
890
891    let params: Vec<&dyn rusqlite::ToSql> = reference_ids.iter()
892        .map(|s| s as &dyn rusqlite::ToSql)
893        .collect();
894
895    let events: Vec<StoredEvent> = stmt.query_map(params.as_slice(), parse_event_row)
896        .map_err(|e| format!("Failed to query related events: {}", e))?
897        .filter_map(|r| r.ok())
898        .collect();
899
900    Ok(events)
901}
902
903/// Context data for a replied-to message.
904pub struct ReplyContext {
905    pub content: String,
906    pub npub: Option<String>,
907    pub has_attachment: bool,
908    /// Extension of the attachment, when the replied-to message is a file, so
909    /// the reply quote can label the type even when the target is off-screen.
910    pub extension: Option<String>,
911}
912
913/// Fetch reply context for a list of message IDs.
914pub async fn get_reply_contexts(
915    message_ids: &[String],
916) -> Result<std::collections::HashMap<String, ReplyContext>, String> {
917    use std::collections::HashMap;
918
919    if message_ids.is_empty() {
920        return Ok(HashMap::new());
921    }
922
923    let (events, edits): (Vec<(String, i32, String, Option<String>, Option<String>)>, Vec<(String, String)>) = {
924        let conn = super::get_db_connection_guard_static()?;
925
926        let placeholders: String = (0..message_ids.len())
927            .map(|i| format!("?{}", i + 1))
928            .collect::<Vec<_>>()
929            .join(",");
930
931        // Query original messages (tags carry the file-type/name for attachment quotes)
932        let sql = format!(
933            "SELECT id, kind, content, npub, tags FROM events WHERE id IN ({})",
934            placeholders
935        );
936        let mut stmt = conn.prepare(&sql)
937            .map_err(|e| format!("Failed to prepare reply context query: {}", e))?;
938
939        let params: Vec<&str> = message_ids.iter().map(|s| s.as_str()).collect();
940        let params_dyn: Vec<&dyn rusqlite::ToSql> = params.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
941
942        let rows = stmt.query_map(params_dyn.as_slice(), |row| {
943            Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)?,
944                row.get::<_, String>(2)?, row.get::<_, Option<String>>(3)?,
945                row.get::<_, Option<String>>(4)?))
946        }).map_err(|e| format!("Failed to query reply contexts: {}", e))?;
947        let events_result: Vec<_> = rows.filter_map(|r| r.ok()).collect();
948        drop(stmt);
949
950        // Query latest edits
951        let edit_sql = format!(
952            "SELECT reference_id, content FROM events \
953             WHERE kind = {} AND reference_id IN ({}) \
954             ORDER BY created_at DESC, received_at DESC",
955            event_kind::MESSAGE_EDIT, placeholders
956        );
957        let mut edit_stmt = conn.prepare(&edit_sql)
958            .map_err(|e| format!("Failed to prepare edit query: {}", e))?;
959        let edit_rows = edit_stmt.query_map(params_dyn.as_slice(), |row| {
960            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
961        }).map_err(|e| format!("Failed to query edits: {}", e))?;
962        let edits_result: Vec<_> = edit_rows.filter_map(|r| r.ok()).collect();
963
964        (events_result, edits_result)
965    };
966
967    // Build latest edit map (first = most recent since ordered DESC)
968    let mut latest_edits: HashMap<String, String> = HashMap::new();
969    for (ref_id, content) in edits {
970        latest_edits.entry(ref_id).or_insert(content);
971    }
972
973    // Batch the file replies' attachments (one query, not one per reply) for the extension below.
974    let file_ids: Vec<String> = events.iter()
975        .filter(|(_, kind, _, _, _)| *kind == event_kind::FILE_ATTACHMENT as i32)
976        .map(|(id, _, _, _, _)| id.clone())
977        .collect();
978    let atts_by_event = super::attachments::get_attachments_for_events(&file_ids).unwrap_or_default();
979
980    // Decrypt and build contexts
981    let mut contexts = HashMap::new();
982    for (id, kind, original_content, npub, tags) in events {
983        let has_attachment = kind == event_kind::FILE_ATTACHMENT as i32;
984        let content_to_decrypt = latest_edits.get(&id).cloned().unwrap_or(original_content);
985
986        let decrypted_content = if kind == event_kind::CHAT_MESSAGE as i32
987            || kind == event_kind::PRIVATE_DIRECT_MESSAGE as i32
988        {
989            crate::crypto::maybe_decrypt(content_to_decrypt).await
990                .unwrap_or_else(|_| "[Decryption failed]".to_string())
991        } else {
992            String::new()
993        };
994
995        // The first attachment's extension lets the quote show the file type. From the table, with a
996        // legacy-tag fallback for an un-backfilled pre-migration row.
997        let extension = if has_attachment {
998            atts_by_event.get(&id)
999                .and_then(|atts| atts.first())
1000                .map(|a| a.extension.to_lowercase())
1001                .filter(|e| !e.is_empty())
1002                .or_else(|| tags.as_deref()
1003                    .and_then(|t| serde_json::from_str::<Vec<Vec<String>>>(t).ok())
1004                    .and_then(|parsed| parsed.into_iter()
1005                        .find(|t| t.first().map(|k| k == "attachments").unwrap_or(false))
1006                        .and_then(|t| t.into_iter().nth(1)))
1007                    .and_then(|json| serde_json::from_str::<Vec<serde_json::Value>>(&json).ok())
1008                    .and_then(|atts| atts.into_iter().next())
1009                    .and_then(|a| a.get("extension").and_then(|e| e.as_str()).map(str::to_lowercase))
1010                    .filter(|e| !e.is_empty()))
1011        } else {
1012            None
1013        };
1014
1015        contexts.insert(id, ReplyContext { content: decrypted_content, npub, has_attachment, extension });
1016    }
1017
1018    Ok(contexts)
1019}
1020
1021/// Populate reply context for a PAGE of messages in one query — the sync/back-page
1022/// counterpart to [`populate_reply_context`]. Every path that emits a message straight to
1023/// the UI must resolve the quote first: the frontend renders the emitted payload, and a
1024/// reply whose parent lies outside the rendered window has no other source for it (a
1025/// parent inside the window is resolved from memory instead). Messages whose parent isn't
1026/// persisted yet are left untouched.
1027pub async fn populate_reply_contexts(messages: Vec<&mut Message>) -> Result<(), String> {
1028    let ids: Vec<String> = messages
1029        .iter()
1030        .filter(|m| !m.replied_to.is_empty())
1031        .map(|m| m.replied_to.clone())
1032        .collect();
1033    if ids.is_empty() {
1034        return Ok(());
1035    }
1036    let contexts = get_reply_contexts(&ids).await?;
1037    for message in messages {
1038        if let Some(ctx) = contexts.get(&message.replied_to) {
1039            message.replied_to_content = Some(ctx.content.clone());
1040            message.replied_to_npub = ctx.npub.clone();
1041            message.replied_to_has_attachment = Some(ctx.has_attachment);
1042            message.replied_to_attachment_extension = ctx.extension.clone();
1043        }
1044    }
1045    Ok(())
1046}
1047
1048/// Populate reply context for a single message.
1049/// Used for real-time messages that don't go through get_message_views.
1050pub async fn populate_reply_context(message: &mut Message) -> Result<(), String> {
1051    if message.replied_to.is_empty() {
1052        return Ok(());
1053    }
1054
1055    let contexts = get_reply_contexts(&[message.replied_to.clone()]).await?;
1056
1057    if let Some(ctx) = contexts.get(&message.replied_to) {
1058        message.replied_to_content = Some(ctx.content.clone());
1059        message.replied_to_npub = ctx.npub.clone();
1060        message.replied_to_has_attachment = Some(ctx.has_attachment);
1061        message.replied_to_attachment_extension = ctx.extension.clone();
1062    }
1063
1064    Ok(())
1065}
1066
1067/// Whether `event_id` is one of our own messages (`mine = 1`). A reply to our
1068/// own message is an implicit ping, so notifications treat it like a direct
1069/// @mention (breaks through a muted channel). Missing row → not ours → false.
1070pub fn is_own_event(event_id: &str) -> bool {
1071    let Ok(conn) = super::get_db_connection_guard_static() else {
1072        return false;
1073    };
1074    conn.query_row(
1075        "SELECT mine FROM events WHERE id = ?1",
1076        [event_id],
1077        |row| row.get::<_, i64>(0),
1078    )
1079    .map(|mine| mine == 1)
1080    .unwrap_or(false)
1081}
1082
1083// ============================================================================
1084// Message Views — compose full Messages from events + reactions + edits
1085// ============================================================================
1086
1087/// Extract a single tag value from raw tags JSON without full allocation.
1088fn extract_tag_from_json(tags_json: &str, key: &str) -> Option<String> {
1089    if tags_json.len() <= 2 { return None; }
1090    let pattern = format!("[\"{}\"", key);
1091    if !tags_json.contains(&pattern) { return None; }
1092    let tags: Vec<Vec<String>> = serde_json::from_str(tags_json).ok()?;
1093    tags.into_iter()
1094        .find(|tag| tag.first().map(|s| s.as_str()) == Some(key))
1095        .and_then(|tag| tag.into_iter().nth(1))
1096}
1097
1098
1099/// A stored reaction author written as 64-char hex (an early v2 ingest) reads
1100/// back as the npub the frontend contract expects — self-heals old rows with no
1101/// migration; a bech32 or unknown value passes through untouched.
1102fn normalize_reaction_author(author: String) -> String {
1103    if author.len() == 64 && author.bytes().all(|b| b.is_ascii_hexdigit()) {
1104        if let Ok(pk) = nostr_sdk::prelude::PublicKey::from_hex(&author) {
1105            use nostr_sdk::prelude::ToBech32;
1106            let Ok(npub) = pk.to_bech32();
1107            return npub;
1108        }
1109    }
1110    author
1111}
1112/// Extract the NIP-30 `["emoji", shortcode, url]` URL from a stored
1113/// reaction's tags. The reaction's content must be `:shortcode:` form
1114/// and the matching tag's shortcode must agree — otherwise we get the
1115/// URL of a stray emoji tag that doesn't actually represent the
1116/// reaction's chosen emoji.
1117fn extract_reaction_emoji_url(tags: &[Vec<String>], content: &str) -> Option<String> {
1118    if !content.starts_with(':') || !content.ends_with(':') || content.len() < 3 {
1119        return None;
1120    }
1121    let sc = &content[1..content.len() - 1];
1122    tags.iter().find_map(|t| {
1123        if t.len() >= 3 && t[0] == "emoji" && t[1] == sc {
1124            Some(t[2].clone())
1125        } else {
1126            None
1127        }
1128    })
1129}
1130
1131/// Extract a NIP-10 reply reference ("e" tag with "reply" marker at position 3).
1132fn extract_reply_tag_from_json(tags_json: &str) -> Option<String> {
1133    if tags_json.len() <= 2 { return None; }
1134    if !tags_json.contains("[\"e\"") { return None; }
1135    let tags: Vec<Vec<String>> = serde_json::from_str(tags_json).ok()?;
1136    tags.into_iter()
1137        .find(|tag| {
1138            tag.first().map(|s| s.as_str()) == Some("e")
1139                && tag.get(3).map(|s| s.as_str()) == Some("reply")
1140        })
1141        .and_then(|tag| tag.into_iter().nth(1))
1142}
1143
1144/// Get message events with reactions, edits, and attachments composed.
1145///
1146/// This is the main "get messages" function. Queries events, fetches related
1147/// reactions/edits, parses attachments, applies edits, resolves reply context.
1148pub async fn get_message_views(
1149    chat_id: i64,
1150    limit: usize,
1151    offset: usize,
1152) -> Result<Vec<Message>, String> {
1153    // Step 1: Get message events (kind 9, 14, 15)
1154    let message_kinds = [event_kind::CHAT_MESSAGE, event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT];
1155    let message_events = get_events(chat_id, Some(&message_kinds), limit, offset).await?;
1156
1157    compose_message_views(message_events).await
1158}
1159
1160/// Compose Message views from already-fetched message events (kind 9/14/15):
1161/// fetch related reactions/edits, parse attachments, apply edits, resolve reply
1162/// context. Shared by `get_message_views` (offset pager) and `get_messages_around`
1163/// (anchored window). Input order is preserved in the output.
1164async fn compose_message_views(message_events: Vec<StoredEvent>) -> Result<Vec<Message>, String> {
1165    use std::collections::HashMap;
1166
1167    if message_events.is_empty() {
1168        return Ok(Vec::new());
1169    }
1170
1171    // Step 2: Get related events (reactions, edits)
1172    let message_ids: Vec<String> = message_events.iter().map(|e| e.id.clone()).collect();
1173    let related_events = get_related_events(&message_ids).await?;
1174
1175    let mut reactions_by_msg: HashMap<String, Vec<Reaction>> = HashMap::new();
1176    let mut edits_by_msg: HashMap<String, Vec<(u64, String, Vec<crate::types::EmojiTag>)>> = HashMap::new();
1177
1178    for event in related_events {
1179        if let Some(ref_id) = &event.reference_id {
1180            match event.kind {
1181                k if k == event_kind::REACTION => {
1182                    let emoji_url = extract_reaction_emoji_url(&event.tags, &event.content);
1183                    reactions_by_msg.entry(ref_id.clone()).or_default().push(Reaction {
1184                        id: event.id.clone(),
1185                        reference_id: ref_id.clone(),
1186                        author_id: normalize_reaction_author(event.npub.clone().unwrap_or_default()),
1187                        emoji: event.content.clone(),
1188                        emoji_url,
1189                    });
1190                }
1191                k if k == event_kind::MESSAGE_EDIT => {
1192                    let decrypted = crate::crypto::maybe_decrypt(event.content.clone()).await
1193                        .unwrap_or_else(|_| event.content.clone());
1194                    let edit_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
1195                    edits_by_msg.entry(ref_id.clone()).or_default().push((event.created_at * 1000, decrypted, edit_emoji));
1196                }
1197                _ => {}
1198            }
1199        }
1200    }
1201
1202    for edits in edits_by_msg.values_mut() {
1203        edits.sort_by_key(|(ts, _, _)| *ts);
1204    }
1205
1206    // Step 3: Attachments from the dedicated table (batched), with a legacy-tag fallback for any
1207    // file event not represented in the table (an un-backfilled pre-migration row).
1208    let attach_ids: Vec<String> = message_events.iter()
1209        .filter(|e| e.kind == event_kind::FILE_ATTACHMENT || e.kind == event_kind::CHAT_MESSAGE)
1210        .map(|e| e.id.clone())
1211        .collect();
1212    let mut attachments_by_msg = super::attachments::get_attachments_for_events(&attach_ids)
1213        .unwrap_or_default();
1214    for event in &message_events {
1215        if event.kind != event_kind::FILE_ATTACHMENT && event.kind != event_kind::CHAT_MESSAGE {
1216            continue;
1217        }
1218        if attachments_by_msg.contains_key(&event.id) {
1219            continue;
1220        }
1221        if let Some(json) = event.get_tag("attachments") {
1222            if let Ok(atts) = serde_json::from_str::<Vec<Attachment>>(json) {
1223                if !atts.is_empty() {
1224                    attachments_by_msg.insert(event.id.clone(), atts);
1225                }
1226            }
1227        }
1228    }
1229
1230    // Step 4: Compose Message structs
1231    let mut messages = Vec::with_capacity(message_events.len());
1232    for event in message_events {
1233        let replied_to = event.get_reply_reference().unwrap_or("").to_string();
1234        let at = event.timestamp_ms();
1235        let reactions = reactions_by_msg.remove(&event.id).unwrap_or_default();
1236        let attachments = attachments_by_msg.remove(&event.id).unwrap_or_default();
1237
1238        let original_content = if event.kind == event_kind::FILE_ATTACHMENT {
1239            String::new()
1240        } else {
1241            event.content.clone()
1242        };
1243
1244        // Edits carry their own emoji tags; the newest edit's tags win so the
1245        // displayed (latest) content renders its custom emoji, not the original's.
1246        let original_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
1247        let (content, edited, edit_history, emoji_tags) = if let Some(edits) = edits_by_msg.remove(&event.id) {
1248            let mut history = Vec::with_capacity(edits.len() + 1);
1249            history.push(crate::types::EditEntry { content: original_content.clone(), edited_at: at });
1250            for (ts, c, _) in &edits {
1251                history.push(crate::types::EditEntry { content: c.clone(), edited_at: *ts });
1252            }
1253            let (latest, latest_emoji) = edits.last()
1254                .map(|(_, c, e)| (c.clone(), e.clone()))
1255                .unwrap_or_else(|| (original_content.clone(), original_emoji.clone()));
1256            (latest, true, Some(history), latest_emoji)
1257        } else {
1258            (original_content, false, None, original_emoji)
1259        };
1260
1261        let preview_metadata = event.preview_metadata
1262            .and_then(|json| serde_json::from_str(&json).ok());
1263
1264        let addressed_bots = extract_bot_tags(&event.tags);
1265        let expiration = extract_expiration_tag(&event.tags);
1266        messages.push(Message {
1267            expiration,
1268            id: event.id, content, replied_to,
1269            replied_to_content: None, replied_to_npub: None, replied_to_has_attachment: None,
1270            replied_to_attachment_extension: None,
1271            preview_metadata, attachments, reactions, at,
1272            pending: event.pending, failed: event.failed, mine: event.mine,
1273            npub: event.npub, wrapper_event_id: event.wrapper_event_id,
1274            edited, edit_history,
1275            emoji_tags,
1276            addressed_bots,
1277        });
1278    }
1279
1280    // Rows whose NIP-40 expiry passed while out of STATE (app closed, chat
1281    // unopened) must never reach a renderer — strip them here, at the single
1282    // DB→Message chokepoint, and purge their remnants in the background.
1283    crate::self_destruct::strip_expired(&mut messages);
1284
1285    // Step 5: Reply context
1286    let reply_ids: Vec<String> = messages.iter()
1287        .filter(|m| !m.replied_to.is_empty())
1288        .map(|m| m.replied_to.clone())
1289        .collect();
1290
1291    if !reply_ids.is_empty() {
1292        let contexts = get_reply_contexts(&reply_ids).await?;
1293        for msg in &mut messages {
1294            if let Some(ctx) = contexts.get(&msg.replied_to) {
1295                msg.replied_to_content = Some(ctx.content.clone());
1296                msg.replied_to_npub = ctx.npub.clone();
1297                msg.replied_to_has_attachment = Some(ctx.has_attachment);
1298                msg.replied_to_attachment_extension = ctx.extension.clone();
1299            }
1300        }
1301    }
1302
1303    Ok(messages)
1304}
1305
1306/// Anchored (random-access) message window: load `before` messages up to and
1307/// including the anchor, plus `after` messages strictly newer than it. O(window)
1308/// regardless of how deep the anchor sits in the chat — unlike the offset pager,
1309/// which is O(depth) to reach a far-back message.
1310///
1311/// Returns ASC by `created_at` (oldest first), composed with reactions/edits/
1312/// attachments. Errs if the anchor id isn't in the DB so the caller can fall back.
1313pub async fn get_messages_around(
1314    chat_id: i64,
1315    anchor_id: &str,
1316    before: usize,
1317    after: usize,
1318) -> Result<Vec<Message>, String> {
1319    let message_kinds = [event_kind::CHAT_MESSAGE, event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT];
1320
1321    let message_events: Vec<StoredEvent> = {
1322        let conn = super::get_db_connection_guard_static()?;
1323
1324        // Resolve the anchor's FULL sort key (created_at, received_at, rowid). Paging by created_at
1325        // alone wedges on a wall of equal timestamps (a message burst): the query keeps returning the
1326        // same newest-N of the cluster, so back-paging stalls before reaching older history. The
1327        // (received_at, rowid) tiebreak — rowid being the unique final key — gives a strict total
1328        // order, so every page steps strictly past the previous, through any same-timestamp cluster.
1329        let (anchor_at, anchor_rt, anchor_rowid): (i64, i64, i64) = conn.query_row(
1330            "SELECT created_at, received_at, rowid FROM events WHERE id = ?1",
1331            rusqlite::params![anchor_id],
1332            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1333        ).map_err(|e| format!("Anchor message not found: {}", e))?;
1334
1335        // Kinds occupy ?2..?4; then ?5 created_at, ?6 received_at, ?7 rowid, ?8 limit.
1336        let kind_placeholders: String = (0..message_kinds.len())
1337            .map(|i| format!("?{}", i + 2))
1338            .collect::<Vec<_>>()
1339            .join(",");
1340        let cols = "id, kind, chat_id, user_id, content, tags, reference_id, \
1341                    created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata";
1342
1343        // Older incl. anchor: strict key <= anchor key; newest-first then reverse to ASC.
1344        let older_sql = format!(
1345            "SELECT {} FROM events WHERE chat_id = ?1 AND kind IN ({}) \
1346             AND (created_at < ?5 OR (created_at = ?5 AND (received_at < ?6 \
1347                  OR (received_at = ?6 AND rowid <= ?7)))) \
1348             ORDER BY created_at DESC, received_at DESC, rowid DESC LIMIT ?8",
1349            cols, kind_placeholders
1350        );
1351        let mut older_stmt = conn.prepare(&older_sql)
1352            .map_err(|e| format!("Failed to prepare older window query: {}", e))?;
1353        let older_rows = older_stmt.query_map(
1354            rusqlite::params![
1355                chat_id,
1356                message_kinds[0] as i32, message_kinds[1] as i32, message_kinds[2] as i32,
1357                anchor_at, anchor_rt, anchor_rowid, before as i64
1358            ],
1359            parse_event_row,
1360        ).map_err(|e| format!("Failed to query older window: {}", e))?;
1361        let mut older: Vec<StoredEvent> = older_rows.filter_map(|r| r.ok()).collect();
1362        older.reverse(); // DESC -> ASC
1363
1364        // Newer: strictly after the anchor key.
1365        let newer_sql = format!(
1366            "SELECT {} FROM events WHERE chat_id = ?1 AND kind IN ({}) \
1367             AND (created_at > ?5 OR (created_at = ?5 AND (received_at > ?6 \
1368                  OR (received_at = ?6 AND rowid > ?7)))) \
1369             ORDER BY created_at ASC, received_at ASC, rowid ASC LIMIT ?8",
1370            cols, kind_placeholders
1371        );
1372        let mut newer_stmt = conn.prepare(&newer_sql)
1373            .map_err(|e| format!("Failed to prepare newer window query: {}", e))?;
1374        let newer_rows = newer_stmt.query_map(
1375            rusqlite::params![
1376                chat_id,
1377                message_kinds[0] as i32, message_kinds[1] as i32, message_kinds[2] as i32,
1378                anchor_at, anchor_rt, anchor_rowid, after as i64
1379            ],
1380            parse_event_row,
1381        ).map_err(|e| format!("Failed to query newer window: {}", e))?;
1382        let newer: Vec<StoredEvent> = newer_rows.filter_map(|r| r.ok()).collect();
1383
1384        older.into_iter().chain(newer).collect()
1385    };
1386
1387    // Decrypt message content (mirror get_events).
1388    let mut decrypted = Vec::with_capacity(message_events.len());
1389    for mut event in message_events {
1390        if event.kind == event_kind::CHAT_MESSAGE || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE {
1391            event.content = crate::crypto::maybe_decrypt(event.content).await
1392                .unwrap_or_else(|_| "[Decryption failed]".to_string());
1393        }
1394        decrypted.push(event);
1395    }
1396
1397    compose_message_views(decrypted).await
1398}
1399
1400/// Get the last message for ALL chats in a single batch query.
1401/// Optimized for app startup (chat list sidebar).
1402pub async fn get_all_chats_last_messages() -> Result<std::collections::HashMap<String, Vec<Message>>, String> {
1403    use std::collections::HashMap;
1404
1405    // Step 1: Query last message per chat via correlated subquery
1406    let message_events: Vec<(String, StoredEvent, String)> = {
1407        let conn = super::get_db_connection_guard_static()?;
1408        let mut stmt = conn.prepare(
1409            "SELECT c.chat_identifier, \
1410             e.id, e.kind, e.chat_id, e.user_id, e.content, e.tags, e.reference_id, \
1411             e.created_at, e.received_at, e.mine, e.pending, e.failed, e.wrapper_event_id, e.npub, e.preview_metadata \
1412             FROM chats c JOIN events e ON e.rowid = ( \
1413                 SELECT e2.rowid FROM events e2 WHERE e2.chat_id = c.id \
1414                 AND e2.kind IN (?1, ?2, ?3) \
1415                 ORDER BY e2.created_at DESC, e2.received_at DESC LIMIT 1) \
1416             WHERE c.chat_type != 1"
1417        ).map_err(|e| format!("Failed to prepare: {}", e))?;
1418
1419        let rows = stmt.query_map(
1420            rusqlite::params![
1421                event_kind::CHAT_MESSAGE as i32,
1422                event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1423                event_kind::FILE_ATTACHMENT as i32
1424            ],
1425            |row| {
1426                let chat_id: String = row.get(0)?;
1427                let tags_json: String = row.get(6)?;
1428                let event = StoredEvent {
1429                    id: row.get(1)?, kind: row.get::<_, i32>(2)? as u16,
1430                    chat_id: row.get(3)?, user_id: row.get(4)?, content: row.get(5)?,
1431                    tags: Vec::new(), // Deferred — parsed on-demand
1432                    reference_id: row.get(7)?,
1433                    created_at: row.get::<_, i64>(8)? as u64, received_at: row.get::<_, i64>(9)? as u64,
1434                    mine: row.get::<_, i32>(10)? != 0, pending: row.get::<_, i32>(11)? != 0,
1435                    failed: row.get::<_, i32>(12)? != 0, wrapper_event_id: row.get(13)?,
1436                    npub: row.get(14)?, preview_metadata: row.get(15)?,
1437                };
1438                Ok((chat_id, event, tags_json))
1439            }
1440        ).map_err(|e| format!("Failed to query: {}", e))?;
1441        rows.filter_map(|r| r.ok()).collect()
1442    };
1443
1444    if message_events.is_empty() {
1445        return Ok(HashMap::new());
1446    }
1447
1448    // Step 2: Related events (reactions, edits)
1449    let message_ids: Vec<String> = message_events.iter().map(|(_, e, _)| e.id.clone()).collect();
1450    let related_events = get_related_events(&message_ids).await?;
1451
1452    let mut reactions_by_msg: HashMap<String, Vec<Reaction>> = HashMap::new();
1453    let mut edits_by_msg: HashMap<String, Vec<(u64, String, Vec<crate::types::EmojiTag>)>> = HashMap::new();
1454
1455    for event in related_events {
1456        if let Some(ref_id) = &event.reference_id {
1457            match event.kind {
1458                k if k == event_kind::REACTION => {
1459                    let emoji_url = extract_reaction_emoji_url(&event.tags, &event.content);
1460                    reactions_by_msg.entry(ref_id.clone()).or_default().push(Reaction {
1461                        id: event.id.clone(), reference_id: ref_id.clone(),
1462                        author_id: normalize_reaction_author(event.npub.clone().unwrap_or_default()),
1463                        emoji: event.content.clone(),
1464                        emoji_url,
1465                    });
1466                }
1467                k if k == event_kind::MESSAGE_EDIT => {
1468                    let decrypted = crate::crypto::maybe_decrypt(event.content.clone()).await
1469                        .unwrap_or_else(|_| event.content.clone());
1470                    let edit_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
1471                    edits_by_msg.entry(ref_id.clone()).or_default().push((event.created_at * 1000, decrypted, edit_emoji));
1472                }
1473                _ => {}
1474            }
1475        }
1476    }
1477    for edits in edits_by_msg.values_mut() {
1478        edits.sort_by_key(|(ts, _, _)| *ts);
1479    }
1480
1481    // Step 3: Attachments from the dedicated table (batched), with a legacy-tag fallback.
1482    let attach_ids: Vec<String> = message_events.iter()
1483        .filter(|(_, e, _)| e.kind == event_kind::FILE_ATTACHMENT || e.kind == event_kind::CHAT_MESSAGE)
1484        .map(|(_, e, _)| e.id.clone())
1485        .collect();
1486    let mut attachments_by_msg = super::attachments::get_attachments_for_events(&attach_ids)
1487        .unwrap_or_default();
1488    for (_, event, tags_json) in &message_events {
1489        if event.kind != event_kind::FILE_ATTACHMENT && event.kind != event_kind::CHAT_MESSAGE {
1490            continue;
1491        }
1492        if attachments_by_msg.contains_key(&event.id) {
1493            continue;
1494        }
1495        if let Some(val) = extract_tag_from_json(tags_json, "attachments") {
1496            if let Ok(atts) = serde_json::from_str::<Vec<Attachment>>(&val) {
1497                if !atts.is_empty() {
1498                    attachments_by_msg.insert(event.id.clone(), atts);
1499                }
1500            }
1501        }
1502    }
1503
1504    // Step 4: Compose Messages grouped by chat_identifier
1505    let mut result: HashMap<String, Vec<Message>> = HashMap::new();
1506
1507    for (chat_identifier, event, tags_json) in message_events {
1508        let reactions = reactions_by_msg.remove(&event.id).unwrap_or_default();
1509        let attachments = attachments_by_msg.remove(&event.id).unwrap_or_default();
1510        let replied_to = extract_reply_tag_from_json(&tags_json).unwrap_or_default();
1511
1512        // Decrypt content
1513        let original_content = if event.kind == event_kind::CHAT_MESSAGE
1514            || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE
1515        {
1516            crate::crypto::maybe_decrypt(event.content.clone()).await
1517                .unwrap_or_else(|_| "[Decryption failed]".to_string())
1518        } else {
1519            String::new()
1520        };
1521
1522        let stored_tags = serde_json::from_str::<Vec<Vec<String>>>(&tags_json).unwrap_or_default();
1523        let original_emoji = crate::types::EmojiTag::extract_from_stored(&stored_tags);
1524        let addressed_bots = extract_bot_tags(&stored_tags);
1525        let expiration = extract_expiration_tag(&stored_tags);
1526        // Newest edit's emoji tags win so the latest content renders correctly.
1527        let (content, edited, edit_history, emoji_tags) = if let Some(edits) = edits_by_msg.remove(&event.id) {
1528            let (latest, latest_emoji) = edits.last()
1529                .map(|(_, c, e)| (c.clone(), e.clone()))
1530                .unwrap_or_else(|| (original_content.clone(), original_emoji.clone()));
1531            let history: Vec<crate::types::EditEntry> = std::iter::once(crate::types::EditEntry {
1532                content: original_content, edited_at: event.created_at * 1000,
1533            }).chain(edits.into_iter().map(|(ts, c, _)| crate::types::EditEntry { content: c, edited_at: ts }))
1534            .collect();
1535            (latest, true, Some(history), latest_emoji)
1536        } else {
1537            (original_content, false, None, original_emoji)
1538        };
1539
1540        let preview_metadata = event.preview_metadata
1541            .and_then(|json| serde_json::from_str(&json).ok());
1542
1543        result.entry(chat_identifier).or_default().push(Message {
1544            expiration,
1545            id: event.id, content, replied_to,
1546            replied_to_content: None, replied_to_npub: None, replied_to_has_attachment: None,
1547            replied_to_attachment_extension: None,
1548            preview_metadata, attachments, reactions, at: event.created_at * 1000,
1549            pending: event.pending, failed: event.failed, mine: event.mine,
1550            npub: event.npub, wrapper_event_id: event.wrapper_event_id,
1551            edited, edit_history,
1552            emoji_tags,
1553            addressed_bots,
1554        });
1555    }
1556
1557    // Step 5: Reply context — the openChat pre-paint renders this boot last-message
1558    // synchronously (before the richer get_message_views load lands), so without
1559    // context here a reply shows its quote only on the second open.
1560    let reply_ids: Vec<String> = result.values()
1561        .flatten()
1562        .filter(|m| !m.replied_to.is_empty())
1563        .map(|m| m.replied_to.clone())
1564        .collect();
1565
1566    if !reply_ids.is_empty() {
1567        let contexts = get_reply_contexts(&reply_ids).await?;
1568        for msg in result.values_mut().flatten() {
1569            if let Some(ctx) = contexts.get(&msg.replied_to) {
1570                msg.replied_to_content = Some(ctx.content.clone());
1571                msg.replied_to_npub = ctx.npub.clone();
1572                msg.replied_to_has_attachment = Some(ctx.has_attachment);
1573                msg.replied_to_attachment_extension = ctx.extension.clone();
1574            }
1575        }
1576    }
1577
1578    // An expired self-destruct as a chat's last message must not flash in the
1579    // chat list — strip + background-purge; the preview shows empty until the
1580    // next boot resolves the prior message, same as a mid-session sweep.
1581    for msgs in result.values_mut() {
1582        crate::self_destruct::strip_expired(msgs);
1583    }
1584
1585    Ok(result)
1586}
1587
1588/// Per-chat unread count, computed straight from the DB so it's correct even when only the last
1589/// message is in RAM (the boot state). Mirrors the in-memory walk-back exactly: unread = non-mine
1590/// messages newer than the most recent "anchor" (our own message OR the `last_read` marker,
1591/// whichever is latest). A never-read chat (empty `last_read`, no own message) counts all its
1592/// non-mine messages. Returns `chat_identifier → count`; chats with 0 unread are omitted.
1593/// Muted/blocked filtering is left to the caller (it lives in RAM state, cheaply).
1594pub async fn unread_counts() -> Result<std::collections::HashMap<String, u32>, String> {
1595    let conn = super::get_db_connection_guard_static()?;
1596    // Anchor computed once per chat in the CTE so the count scan doesn't re-derive it per row. The
1597    // anchor filter rides the LEFT JOIN's ON clause so a never-read chat still yields a row (anchor
1598    // 0 via COALESCE) and counts all its messages; in WHERE it would drop those chats. The
1599    // `last_read` anchor is kind-agnostic: a "read to here" marker can land on a system event (kind
1600    // 30078) and must still cut the count by its timestamp. Only the own-message anchor is kind-filtered.
1601    let mut stmt = conn
1602        .prepare(
1603            "WITH anchors AS ( \
1604                SELECT c.id AS chat_id, c.chat_identifier AS chat_identifier, \
1605                       COALESCE(MAX(e.created_at), 0) AS anchor_ts \
1606                FROM chats c \
1607                LEFT JOIN events e ON e.chat_id = c.id \
1608                  AND ((e.mine = 1 AND e.kind IN (?1, ?2, ?3)) OR e.id = c.last_read) \
1609                GROUP BY c.id \
1610             ) \
1611             SELECT a.chat_identifier, COUNT(*) AS unread \
1612             FROM events e JOIN anchors a ON a.chat_id = e.chat_id \
1613             WHERE e.kind IN (?1, ?2, ?3) AND e.mine = 0 AND e.created_at > a.anchor_ts \
1614             GROUP BY a.chat_identifier",
1615        )
1616        .map_err(|e| format!("prepare unread_counts: {e}"))?;
1617    let rows = stmt
1618        .query_map(
1619            rusqlite::params![
1620                event_kind::CHAT_MESSAGE as i32,
1621                event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1622                event_kind::FILE_ATTACHMENT as i32
1623            ],
1624            |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u32)),
1625        )
1626        .map_err(|e| format!("query unread_counts: {e}"))?;
1627    let mut out = std::collections::HashMap::new();
1628    for r in rows.flatten() {
1629        out.insert(r.0, r.1);
1630    }
1631    Ok(out)
1632}
1633
1634/// Unread count for a SINGLE chat, same semantics as [`unread_counts`]. The RAM cache calls this to
1635/// reconcile one chat (open / delete / retreat) without recomputing every chat's count.
1636pub async fn unread_count_for_chat(chat_identifier: &str) -> Result<u32, String> {
1637    let conn = super::get_db_connection_guard_static()?;
1638    let count: i64 = conn
1639        .query_row(
1640            "SELECT COUNT(*) FROM events e JOIN chats c ON e.chat_id = c.id \
1641             WHERE c.chat_identifier = ?4 AND e.kind IN (?1, ?2, ?3) AND e.mine = 0 \
1642               AND e.created_at > COALESCE(( \
1643                     SELECT MAX(e2.created_at) FROM events e2 \
1644                     WHERE e2.chat_id = c.id \
1645                       AND ((e2.mine = 1 AND e2.kind IN (?1, ?2, ?3)) OR e2.id = c.last_read)), 0)",
1646            rusqlite::params![
1647                event_kind::CHAT_MESSAGE as i32,
1648                event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1649                event_kind::FILE_ATTACHMENT as i32,
1650                chat_identifier
1651            ],
1652            |row| row.get(0),
1653        )
1654        .map_err(|e| format!("query unread_count_for_chat: {e}"))?;
1655    Ok(count as u32)
1656}
1657
1658/// What [`compute_unread_anchor`] decided a chat's read marker should become to surface its newest
1659/// contact message as unread. Computed from the full DB history (RAM may hold only a preview
1660/// message for an unopened community).
1661#[derive(Debug, PartialEq)]
1662pub enum UnreadMark {
1663    /// Nothing to surface: no contact message, or a strictly-newer own message (we spoke last).
1664    NoOp,
1665    /// Reset to the never-read anchor: the target is the chat's earliest message.
1666    Clear,
1667    /// Retreat `last_read` to this event id (the newest message in a strictly earlier second).
1668    Anchor(String),
1669}
1670
1671/// Decide how to mark `chat_identifier` unread. Anchors on the newest message strictly before the
1672/// newest contact message's second — the count query compares whole seconds with a strict `>`, so a
1673/// same-second anchor would leave the target on the boundary and it would read as caught-up.
1674pub async fn compute_unread_anchor(chat_identifier: &str) -> Result<UnreadMark, String> {
1675    let conn = super::get_db_connection_guard_static()?;
1676    let (k0, k1, k2) = (
1677        event_kind::CHAT_MESSAGE as i32,
1678        event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1679        event_kind::FILE_ATTACHMENT as i32,
1680    );
1681    // Newest non-mine message second (the target) and newest overall (to detect we spoke last).
1682    let (target_ts, newest_ts): (Option<i64>, Option<i64>) = conn
1683        .query_row(
1684            "SELECT MAX(CASE WHEN e.mine = 0 THEN e.created_at END), MAX(e.created_at) \
1685             FROM events e JOIN chats c ON e.chat_id = c.id \
1686             WHERE c.chat_identifier = ?1 AND e.kind IN (?2, ?3, ?4)",
1687            rusqlite::params![chat_identifier, k0, k1, k2],
1688            |row| Ok((row.get(0)?, row.get(1)?)),
1689        )
1690        .map_err(|e| format!("unread anchor target: {e}"))?;
1691
1692    let target_ts = match target_ts {
1693        Some(t) => t,
1694        None => return Ok(UnreadMark::NoOp), // no contact message to surface
1695    };
1696    if newest_ts.map_or(false, |n| n > target_ts) {
1697        return Ok(UnreadMark::NoOp); // a strictly-newer own message → we spoke last
1698    }
1699
1700    let anchor_id: Option<String> = conn
1701        .query_row(
1702            "SELECT e.id FROM events e JOIN chats c ON e.chat_id = c.id \
1703             WHERE c.chat_identifier = ?1 AND e.kind IN (?2, ?3, ?4) AND e.created_at < ?5 \
1704             ORDER BY e.created_at DESC LIMIT 1",
1705            rusqlite::params![chat_identifier, k0, k1, k2, target_ts],
1706            |row| row.get(0),
1707        )
1708        .optional()
1709        .map_err(|e| format!("unread anchor prev: {e}"))?;
1710
1711    Ok(match anchor_id {
1712        Some(id) => UnreadMark::Anchor(id),
1713        None => UnreadMark::Clear,
1714    })
1715}
1716
1717/// Drain a sync loop's pending message batch into one transaction. The shared flush for the
1718/// segment-flush pattern: bulk loops COLLECT message saves and call this at delete barriers +
1719/// loop end (a batched save committing AFTER a delete it originally preceded would resurrect
1720/// the deleted row — flushing first preserves wire order). Session-guarded HERE so every bulk
1721/// path gets the same swap-safety: on a stale session the batch is DROPPED, never written
1722/// into the next account's DB (the caller's loop is about to bail anyway).
1723pub async fn flush_message_batch(
1724    chat_id: &str,
1725    pending: &mut Vec<&Message>,
1726    session: &crate::state::SessionGuard,
1727) {
1728    if pending.is_empty() {
1729        return;
1730    }
1731    if !session.is_valid() {
1732        pending.clear();
1733        return;
1734    }
1735    if let Err(e) = save_messages_batch(chat_id, pending, Some(session)).await {
1736        crate::log_warn!("[DB] batch flush failed for {}: {}", chat_id, e);
1737    }
1738    pending.clear();
1739}
1740
1741/// Batch save messages for a chat — one transaction for the whole slice.
1742pub async fn save_chat_messages(chat_id: &str, messages: &[Message]) -> Result<(), String> {
1743    if messages.is_empty() {
1744        return Ok(());
1745    }
1746    let refs: Vec<&Message> = messages.iter().collect();
1747    save_messages_batch(chat_id, &refs, None).await.map(|_| ())
1748}
1749
1750#[cfg(test)]
1751mod tests {
1752    use super::*;
1753    use crate::stored_event::SystemEventType;
1754
1755    static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(71000);
1756
1757    fn make_test_npub(n: u32) -> String {
1758        const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
1759        let mut payload = vec![b'q'; 58];
1760        let mut x = n as u64;
1761        let mut i = 58;
1762        while x > 0 && i > 0 {
1763            i -= 1;
1764            payload[i] = BECH32[(x as usize) % 32];
1765            x /= 32;
1766        }
1767        format!("npub1{}", std::str::from_utf8(&payload).unwrap())
1768    }
1769
1770    fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
1771        let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1772        crate::db::close_database();
1773        // Each test rebinds to a fresh per-account DB; the row-id caches are per-account, so a stale
1774        // entry (e.g. a shared author npub) would point into the prior test's DB and FK-fail the insert.
1775        crate::db::clear_id_caches();
1776        let tmp = tempfile::tempdir().unwrap();
1777        let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1778        let account = make_test_npub(n);
1779        std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
1780        crate::db::set_app_data_dir(tmp.path().to_path_buf());
1781        crate::db::set_current_account(account.clone()).unwrap();
1782        crate::db::init_database(&account).unwrap();
1783        (tmp, guard)
1784    }
1785
1786    fn now_secs() -> u64 {
1787        std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
1788    }
1789
1790    /// A page of synced replies must come out of the batch resolver carrying their quotes.
1791    /// The sync/promote paths emit straight to the UI, and the renderer falls back to the
1792    /// in-memory parent only when the parent is inside the rendered window — so a reply to
1793    /// an OLDER message renders with no quote at all unless the backend resolved it here.
1794    /// Reopening the chat re-reads through `get_message_views` (which resolves), which is
1795    /// why the bug looked like "replies lose their context until you reopen".
1796    #[tokio::test]
1797    async fn batch_resolver_fills_quotes_for_a_synced_page() {
1798        let (_tmp, _guard) = init_test_db();
1799        let chat = "channel_reply_batch";
1800        let author = make_test_npub(90_001);
1801
1802        // An older, already-persisted parent (the case with no in-memory fallback).
1803        let mut parent = Message::default();
1804        parent.id = "parent_evt".to_string();
1805        parent.content = "the original message".to_string();
1806        parent.npub = Some(author.clone());
1807        parent.at = now_secs() - 5_000;
1808        save_message(chat, &parent).await.unwrap();
1809
1810        // The freshly-synced page: a reply to it, plus an unrelated message.
1811        let mut reply = Message::default();
1812        reply.id = "reply_evt".to_string();
1813        reply.content = "replying now".to_string();
1814        reply.replied_to = "parent_evt".to_string();
1815        reply.at = now_secs();
1816        let mut plain = Message::default();
1817        plain.id = "plain_evt".to_string();
1818        plain.content = "unrelated".to_string();
1819        plain.at = now_secs();
1820        // A reply whose parent this device has never seen must stay empty, not fabricate a quote.
1821        let mut orphan = Message::default();
1822        orphan.id = "orphan_evt".to_string();
1823        orphan.replied_to = "never_seen_evt".to_string();
1824        orphan.at = now_secs();
1825
1826        populate_reply_contexts(vec![&mut reply, &mut plain, &mut orphan]).await.unwrap();
1827
1828        assert_eq!(
1829            reply.replied_to_content.as_deref(),
1830            Some("the original message"),
1831            "the synced reply carries its parent's content"
1832        );
1833        assert_eq!(reply.replied_to_npub.as_deref(), Some(author.as_str()), "and its parent's author");
1834        assert!(plain.replied_to_content.is_none(), "a non-reply is untouched");
1835        assert!(orphan.replied_to_content.is_none(), "an unresolvable parent leaves the quote empty");
1836    }
1837
1838    /// An empty page (or one with no replies at all) must not query or error — the sync path
1839    /// calls this for every page, most of which carry no replies.
1840    #[tokio::test]
1841    async fn batch_resolver_is_a_noop_without_replies() {
1842        let (_tmp, _guard) = init_test_db();
1843        populate_reply_contexts(vec![]).await.unwrap();
1844        let mut plain = Message::default();
1845        plain.id = "solo".to_string();
1846        populate_reply_contexts(vec![&mut plain]).await.unwrap();
1847        assert!(plain.replied_to_content.is_none());
1848    }
1849
1850    // C-H2: a presence (join/leave) persisted from HISTORY must keep its authenticated timestamp so it
1851    // sorts where it happened, not at ingest-time "now"; a future-dated one is clamped so it can't jump
1852    // ahead of real activity.
1853    #[tokio::test]
1854    async fn system_event_stamps_authenticated_time_clamped_to_now() {
1855        let (_tmp, _guard) = init_test_db();
1856        let chat = "channel_ch2_timestamp";
1857        let before = now_secs();
1858        let past = before - 100_000;
1859
1860        save_system_event_at("ev_past", chat, SystemEventType::MemberJoined, "npubX", None, past, None, None).await.unwrap();
1861        save_system_event_at("ev_future", chat, SystemEventType::MemberJoined, "npubX", None, before + 100_000, None, None).await.unwrap();
1862        let after = now_secs();
1863
1864        let evs = get_system_events_for_chat(chat).unwrap();
1865        let past_ev = evs.iter().find(|e| e.id == "ev_past").expect("past event saved");
1866        assert_eq!(past_ev.created_at, past, "historical join keeps its real (authenticated) timestamp");
1867
1868        let fut_ev = evs.iter().find(|e| e.id == "ev_future").expect("future event saved");
1869        assert!(fut_ev.created_at >= before && fut_ev.created_at <= after,
1870            "future-dated event clamped to local now ({} not in {}..={})", fut_ev.created_at, before, after);
1871    }
1872
1873    // Delete-affordance resolution must work on paged-out rows: the events table is the
1874    // fallback source for (chat, mine, author) when a message isn't STATE-resident.
1875    #[tokio::test]
1876    async fn event_delete_context_resolves_from_db() {
1877        let (_tmp, _guard) = init_test_db();
1878        let chat = "npub1contactdc";
1879
1880        let mine_msg = Message { id: "dc_mine".into(), content: "x".into(), at: 1_000, mine: true, ..Default::default() };
1881        let theirs = Message {
1882            id: "dc_theirs".into(), content: "y".into(), at: 2_000, mine: false,
1883            npub: Some("npub1sender".to_string()),
1884            ..Default::default()
1885        };
1886        save_message(chat, &mine_msg).await.unwrap();
1887        save_message(chat, &theirs).await.unwrap();
1888
1889        let (chat_id, mine, _author) = event_delete_context("dc_mine").unwrap().expect("own row resolves");
1890        assert_eq!(chat_id, chat);
1891        assert!(mine);
1892
1893        let (chat_id, mine, author) = event_delete_context("dc_theirs").unwrap().expect("contact row resolves");
1894        assert_eq!(chat_id, chat);
1895        assert!(!mine);
1896        assert_eq!(author.as_deref(), Some("npub1sender"));
1897
1898        assert!(event_delete_context("dc_absent").unwrap().is_none(), "unknown id is None, not an error");
1899    }
1900
1901    // The reported bug: unread must accumulate across a restart (when only the last message per
1902    // chat is in RAM). The DB cutoff count must mirror the in-memory walk-back exactly.
1903    #[tokio::test]
1904    async fn unread_counts_match_walk_back_semantics() {
1905        let (_tmp, _guard) = init_test_db();
1906        let chat = "npub1contactdm";
1907        // `at` is ms (created_at = at/1000); use distinct seconds.
1908        let mk = |id: &str, secs: u64, mine: bool| Message {
1909            id: id.into(), content: "x".into(), at: secs * 1000, mine,
1910            npub: (!mine).then(|| "npub1sender".to_string()),
1911            ..Default::default()
1912        };
1913        let unread = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
1914
1915        // 6 contact messages, never opened/read, no own reply → all 6 unread.
1916        for i in 0..6u64 {
1917            save_message(chat, &mk(&format!("m{i}"), 1000 + i, false)).await.unwrap();
1918        }
1919        assert_eq!(unread().await, 6, "never-read backlog counts all 6");
1920
1921        // 2 more arrive → 8, NOT replaced by 2 (the exact reported symptom).
1922        save_message(chat, &mk("m6", 2000, false)).await.unwrap();
1923        save_message(chat, &mk("m7", 2001, false)).await.unwrap();
1924        assert_eq!(unread().await, 8, "6 backlog + 2 new = 8");
1925
1926        // Our own reply clears it (walk-back stops at the newest mine).
1927        save_message(chat, &mk("mine", 2002, true)).await.unwrap();
1928        assert_eq!(unread().await, 0, "own message = read up to here");
1929
1930        // A contact message after our send is unread again.
1931        save_message(chat, &mk("m8", 2003, false)).await.unwrap();
1932        assert_eq!(unread().await, 1, "one new after our send");
1933
1934        // last_read marker advances the cutoff just like an own message.
1935        {
1936            let conn = crate::db::get_write_connection_guard_static().unwrap();
1937            conn.execute(
1938                "UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
1939                rusqlite::params!["m8", chat],
1940            ).unwrap();
1941        }
1942        assert_eq!(unread().await, 0, "last_read=m8 clears all");
1943        save_message(chat, &mk("m9", 2004, false)).await.unwrap();
1944        assert_eq!(unread().await, 1, "one arrival after last_read");
1945    }
1946
1947    // The single-chat reconcile query must agree with the full map for every state the cache
1948    // reconciles from (never-read, own-reply cutoff, last_read marker).
1949    #[tokio::test]
1950    async fn unread_count_for_chat_matches_the_map() {
1951        let (_tmp, _guard) = init_test_db();
1952        let chat = "npub1reconcile";
1953        let mk = |id: &str, secs: u64, mine: bool| Message {
1954            id: id.into(), content: "x".into(), at: secs * 1000, mine,
1955            npub: (!mine).then(|| "npub1sender".to_string()),
1956            ..Default::default()
1957        };
1958        let agree = || async {
1959            let map = unread_counts().await.unwrap().get(chat).copied().unwrap_or(0);
1960            let one = unread_count_for_chat(chat).await.unwrap();
1961            assert_eq!(map, one, "single-chat query diverged from the map");
1962            one
1963        };
1964
1965        for i in 0..4u64 { save_message(chat, &mk(&format!("m{i}"), 1000 + i, false)).await.unwrap(); }
1966        assert_eq!(agree().await, 4);
1967        save_message(chat, &mk("mine", 1010, true)).await.unwrap();
1968        assert_eq!(agree().await, 0);
1969        save_message(chat, &mk("after", 1011, false)).await.unwrap();
1970        assert_eq!(agree().await, 1);
1971        {
1972            let conn = crate::db::get_write_connection_guard_static().unwrap();
1973            conn.execute("UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
1974                rusqlite::params!["after", chat]).unwrap();
1975        }
1976        assert_eq!(agree().await, 0);
1977        // A chat with nothing at all reconciles to 0 (no row in the map).
1978        assert_eq!(unread_count_for_chat("npub1nonexistent").await.unwrap(), 0);
1979    }
1980
1981    #[tokio::test]
1982    async fn attachments_table_round_trip_dedup_and_cascade() {
1983        let (_tmp, _guard) = init_test_db();
1984        // downloaded:false mirrors a freshly-received attachment (Attachment::default is downloaded:true).
1985        let att = |id: &str, name: &str| Attachment {
1986            id: id.into(), url: format!("https://blossom/{id}"), name: name.into(),
1987            extension: "png".into(), size: 42, downloaded: false, ..Default::default()
1988        };
1989        let msg = |mid: &str, secs: u64, atts: Vec<Attachment>| Message {
1990            id: mid.into(), content: String::new(), at: secs * 1000, mine: false,
1991            npub: Some("npub1sender".into()), attachments: atts, ..Default::default()
1992        };
1993
1994        // Save a message with two attachments → both rows, order preserved by att_index.
1995        save_message("npub1att", &msg("m1", 1000, vec![att("hashA", "a.png"), att("hashB", "b.png")])).await.unwrap();
1996        let got = crate::db::attachments::get_attachments_for_event("m1").unwrap();
1997        assert_eq!(got.len(), 2);
1998        assert_eq!((got[0].id.as_str(), got[1].id.as_str()), ("hashA", "hashB"), "att_index order");
1999        assert_eq!(got[0].name, "a.png");
2000        assert_eq!(got[0].size, 42);
2001        assert!(!got[0].downloaded);
2002
2003        // Single-row download flip (no read-modify-write of a blob).
2004        crate::db::attachments::set_attachment_downloaded("m1", "hashA", true, "/tmp/a.png").unwrap();
2005        let got = crate::db::attachments::get_attachments_for_event("m1").unwrap();
2006        assert!(got[0].downloaded && got[0].path == "/tmp/a.png");
2007        assert!(!got[1].downloaded, "sibling attachment untouched");
2008
2009        // Dedup: a second message shares hashA → backfill-by-hash marks it (indexed, no LIKE scan).
2010        save_message("npub1att", &msg("m2", 1001, vec![att("hashA", "a-again.png")])).await.unwrap();
2011        let affected = crate::db::attachments::backfill_downloaded_by_hash("hashA", "/tmp/a.png", "m1").unwrap();
2012        assert_eq!(affected, vec!["m2".to_string()]);
2013        assert!(crate::db::attachments::get_attachments_for_event("m2").unwrap()[0].downloaded);
2014
2015        // The write funnel dual-populates the legacy tag, so get_message_views (still tag-backed
2016        // during shadow-populate) composes the attachments onto the Message.
2017        let chat_int = crate::db::id_cache::get_or_create_chat_id("npub1att").unwrap();
2018        let views = get_message_views(chat_int, 10, 0).await.unwrap();
2019        let m1 = views.iter().find(|m| m.id == "m1").unwrap();
2020        assert_eq!(m1.attachments.len(), 2);
2021
2022        // Cascade: deleting the event removes its attachment rows.
2023        delete_event("m1").await.unwrap();
2024        assert!(crate::db::attachments::get_attachments_for_event("m1").unwrap().is_empty(), "ON DELETE CASCADE");
2025    }
2026
2027    // The download persist path: a re-save must never DOWNGRADE download state (relay re-delivery),
2028    // but a re-save carrying a completed download (+ the nonce→content-hash id rewrite) must persist.
2029    #[tokio::test]
2030    async fn attachment_download_state_is_monotonic_across_resaves() {
2031        let (_tmp, _guard) = init_test_db();
2032        let att = |id: &str, downloaded: bool, path: &str| Attachment {
2033            id: id.into(), url: "u".into(), name: "f.png".into(), extension: "png".into(),
2034            size: 1, downloaded, path: path.into(), ..Default::default()
2035        };
2036        let msg = |atts: Vec<Attachment>| Message {
2037            id: "dl1".into(), content: String::new(), at: 1_000_000, mine: false,
2038            npub: Some("npub1s".into()), attachments: atts, ..Default::default()
2039        };
2040
2041        // Receive (not downloaded), then the user downloads → single-row flip.
2042        save_message("npub1dl", &msg(vec![att("nonceid", false, "")])).await.unwrap();
2043        crate::db::attachments::set_attachment_downloaded("dl1", "nonceid", true, "/tmp/f.png").unwrap();
2044
2045        // Relay re-delivery (downloaded=false) must NOT reset the download.
2046        save_message("npub1dl", &msg(vec![att("nonceid", false, "")])).await.unwrap();
2047        let got = crate::db::attachments::get_attachments_for_event("dl1").unwrap();
2048        assert!(got[0].downloaded && got[0].path == "/tmp/f.png", "re-delivery preserves the download");
2049
2050        // Post-download re-save: id rewritten nonce→content-hash, downloaded=true persists in one pass.
2051        save_message("npub1dl", &msg(vec![att("contenthash", true, "/tmp/f.png")])).await.unwrap();
2052        let got = crate::db::attachments::get_attachments_for_event("dl1").unwrap();
2053        assert_eq!(got[0].id, "contenthash", "hash rewritten nonce→content");
2054        assert!(got[0].downloaded && got[0].path == "/tmp/f.png");
2055
2056        // A re-delivery AFTER the rewrite must keep the content-hash key (not revert to the nonce),
2057        // so the hash-keyed download/backfill/clear helpers still resolve the row.
2058        save_message("npub1dl", &msg(vec![att("nonceid", false, "")])).await.unwrap();
2059        let got = crate::db::attachments::get_attachments_for_event("dl1").unwrap();
2060        assert_eq!(got[0].id, "contenthash", "content-hash key survives a later re-delivery");
2061        assert!(got[0].downloaded && got[0].path == "/tmp/f.png");
2062    }
2063
2064    // An un-backfilled pre-migration event (attachments only in the legacy tag, no table row) still
2065    // renders via the read fallback.
2066    #[tokio::test]
2067    async fn attachments_fall_back_to_legacy_tag_when_table_empty() {
2068        let (_tmp, _guard) = init_test_db();
2069        let a = Attachment {
2070            id: "tagonly".into(), url: "u".into(), name: "old.png".into(), extension: "png".into(),
2071            size: 7, downloaded: false, ..Default::default()
2072        };
2073        save_message("npub1old", &Message {
2074            id: "old1".into(), content: String::new(), at: 2_000_000, mine: false,
2075            npub: Some("npub1s".into()), attachments: vec![a.clone()], ..Default::default()
2076        }).await.unwrap();
2077
2078        // Simulate the pre-migration shape: drop the table rows, write the legacy tag onto the event.
2079        {
2080            let conn = crate::db::get_write_connection_guard_static().unwrap();
2081            conn.execute("DELETE FROM attachments WHERE event_id='old1'", []).unwrap();
2082            let inner = serde_json::to_string(&vec![a]).unwrap();
2083            let tags = serde_json::to_string(&vec![vec!["attachments".to_string(), inner]]).unwrap();
2084            conn.execute("UPDATE events SET tags=?1 WHERE id='old1'", rusqlite::params![tags]).unwrap();
2085        }
2086        assert!(crate::db::attachments::get_attachments_for_event("old1").unwrap().is_empty(), "table empty");
2087
2088        let chat_int = crate::db::id_cache::get_or_create_chat_id("npub1old").unwrap();
2089        let views = get_message_views(chat_int, 10, 0).await.unwrap();
2090        let old = views.iter().find(|m| m.id == "old1").unwrap();
2091        assert_eq!(old.attachments.len(), 1, "attachment served from the legacy-tag fallback");
2092        assert_eq!(old.attachments[0].name, "old.png");
2093    }
2094
2095    // Migration 75's strip logic: remove the legacy attachments tag ONLY from events that are
2096    // provably backfilled (have a table row); un-backfilled events keep their tag as the fallback.
2097    #[tokio::test]
2098    async fn attachment_tag_strip_is_gated_on_backfill() {
2099        let (_tmp, _guard) = init_test_db();
2100        let a = Attachment { id: "h1".into(), extension: "png".into(), name: "f.png".into(), downloaded: false, ..Default::default() };
2101        let mk = |id: &str, secs: u64| Message {
2102            id: id.into(), content: String::new(), at: secs * 1000, mine: false,
2103            npub: Some("npub1s".into()), attachments: vec![a.clone()], ..Default::default()
2104        };
2105        save_message("npub1s", &mk("bf", 1000)).await.unwrap();
2106        save_message("npub1s", &mk("unbf", 2000)).await.unwrap();
2107
2108        {
2109            let conn = crate::db::get_write_connection_guard_static().unwrap();
2110            let inner = serde_json::to_string(&vec![a.clone()]).unwrap();
2111            let with_tag = |ms: &str| serde_json::to_string(&vec![
2112                vec!["ms".to_string(), ms.to_string()],
2113                vec!["attachments".to_string(), inner.clone()],
2114            ]).unwrap();
2115            // Both events carry a legacy tag; only `unbf` loses its table rows (un-backfilled).
2116            conn.execute("UPDATE events SET tags=?1 WHERE id='bf'", rusqlite::params![with_tag("5")]).unwrap();
2117            conn.execute("UPDATE events SET tags=?1 WHERE id='unbf'", rusqlite::params![with_tag("6")]).unwrap();
2118            conn.execute("DELETE FROM attachments WHERE event_id='unbf'", []).unwrap();
2119
2120            // Replicate migration 75's gated strip.
2121            let events: Vec<(String, String)> = {
2122                let mut stmt = conn.prepare("SELECT id, tags FROM events WHERE tags LIKE '%attachments%' AND id IN (SELECT DISTINCT event_id FROM attachments)").unwrap();
2123                let m = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))).unwrap();
2124                m.flatten().collect()
2125            };
2126            for (id, tj) in events {
2127                let mut tags: Vec<Vec<String>> = serde_json::from_str(&tj).unwrap();
2128                tags.retain(|t| t.first().map(|s| s.as_str()) != Some("attachments"));
2129                conn.execute("UPDATE events SET tags=?1 WHERE id=?2",
2130                    rusqlite::params![serde_json::to_string(&tags).unwrap(), id]).unwrap();
2131            }
2132
2133            let bf: String = conn.query_row("SELECT tags FROM events WHERE id='bf'", [], |r| r.get(0)).unwrap();
2134            assert!(!bf.contains("attachments"), "backfilled event's attachments tag stripped");
2135            assert!(bf.contains("\"ms\""), "sibling ms tag survives the strip");
2136            let unbf: String = conn.query_row("SELECT tags FROM events WHERE id='unbf'", [], |r| r.get(0)).unwrap();
2137            assert!(unbf.contains("attachments"), "un-backfilled event keeps its tag (no table row)");
2138        }
2139
2140        // The un-backfilled event still renders via the read fallback after the strip.
2141        let chat_int = crate::db::id_cache::get_or_create_chat_id("npub1s").unwrap();
2142        let views = get_message_views(chat_int, 10, 0).await.unwrap();
2143        assert_eq!(views.iter().find(|m| m.id == "unbf").unwrap().attachments.len(), 1, "fallback still renders unbf");
2144    }
2145
2146    // Mark-as-unread anchors from the FULL DB history (a community row often holds only a preview
2147    // message in RAM). The anchor lands strictly before the target's second so the count query's
2148    // strict `>` still counts the newest contact message. Covers the community repro + edge cases.
2149    #[tokio::test]
2150    async fn compute_unread_anchor_covers_the_cases() {
2151        let (_tmp, _guard) = init_test_db();
2152        let mk = |id: &str, secs: u64, mine: bool| Message {
2153            id: id.into(), content: "x".into(), at: secs * 1000, mine,
2154            npub: (!mine).then(|| "npub1sender".to_string()),
2155            ..Default::default()
2156        };
2157        let unread = |chat: &'static str| async move {
2158            unread_counts().await.unwrap().get(chat).copied().unwrap_or(0)
2159        };
2160        let set_lr = |chat: &str, lr: &str| {
2161            let conn = crate::db::get_write_connection_guard_static().unwrap();
2162            conn.execute("UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2163                rusqlite::params![lr, chat]).unwrap();
2164        };
2165
2166        // (A) The community repro: we spoke long ago, they kept talking. Anchor = second-newest
2167        // contact message → exactly one unread, whatever the RAM cache held.
2168        let a = "npub1anchorA";
2169        save_message(a, &mk("a_mine", 1000, true)).await.unwrap();
2170        for i in 0..8u64 { save_message(a, &mk(&format!("a{i}"), 2000 + i, false)).await.unwrap(); }
2171        assert_eq!(compute_unread_anchor(a).await.unwrap(), UnreadMark::Anchor("a6".into()));
2172        set_lr(a, "a6");
2173        assert_eq!(unread(a).await, 1, "A: newest contact message is the sole unread");
2174
2175        // (B) We spoke last → NoOp (no phantom badge, no snap-back jiggle).
2176        let b = "npub1anchorB";
2177        save_message(b, &mk("b0", 2000, false)).await.unwrap();
2178        save_message(b, &mk("b_mine", 2001, true)).await.unwrap();
2179        assert_eq!(compute_unread_anchor(b).await.unwrap(), UnreadMark::NoOp);
2180
2181        // (C) Same-second tail: the two newest share a second. The anchor must skip to a strictly
2182        // earlier second, so both same-second messages surface instead of snapping back to read.
2183        let c = "npub1anchorC";
2184        save_message(c, &mk("c0", 3000, false)).await.unwrap();
2185        save_message(c, &mk("c1", 3005, false)).await.unwrap();
2186        save_message(c, &mk("c2", 3005, false)).await.unwrap();
2187        assert_eq!(compute_unread_anchor(c).await.unwrap(), UnreadMark::Anchor("c0".into()));
2188        set_lr(c, "c0");
2189        assert_eq!(unread(c).await, 2, "C: same-second tail both count");
2190
2191        // (D) The newest contact message is the chat's first → Clear (never-read) → it still counts.
2192        let d = "npub1anchorD";
2193        save_message(d, &mk("d0", 4000, false)).await.unwrap();
2194        assert_eq!(compute_unread_anchor(d).await.unwrap(), UnreadMark::Clear);
2195        set_lr(d, "");
2196        assert_eq!(unread(d).await, 1, "D: lone contact message surfaces");
2197
2198        // (E) No contact message at all (only our own) → NoOp.
2199        let e = "npub1anchorE";
2200        save_message(e, &mk("e_mine", 5000, true)).await.unwrap();
2201        assert_eq!(compute_unread_anchor(e).await.unwrap(), UnreadMark::NoOp);
2202    }
2203
2204    // Regression: a "read to here" marker that lands on a system event (kind 30078, not a counted
2205    // kind, e.g. the windowed jump-reveal path marking off the raw tail) must still clear unread.
2206    // The anchor keys off the marker row's time whatever its kind, so it can't wedge at 99+.
2207    #[tokio::test]
2208    async fn unread_clears_when_last_read_is_a_system_event() {
2209        let (_tmp, _guard) = init_test_db();
2210        let chat = "npub1sysevtdm";
2211        let mk = |id: &str, secs: u64| Message {
2212            id: id.into(), content: "x".into(), at: secs * 1000, mine: false,
2213            npub: Some("npub1sender".to_string()), ..Default::default()
2214        };
2215        let unread = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
2216
2217        // 5 contact messages, never read → all unread. No own message, so the ONLY viable anchor
2218        // is last_read (this is the case that used to stick at a permanent count).
2219        for i in 0..5u64 {
2220            save_message(chat, &mk(&format!("m{i}"), 1000 + i)).await.unwrap();
2221        }
2222        assert_eq!(unread().await, 5, "never-read backlog");
2223
2224        // A system event is the newest row (a join notification, after every contact message).
2225        save_system_event_at("sysev", chat, SystemEventType::MemberJoined, "npubX", None, 2000, None, None).await.unwrap();
2226
2227        // last_read pinned to that system event (kind 30078) — the reported bad state.
2228        {
2229            let conn = crate::db::get_write_connection_guard_static().unwrap();
2230            conn.execute(
2231                "UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2232                rusqlite::params!["sysev", chat],
2233            ).unwrap();
2234        }
2235        assert_eq!(unread().await, 0, "read marker on a system event still clears the badge");
2236    }
2237
2238    // Deleting a message adjusts unread correctly: removing an UNREAD message drops the count by
2239    // one, removing the read MARKER retreats it to the prior message (never collapses to 99+), and
2240    // removing the last read message clears the marker without over-counting.
2241    #[tokio::test]
2242    async fn deleting_a_message_adjusts_unread_without_wedging() {
2243        let (_tmp, _guard) = init_test_db();
2244        let chat = "npub1delunread";
2245        let mk = |id: &str, secs: u64| Message {
2246            id: id.into(), content: "x".into(), at: secs * 1000, mine: false,
2247            npub: Some("npub1sender".to_string()), ..Default::default()
2248        };
2249        let unread = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
2250        let set_marker = |id: &str| {
2251            let conn = crate::db::get_write_connection_guard_static().unwrap();
2252            conn.execute("UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2253                rusqlite::params![id, chat]).unwrap();
2254        };
2255        let marker = || -> String {
2256            let conn = crate::db::get_db_connection_guard_static().unwrap();
2257            conn.query_row("SELECT last_read FROM chats WHERE chat_identifier = ?1",
2258                rusqlite::params![chat], |r| r.get::<_, String>(0)).unwrap()
2259        };
2260
2261        // m0..m5, read up to m1 → m2,m3,m4,m5 unread.
2262        for i in 0..6u64 { save_message(chat, &mk(&format!("m{i}"), 1000 + i)).await.unwrap(); }
2263        set_marker("m1");
2264        assert_eq!(unread().await, 4, "m2..m5 unread");
2265
2266        // Delete an UNREAD mid-block message → badge drops by one, marker untouched.
2267        delete_event("m3").await.unwrap();
2268        assert_eq!(unread().await, 3, "one unread deleted → badge minus one");
2269        assert_eq!(marker(), "m1", "deleting an unread message leaves the marker alone");
2270
2271        // Delete the read MARKER → retreats to the prior surviving message (m0), unread unchanged.
2272        delete_event("m1").await.unwrap();
2273        assert_eq!(marker(), "m0", "marker retreats to the newest survivor before it");
2274        assert_eq!(unread().await, 3, "retreat keeps the count, no collapse to 99+");
2275
2276        // Delete the last surviving read message → marker clears, still exactly the unread block.
2277        delete_event("m0").await.unwrap();
2278        assert_eq!(marker(), "", "no earlier survivor → marker clears");
2279        assert_eq!(unread().await, 3, "cleared marker counts only the true unread survivors");
2280    }
2281
2282    // Edits are event-sourced for BOTH transports: a MESSAGE_EDIT event folds into the target's
2283    // history on reload (latest content + revisions + the edit's own emoji). Community used to
2284    // overwrite the row and lose all of this — this locks in the unified fold.
2285    #[tokio::test]
2286    async fn edit_event_folds_into_history_on_reload() {
2287        let (_tmp, _guard) = init_test_db();
2288        let chat = "channel_edit_fold";
2289        save_message(chat, &Message {
2290            id: "orig1".into(), content: "original".into(), at: 5_000_000,
2291            npub: Some("npub1author".into()), ..Default::default()
2292        }).await.unwrap();
2293
2294        let cid = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2295        let emoji = vec![crate::types::EmojiTag { shortcode: "wave".into(), url: "u/wave".into() }];
2296        save_edit_event("edit1", "orig1", "edited :wave:", &emoji, cid, None, "npub1author").await.unwrap();
2297
2298        let m = get_message_views(cid, 50, 0).await.unwrap()
2299            .into_iter().find(|m| m.id == "orig1").expect("message reloaded");
2300        assert!(m.edited, "folded edit sets the edited flag");
2301        let h = m.edit_history.as_ref().expect("history reconstructed from the edit event");
2302        assert_eq!(h.len(), 2, "original + one edit");
2303        assert_eq!(h[0].content, "original");
2304        assert_eq!(h[1].content, "edited :wave:");
2305        assert_eq!(m.content, "edited :wave:", "latest revision is the displayed content");
2306        assert_eq!(m.emoji_tags.len(), 1, "the edit's own emoji folds onto the message");
2307        assert_eq!(m.emoji_tags[0].shortcode, "wave");
2308    }
2309
2310    // The bulk-sync persist path: one transaction must land events + attachments + reactions
2311    // with the same shape save_message produces, and slice order must set rowid order (the
2312    // same-timestamp pagination tiebreak).
2313    #[tokio::test]
2314    async fn batched_save_matches_single_save_shape() {
2315        let (_tmp, _guard) = init_test_db();
2316        let chat = "channel_batch1";
2317        let att = crate::types::Attachment {
2318            id: "atthash1".into(), extension: "png".into(), name: "a.png".into(),
2319            url: "https://x/att".into(), downloaded: false, ..Default::default()
2320        };
2321        let reaction = Reaction {
2322            id: "react_b1".into(), reference_id: "b1".into(),
2323            author_id: "npub1reactor".into(), emoji: "👍".into(), emoji_url: None,
2324        };
2325        // Same `at` second across the batch — rowid is the only orderer.
2326        let msgs: Vec<Message> = (0..5u64).map(|i| Message {
2327            id: format!("b{i}"), content: format!("c{i}"), at: 7_000_000,
2328            npub: Some("npub1sender".into()),
2329            attachments: if i == 2 { vec![att.clone()] } else { Vec::new() },
2330            reactions: if i == 1 { vec![reaction.clone()] } else { Vec::new() },
2331            ..Default::default()
2332        }).collect();
2333        let refs: Vec<&Message> = msgs.iter().collect();
2334
2335        let saved = save_messages_batch(chat, &refs, None).await.unwrap();
2336        assert_eq!(saved, 5, "every message written");
2337
2338        for i in 0..5u64 {
2339            assert!(event_exists(&format!("b{i}")).unwrap(), "b{i} row exists");
2340        }
2341        assert!(event_exists("react_b1").unwrap(), "reaction landed as its own kind-7 row");
2342        let atts = crate::db::attachments::get_attachments_for_event("b2").unwrap();
2343        assert_eq!(atts.len(), 1, "attachment row committed with its event");
2344        assert_eq!(atts[0].id, "atthash1");
2345
2346        // rowid order == slice order despite identical timestamps.
2347        let conn = crate::db::get_db_connection_guard_static().unwrap();
2348        let ids: Vec<String> = conn
2349            .prepare("SELECT id FROM events WHERE id IN ('b0','b1','b2','b3','b4') ORDER BY rowid")
2350            .unwrap()
2351            .query_map([], |r| r.get(0)).unwrap()
2352            .flatten().collect();
2353        assert_eq!(ids, vec!["b0", "b1", "b2", "b3", "b4"], "insert order preserves the rowid tiebreak");
2354    }
2355
2356    // A batched re-save must keep save_message's upsert semantics: wrapper_event_id is
2357    // COALESCE-preserved, and a duplicate reaction in the batch never doubles its row.
2358    #[tokio::test]
2359    async fn batched_resave_preserves_wrapper_and_dedups_reactions() {
2360        let (_tmp, _guard) = init_test_db();
2361        let chat = "channel_batch2";
2362        let reaction = Reaction {
2363            id: "react_rs".into(), reference_id: "rs1".into(),
2364            author_id: "npub1reactor".into(), emoji: "🔥".into(), emoji_url: None,
2365        };
2366        let mut msg = Message {
2367            id: "rs1".into(), content: "hello".into(), at: 8_000_000,
2368            npub: Some("npub1sender".into()),
2369            wrapper_event_id: Some("wrap_original".into()),
2370            reactions: vec![reaction],
2371            ..Default::default()
2372        };
2373        save_message(chat, &msg).await.unwrap();
2374
2375        // Re-delivery re-save without a wrapper id, reaction still attached.
2376        msg.wrapper_event_id = None;
2377        let saved = save_messages_batch(chat, &[&msg], None).await.unwrap();
2378        assert_eq!(saved, 1);
2379
2380        let conn = crate::db::get_db_connection_guard_static().unwrap();
2381        let wrapper: Option<String> = conn.query_row(
2382            "SELECT wrapper_event_id FROM events WHERE id = 'rs1'", [], |r| r.get(0),
2383        ).unwrap();
2384        assert_eq!(wrapper.as_deref(), Some("wrap_original"), "COALESCE keeps the known wrapper");
2385        let reaction_rows: i64 = conn.query_row(
2386            "SELECT COUNT(*) FROM events WHERE id = 'react_rs'", [], |r| r.get(0),
2387        ).unwrap();
2388        assert_eq!(reaction_rows, 1, "reaction row not duplicated by the re-save");
2389    }
2390
2391    // The DM stream's multi-chat flush: one call, one transaction, rows land under their own
2392    // chats with their gift-wrap ledger entries; a flush against a stale session drops the
2393    // buffer AND leaves the wrappers unledgered (that's what makes the drop recoverable).
2394    #[tokio::test]
2395    async fn batching_persist_flushes_multi_chat_and_drops_on_stale_session() {
2396        let (_tmp, _guard) = init_test_db();
2397        let handler = crate::event_handler::NoOpEventHandler;
2398        let batcher = crate::event_handler::BatchingPersist::new(&handler);
2399
2400        let mk = |id: &str, npub: &str| Message {
2401            id: id.into(), content: "x".into(), at: 9_000_000,
2402            npub: Some(npub.into()), ..Default::default()
2403        };
2404        // Mirror the commit path: buffering only ever happens AFTER the STATE add (the
2405        // flush drops anything not STATE-resident as deletion protection).
2406        let seed = |chat: &str, m: &Message| {
2407            let m = m.clone();
2408            let chat = chat.to_string();
2409            async move {
2410                let mut st = crate::state::STATE.lock().await;
2411                st.add_message_to_participant(&chat, &m);
2412            }
2413        };
2414        let wrap_a1 = ([0xA1u8; 32], 111u64);
2415        let wrap_b1 = ([0xB1u8; 32], 222u64);
2416        let a1 = mk("bp_a1", "npub1chata");
2417        let b1 = mk("bp_b1", "npub1chatb");
2418        let a2 = mk("bp_a2", "npub1chata");
2419        seed("npub1chata", &a1).await;
2420        seed("npub1chatb", &b1).await;
2421        seed("npub1chata", &a2).await;
2422
2423        use crate::event_handler::InboundEventHandler;
2424        assert!(batcher.buffer_persist("npub1chata", &a1, Some(wrap_a1)), "batcher owns the persist");
2425        assert!(batcher.buffer_persist("npub1chatb", &b1, Some(wrap_b1)));
2426        assert!(batcher.buffer_persist("npub1chata", &a2, None));
2427        assert_eq!(batcher.buffered(), 3);
2428
2429        let ledgered = |bytes: [u8; 32]| {
2430            let id = nostr_sdk::prelude::EventId::from_byte_array(bytes);
2431            crate::db::wrappers::load_negentropy_items().unwrap().iter().any(|(e, _)| *e == id)
2432        };
2433        assert!(!ledgered(wrap_a1.0), "wrapper unledgered while its message sits buffered");
2434
2435        let session = crate::state::SessionGuard::capture();
2436        assert_eq!(batcher.flush(&session).await, 3, "all buffered messages written");
2437        assert_eq!(batcher.buffered(), 0);
2438        assert!(event_exists("bp_a1").unwrap() && event_exists("bp_b1").unwrap() && event_exists("bp_a2").unwrap());
2439        assert!(ledgered(wrap_a1.0) && ledgered(wrap_b1.0), "wrappers ledgered with the flush");
2440        let a = crate::db::id_cache::get_chat_id_by_identifier("npub1chata").unwrap();
2441        let b = crate::db::id_cache::get_chat_id_by_identifier("npub1chatb").unwrap();
2442        assert_ne!(a, b, "rows grouped under their own chats");
2443
2444        // Stale session: buffered messages are dropped, never written — and the wrapper
2445        // stays out of the negentropy fingerprint set, so the message re-delivers.
2446        let stale = mk("bp_stale", "npub1chata");
2447        seed("npub1chata", &stale).await;
2448        let wrap_stale = ([0x5Eu8; 32], 333u64);
2449        batcher.buffer_persist("npub1chata", &stale, Some(wrap_stale));
2450        crate::state::bump_session_generation();
2451        assert_eq!(batcher.flush(&session).await, 0, "stale flush writes nothing");
2452        assert!(!event_exists("bp_stale").unwrap(), "stale message never reached the DB");
2453        assert!(!ledgered(wrap_stale.0), "dropped message's wrapper NOT ledgered — negentropy will re-deliver it");
2454        assert_eq!(batcher.buffered(), 0, "stale buffer drained, not retried into the next account");
2455    }
2456
2457    // A deletion landing while its target sits buffered must not resurrect the message:
2458    // the same-task path purges via on_message_deleted, and the cross-task path (live
2459    // subscription, different handler) is caught by the flush's deletion-tombstone filter.
2460    // The filter keys on the POSITIVE tombstone, never STATE absence — an LRU-evicted (but
2461    // not deleted) message MUST still persist and ledger, or archive sync could silently
2462    // drop the exact history it exists to persist.
2463    #[tokio::test]
2464    async fn buffered_message_deleted_before_flush_never_persists() {
2465        let (_tmp, _guard) = init_test_db();
2466        let handler = crate::event_handler::NoOpEventHandler;
2467        let batcher = crate::event_handler::BatchingPersist::new(&handler);
2468        use crate::event_handler::InboundEventHandler;
2469
2470        let chat = "npub1delchat";
2471        let mk = |id: &str| Message {
2472            id: id.into(), content: "x".into(), at: 9_500_000,
2473            npub: Some(chat.into()), ..Default::default()
2474        };
2475        let ledgered = |bytes: [u8; 32]| {
2476            let id = nostr_sdk::prelude::EventId::from_byte_array(bytes);
2477            crate::db::wrappers::load_negentropy_items().unwrap().iter().any(|(e, _)| *e == id)
2478        };
2479
2480        // Same-task deletion (sync stream): commit_deletion fires on_message_deleted on
2481        // the batching handler → the buffered entry purges immediately.
2482        let m1 = mk("del_sametask");
2483        {
2484            let mut st = crate::state::STATE.lock().await;
2485            st.add_message_to_participant(chat, &m1);
2486        }
2487        batcher.buffer_persist(chat, &m1, Some(([0xD1u8; 32], 444)));
2488        {
2489            let mut st = crate::state::STATE.lock().await;
2490            st.remove_message("del_sametask");
2491        }
2492        batcher.on_message_deleted(chat, "del_sametask");
2493        assert_eq!(batcher.buffered(), 0, "deletion purges the buffered target");
2494
2495        // Cross-task deletion (live subscription, plain handler): no purge call — the
2496        // deletion tombstone (recorded by commit_deletion) drops it at flush time.
2497        let m2 = mk("del_crosstask");
2498        {
2499            let mut st = crate::state::STATE.lock().await;
2500            st.add_message_to_participant(chat, &m2);
2501        }
2502        batcher.buffer_persist(chat, &m2, Some(([0xD2u8; 32], 555)));
2503        crate::state::note_message_deleted("del_crosstask");
2504
2505        // LRU eviction is NOT deletion: gone from STATE, no tombstone → must persist.
2506        let m3 = mk("evicted_ok");
2507        let wrap_evicted = ([0xE0u8; 32], 666u64);
2508        {
2509            let mut st = crate::state::STATE.lock().await;
2510            st.add_message_to_participant(chat, &m3);
2511            st.remove_message("evicted_ok");
2512        }
2513        batcher.buffer_persist(chat, &m3, Some(wrap_evicted));
2514
2515        let session = crate::state::SessionGuard::capture();
2516        assert_eq!(batcher.flush(&session).await, 1, "tombstoned target dropped, evicted message written");
2517        assert!(!event_exists("del_sametask").unwrap(), "purged message never persisted");
2518        assert!(!event_exists("del_crosstask").unwrap(), "tombstoned message never persisted");
2519        assert!(event_exists("evicted_ok").unwrap(), "evicted-but-not-deleted message persisted");
2520        assert!(ledgered(wrap_evicted.0), "evicted message's wrapper ledgered with it");
2521    }
2522}