Skip to main content

vector_core/db/
wrappers.rs

1//! Wrapper tracking — NIP-59 gift wrap dedup + NIP-77 negentropy.
2
3use nostr_sdk::prelude::{EventId, Timestamp};
4
5/// Transport carriers for the shared outer-event ledger — stored as a small INTEGER discriminator
6/// (cheaper than a per-row string, and the ledger can grow large). Never renumber an existing value.
7pub const TRANSPORT_NIP17: i64 = 0;
8pub const TRANSPORT_CONCORD: i64 = 1;
9
10/// Persist an outer-event id for cross-session dedup (INSERT OR IGNORE), tagged by `transport`
11/// so the ledger is shared across transports while negentropy stays NIP-17-scoped.
12pub fn save_processed_wrapper(wrapper_id_bytes: &[u8; 32], wrapper_created_at: u64, transport: i64) -> Result<(), String> {
13    let conn = super::get_write_connection_guard_static()?;
14    conn.execute(
15        "INSERT OR IGNORE INTO processed_wrappers (wrapper_id, wrapper_created_at, transport) VALUES (?1, ?2, ?3)",
16        rusqlite::params![&wrapper_id_bytes[..], wrapper_created_at as i64, transport],
17    ).map_err(|e| format!("Failed to save processed wrapper: {}", e))?;
18    Ok(())
19}
20
21/// Sync existence check against the ledger (any transport) — the DB half of the outer-event dedup
22/// for callers that can't reach the async `WRAPPER_ID_CACHE` (e.g. the synchronous Concord ingest).
23/// Returns false on a missing/closed DB so a dedup failure never drops a genuinely-new event.
24pub fn processed_wrapper_exists(wrapper_id_bytes: &[u8; 32]) -> bool {
25    let conn = match super::get_db_connection_guard_static() {
26        Ok(c) => c,
27        Err(_) => return false,
28    };
29    conn.prepare_cached("SELECT EXISTS(SELECT 1 FROM processed_wrappers WHERE wrapper_id = ?1)")
30        .and_then(|mut stmt| stmt.query_row(rusqlite::params![&wrapper_id_bytes[..]], |row| row.get(0)))
31        .unwrap_or(false)
32}
33
34/// Backfill a wrapper timestamp onto an EXISTING ledger row (pre-migration-17 rows hold 0).
35/// UPDATE-only by design: the ledger is the negentropy fingerprint set, and message wrappers
36/// may be deferred (batch-buffered) — an INSERT here could ledger a message before its row
37/// lands, marking it "have" forever. Inserting belongs to the save paths, never this backfill.
38pub fn update_wrapper_timestamp(wrapper_id_bytes: &[u8; 32], wrapper_created_at: u64) -> Result<(), String> {
39    let conn = super::get_write_connection_guard_static()?;
40    conn.execute(
41        "UPDATE processed_wrappers SET wrapper_created_at = ?2 \
42         WHERE wrapper_id = ?1 AND wrapper_created_at = 0",
43        rusqlite::params![&wrapper_id_bytes[..], wrapper_created_at as i64],
44    ).map_err(|e| format!("Failed to backfill wrapper timestamp: {}", e))?;
45    Ok(())
46}
47
48/// Load all processed wrapper IDs as raw bytes for the dedup cache.
49pub fn load_processed_wrappers() -> Result<Vec<[u8; 32]>, String> {
50    let conn = match super::get_db_connection_guard_static() {
51        Ok(c) => c,
52        Err(_) => return Ok(Vec::new()),
53    };
54    // NIP-17 only: this feeds the WRAPPER_ID_CACHE, the DM gift-wrap dedup. Concord uses the
55    // synchronous ledger check (processed_wrapper_exists), not this in-memory cache.
56    let mut stmt = conn.prepare("SELECT wrapper_id FROM processed_wrappers WHERE transport = 0")
57        .map_err(|e| format!("Failed to prepare processed_wrappers query: {}", e))?;
58    let rows = stmt.query_map([], |row| {
59        let blob: Vec<u8> = row.get(0)?;
60        if blob.len() == 32 {
61            let mut arr = [0u8; 32];
62            arr.copy_from_slice(&blob);
63            Ok(arr)
64        } else {
65            Err(rusqlite::Error::InvalidParameterCount(blob.len(), 32))
66        }
67    }).map_err(|e| format!("Failed to query processed_wrappers: {}", e))?;
68
69    Ok(rows.flatten().collect())
70}
71
72/// [`load_processed_wrappers`] bounded to `wrapper_created_at >= since_secs` —
73/// the dedup cache only needs the window the planned reconciles can touch;
74/// anything older that still arrives dedups through the DB fallback.
75pub fn load_processed_wrappers_since(since_secs: u64) -> Result<Vec<[u8; 32]>, String> {
76    let conn = match super::get_db_connection_guard_static() {
77        Ok(c) => c,
78        Err(_) => return Ok(Vec::new()),
79    };
80    let mut stmt = conn.prepare(
81        "SELECT wrapper_id FROM processed_wrappers WHERE transport = 0 AND wrapper_created_at >= ?1",
82    ).map_err(|e| format!("Failed to prepare processed_wrappers query: {}", e))?;
83    let rows = stmt.query_map(rusqlite::params![since_secs as i64], |row| {
84        let blob: Vec<u8> = row.get(0)?;
85        if blob.len() == 32 {
86            let mut arr = [0u8; 32];
87            arr.copy_from_slice(&blob);
88            Ok(arr)
89        } else {
90            Err(rusqlite::Error::InvalidParameterCount(blob.len(), 32))
91        }
92    }).map_err(|e| format!("Failed to query processed_wrappers: {}", e))?;
93
94    Ok(rows.flatten().collect())
95}
96
97/// Load recent wrapper IDs from events table (last N days) as raw bytes.
98pub fn load_recent_wrapper_ids(days: u64) -> Result<Vec<[u8; 32]>, String> {
99    let conn = match super::get_db_connection_guard_static() {
100        Ok(c) => c,
101        Err(_) => return Ok(Vec::new()),
102    };
103
104    let cutoff_secs = web_time::SystemTime::now()
105        .duration_since(web_time::UNIX_EPOCH).unwrap()
106        .as_secs()
107        .saturating_sub(days * 24 * 60 * 60);
108
109    // DM cache only: exclude Community chats (chat_type 2). Concord stamps its OUTER id on
110    // `events.wrapper_event_id` too (atomic message dedup), so without this join those ids would warm
111    // the DM gift-wrap cache — harmless (they'd never match a gift-wrap lookup) but wasteful. Concord
112    // dedup uses the synchronous `processed_wrapper_exists` ledger, not this cache.
113    let mut stmt = conn.prepare(
114        "SELECT e.wrapper_event_id FROM events e \
115         JOIN chats c ON e.chat_id = c.id \
116         WHERE e.wrapper_event_id IS NOT NULL AND e.wrapper_event_id != '' \
117         AND e.created_at >= ?1 AND c.chat_type != 2"
118    ).map_err(|e| format!("Failed to prepare wrapper_id query: {}", e))?;
119
120    // Decoded straight off the borrowed column text. Collecting owned `String`s
121    // first would allocate once per row and hold the whole hex set resident
122    // alongside the decoded one, for no gain — each id is read exactly once.
123    let mut rows = stmt
124        .query(rusqlite::params![cutoff_secs as i64])
125        .map_err(|e| format!("Failed to query wrapper_ids: {}", e))?;
126
127    let mut result: Vec<[u8; 32]> = Vec::new();
128    while let Some(row) = rows
129        .next()
130        .map_err(|e| format!("Failed to read wrapper_id row: {}", e))?
131    {
132        let Ok(hex) = row.get_ref(0).and_then(|v| v.as_str().map_err(Into::into)) else {
133            continue;
134        };
135        if hex.len() == 64 {
136            result.push(crate::simd::hex::hex_to_bytes_32(hex));
137        }
138    }
139    Ok(result)
140}
141
142/// Load all processed wrappers as (EventId, Timestamp) pairs for negentropy (NIP-77).
143pub fn load_negentropy_items() -> Result<Vec<(EventId, Timestamp)>, String> {
144    load_negentropy_items_inner(None)
145}
146
147/// Fingerprint items no older than `since_secs`.
148///
149/// Reconnect and quick reconciles cover a window of days, not all of history —
150/// on an established account that is a few dozen items out of six figures, so
151/// the bound belongs in SQL rather than in a filter over a fully materialised
152/// set.
153pub fn load_negentropy_items_since(
154    since_secs: u64,
155) -> Result<Vec<(EventId, Timestamp)>, String> {
156    load_negentropy_items_inner(Some(since_secs))
157}
158
159fn load_negentropy_items_inner(
160    since_secs: Option<u64>,
161) -> Result<Vec<(EventId, Timestamp)>, String> {
162    let conn = super::get_db_connection_guard_static()
163        .map_err(|_| "No DB connection".to_string())?;
164
165    // NIP-77 reconciles gift-wraps for our pubkey, so fingerprint ONLY the 'nip17' carrier.
166    // Concord outer events share the ledger for dedup but must never enter DM negentropy.
167    //
168    // Ordered so negentropy's `seal()` sorts an already-sorted set rather than a
169    // scan-ordered one. This is only cheap because `idx_processed_wrappers_neg`
170    // (migration 87) carries all three columns: against an index missing
171    // `wrapper_id` the same ORDER BY costs a temp B-tree and a row lookup per
172    // hit, which is far more than the sort it saves.
173    let sql = if since_secs.is_some() {
174        "SELECT wrapper_id, wrapper_created_at FROM processed_wrappers \
175         WHERE transport = 0 AND wrapper_created_at >= ?1 \
176         ORDER BY wrapper_created_at, wrapper_id"
177    } else {
178        "SELECT wrapper_id, wrapper_created_at FROM processed_wrappers \
179         WHERE transport = 0 \
180         ORDER BY wrapper_created_at, wrapper_id"
181    };
182
183    let mut stmt = conn
184        .prepare(sql)
185        .map_err(|e| format!("Failed to prepare negentropy query: {}", e))?;
186
187    // Ids are read as borrowed blobs: `get::<Vec<u8>>` would heap-allocate once
188    // per row, which on a six-figure set is the bulk of this function's cost.
189    let mut rows = match since_secs {
190        Some(since) => stmt.query(rusqlite::params![since as i64]),
191        None => stmt.query([]),
192    }
193    .map_err(|e| format!("Failed to query processed_wrappers: {}", e))?;
194
195    let mut items: Vec<(EventId, Timestamp)> = Vec::new();
196    while let Some(row) = rows
197        .next()
198        .map_err(|e| format!("Failed to read processed_wrappers row: {}", e))?
199    {
200        let Ok(blob) = row.get_ref(0).and_then(|v| v.as_blob().map_err(Into::into)) else {
201            continue;
202        };
203        if blob.len() != 32 {
204            continue;
205        }
206        let mut arr = [0u8; 32];
207        arr.copy_from_slice(blob);
208        // A malformed row is skipped, never fatal: callers fall back to an empty
209        // set on `Err`, and an empty fingerprint set tells negentropy we hold
210        // nothing, which re-downloads the entire history.
211        let Ok(created_at) = row.get::<_, i64>(1) else {
212            continue;
213        };
214        items.push((
215            EventId::from_byte_array(arr),
216            Timestamp::from_secs(created_at as u64),
217        ));
218    }
219
220    Ok(items)
221}