Skip to main content

inline_client/
store.rs

1//! Store boundary for durable client state.
2//!
3//! Production `inline-client` storage will eventually be backed by SQLite. This
4//! trait is intentionally small for now: session, dialogs, users, history, and
5//! sent message recording are enough to support the first adapter paths
6//! while keeping the runner independent from filesystem assumptions.
7
8use std::{
9    collections::HashMap,
10    fmt,
11    path::Path,
12    sync::{Arc, Mutex},
13    time::{SystemTime, UNIX_EPOCH},
14};
15
16use futures_util::future::BoxFuture;
17use rusqlite::{Connection, OptionalExtension, params};
18
19use crate::{
20    AuthCredential, ClientErrorCategory, ClientFailure, DialogRecord, DialogsPage, DialogsRequest,
21    HistoryPage, HistoryRequest, InlineId, MessageContent, MessageRecord, TransactionEvent,
22    TransactionId, TransactionIdentity, TransactionState, UserRecord,
23};
24
25/// Result type returned by client stores.
26pub type StoreResult<T> = Result<T, StoreError>;
27
28/// Redacted store error.
29#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
30#[error("{category:?}: {message}")]
31pub struct StoreError {
32    /// Stable error category.
33    pub category: ClientErrorCategory,
34    /// Redacted message suitable for hosts.
35    pub message: String,
36}
37
38impl StoreError {
39    /// Creates a store error.
40    pub fn new(category: ClientErrorCategory, message: impl Into<String>) -> Self {
41        Self {
42            category,
43            message: message.into(),
44        }
45    }
46
47    fn internal(message: impl Into<String>) -> Self {
48        Self::new(ClientErrorCategory::Internal, message)
49    }
50}
51
52/// Stored session metadata.
53#[derive(Clone, PartialEq, Eq)]
54pub struct StoredSession {
55    /// Auth credential needed by the runtime.
56    pub auth: AuthCredential,
57    /// Optional account/store namespace chosen by the host.
58    pub account_namespace: Option<String>,
59}
60
61impl fmt::Debug for StoredSession {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        f.debug_struct("StoredSession")
64            .field("auth", &self.auth)
65            .field("account_namespace", &self.account_namespace)
66            .finish()
67    }
68}
69
70/// Durable store boundary for client state.
71pub trait ClientStore: fmt::Debug + Send + Sync + 'static {
72    /// Saves the current session.
73    fn save_session(&self, session: StoredSession) -> BoxFuture<'static, StoreResult<()>>;
74
75    /// Loads the current session.
76    fn load_session(&self) -> BoxFuture<'static, StoreResult<Option<StoredSession>>>;
77
78    /// Clears the current session.
79    fn clear_session(&self) -> BoxFuture<'static, StoreResult<()>>;
80
81    /// Lists stored dialogs.
82    fn dialogs(&self, request: DialogsRequest) -> BoxFuture<'static, StoreResult<DialogsPage>>;
83
84    /// Records a dialog in local state.
85    fn record_dialog(&self, dialog: DialogRecord) -> BoxFuture<'static, StoreResult<()>>;
86
87    /// Records user summaries in local state.
88    fn record_users(&self, users: Vec<UserRecord>) -> BoxFuture<'static, StoreResult<()>>;
89
90    /// Fetches stored history.
91    fn history(&self, request: HistoryRequest) -> BoxFuture<'static, StoreResult<HistoryPage>>;
92
93    /// Records a message in stored history.
94    fn record_message(&self, message: MessageRecord) -> BoxFuture<'static, StoreResult<()>>;
95
96    /// Records a transaction state change.
97    fn record_transaction(
98        &self,
99        transaction: StoredTransaction,
100    ) -> BoxFuture<'static, StoreResult<()>>;
101
102    /// Finds a transaction by transaction ID.
103    fn transaction(
104        &self,
105        transaction_id: TransactionId,
106    ) -> BoxFuture<'static, StoreResult<Option<StoredTransaction>>>;
107}
108
109/// Stored durable transaction state.
110#[derive(Clone, Debug, PartialEq, Eq)]
111pub struct StoredTransaction {
112    /// Mutation identity and reconciliation IDs.
113    pub identity: TransactionIdentity,
114    /// Current transaction state.
115    pub state: TransactionState,
116    /// Chat containing the transaction, when known.
117    pub chat_id: Option<InlineId>,
118    /// Final message ID, when known.
119    pub message_id: Option<InlineId>,
120    /// Redacted failure for failed transactions.
121    pub failure: Option<ClientFailure>,
122    /// Unix timestamp in seconds when the transaction row was created.
123    pub created_at: i64,
124    /// Unix timestamp in seconds when the transaction row was last updated.
125    pub updated_at: i64,
126}
127
128impl StoredTransaction {
129    /// Creates a stored transaction in the provided state.
130    pub fn new(identity: TransactionIdentity, state: TransactionState) -> Self {
131        let now = now_seconds();
132        Self {
133            identity,
134            state,
135            chat_id: None,
136            message_id: None,
137            failure: None,
138            created_at: now,
139            updated_at: now,
140        }
141    }
142
143    /// Sets the chat ID.
144    pub fn with_chat_id(mut self, chat_id: InlineId) -> Self {
145        self.chat_id = Some(chat_id);
146        self
147    }
148
149    /// Sets the final message ID.
150    pub fn with_message_id(mut self, message_id: InlineId) -> Self {
151        self.message_id = Some(message_id);
152        self.identity.final_message_id = Some(message_id);
153        self
154    }
155
156    /// Sets the redacted failure.
157    pub fn with_failure(mut self, failure: ClientFailure) -> Self {
158        self.failure = Some(failure);
159        self
160    }
161
162    /// Converts to a client event payload.
163    pub fn event(&self) -> TransactionEvent {
164        TransactionEvent {
165            identity: self.identity.clone(),
166            state: self.state,
167            failure: self.failure.clone(),
168        }
169    }
170}
171
172/// In-memory store for tests and early client development.
173#[derive(Clone, Debug, Default)]
174pub struct InMemoryStore {
175    state: Arc<Mutex<InMemoryStoreState>>,
176}
177
178#[derive(Debug, Default)]
179struct InMemoryStoreState {
180    session: Option<StoredSession>,
181    dialogs: Vec<DialogRecord>,
182    users: HashMap<i64, UserRecord>,
183    messages: HashMap<i64, Vec<MessageRecord>>,
184    transactions: HashMap<String, StoredTransaction>,
185}
186
187impl InMemoryStore {
188    /// Creates an empty in-memory store.
189    pub fn new() -> Self {
190        Self::default()
191    }
192
193    /// Inserts or replaces a dialog.
194    pub fn upsert_dialog(&self, dialog: DialogRecord) {
195        let mut state = self.state.lock().expect("in-memory store poisoned");
196        upsert_dialog(&mut state.dialogs, dialog);
197    }
198
199    /// Inserts or replaces a user summary.
200    pub fn upsert_user(&self, user: UserRecord) {
201        let mut state = self.state.lock().expect("in-memory store poisoned");
202        state.users.insert(user.user_id.get(), user);
203    }
204
205    /// Inserts a message into history.
206    pub fn insert_message(&self, message: MessageRecord) {
207        let mut state = self.state.lock().expect("in-memory store poisoned");
208        insert_message(&mut state.messages, message);
209    }
210}
211
212impl ClientStore for InMemoryStore {
213    fn save_session(&self, session: StoredSession) -> BoxFuture<'static, StoreResult<()>> {
214        let store = self.clone();
215        Box::pin(async move {
216            store
217                .state
218                .lock()
219                .expect("in-memory store poisoned")
220                .session = Some(session);
221            Ok(())
222        })
223    }
224
225    fn load_session(&self) -> BoxFuture<'static, StoreResult<Option<StoredSession>>> {
226        let store = self.clone();
227        Box::pin(async move {
228            Ok(store
229                .state
230                .lock()
231                .expect("in-memory store poisoned")
232                .session
233                .clone())
234        })
235    }
236
237    fn clear_session(&self) -> BoxFuture<'static, StoreResult<()>> {
238        let store = self.clone();
239        Box::pin(async move {
240            store
241                .state
242                .lock()
243                .expect("in-memory store poisoned")
244                .session = None;
245            Ok(())
246        })
247    }
248
249    fn dialogs(&self, request: DialogsRequest) -> BoxFuture<'static, StoreResult<DialogsPage>> {
250        let store = self.clone();
251        Box::pin(async move {
252            let state = store.state.lock().expect("in-memory store poisoned");
253            let start = parse_cursor(request.cursor.as_deref())?;
254            let limit = request.limit.unwrap_or(50).max(1) as usize;
255            let dialogs = state
256                .dialogs
257                .iter()
258                .skip(start)
259                .take(limit)
260                .map(|dialog| {
261                    dialog_with_synced_through(
262                        dialog.clone(),
263                        max_message_id_from_memory(&state.messages, dialog.chat_id),
264                    )
265                })
266                .collect::<Vec<_>>();
267            let next = start + dialogs.len();
268            let users = all_users_from_memory(&state.users);
269            Ok(DialogsPage {
270                dialogs,
271                users,
272                next_cursor: (next < state.dialogs.len()).then(|| next.to_string()),
273            })
274        })
275    }
276
277    fn record_dialog(&self, dialog: DialogRecord) -> BoxFuture<'static, StoreResult<()>> {
278        let store = self.clone();
279        Box::pin(async move {
280            store.upsert_dialog(dialog);
281            Ok(())
282        })
283    }
284
285    fn record_users(&self, users: Vec<UserRecord>) -> BoxFuture<'static, StoreResult<()>> {
286        let store = self.clone();
287        Box::pin(async move {
288            let mut state = store.state.lock().expect("in-memory store poisoned");
289            for user in users {
290                state.users.insert(user.user_id.get(), user);
291            }
292            Ok(())
293        })
294    }
295
296    fn history(&self, request: HistoryRequest) -> BoxFuture<'static, StoreResult<HistoryPage>> {
297        let store = self.clone();
298        Box::pin(async move {
299            let state = store.state.lock().expect("in-memory store poisoned");
300            let mut messages = state
301                .messages
302                .get(&request.chat_id.get())
303                .cloned()
304                .unwrap_or_default();
305            messages.sort_by_key(|message| (message.timestamp, message.message_id.get()));
306            if request.before_message_id.is_some() && request.after_message_id.is_some() {
307                return Err(StoreError::new(
308                    ClientErrorCategory::InvalidInput,
309                    "history request cannot specify both before_message_id and after_message_id",
310                ));
311            }
312            if let Some(before) = request.before_message_id {
313                messages.retain(|message| message.message_id.get() < before.get());
314            }
315            if let Some(after) = request.after_message_id {
316                messages.retain(|message| message.message_id.get() > after.get());
317            }
318            let limit = request.limit.unwrap_or(50).max(1) as usize;
319            let has_more = messages.len() > limit;
320            if has_more {
321                if request.after_message_id.is_some() {
322                    messages.truncate(limit);
323                } else {
324                    let start = messages.len() - limit;
325                    messages = messages[start..].to_vec();
326                }
327            }
328            let users = users_for_messages_from_memory(&state.users, &messages);
329            Ok(HistoryPage {
330                messages,
331                users,
332                has_more,
333                next_cursor: None,
334            })
335        })
336    }
337
338    fn record_message(&self, message: MessageRecord) -> BoxFuture<'static, StoreResult<()>> {
339        let store = self.clone();
340        Box::pin(async move {
341            let mut state = store.state.lock().expect("in-memory store poisoned");
342            insert_message(&mut state.messages, message);
343            Ok(())
344        })
345    }
346
347    fn record_transaction(
348        &self,
349        transaction: StoredTransaction,
350    ) -> BoxFuture<'static, StoreResult<()>> {
351        let store = self.clone();
352        Box::pin(async move {
353            let mut state = store.state.lock().expect("in-memory store poisoned");
354            state.transactions.insert(
355                transaction.identity.transaction_id.as_str().to_owned(),
356                transaction,
357            );
358            Ok(())
359        })
360    }
361
362    fn transaction(
363        &self,
364        transaction_id: TransactionId,
365    ) -> BoxFuture<'static, StoreResult<Option<StoredTransaction>>> {
366        let store = self.clone();
367        Box::pin(async move {
368            Ok(store
369                .state
370                .lock()
371                .expect("in-memory store poisoned")
372                .transactions
373                .get(transaction_id.as_str())
374                .cloned())
375        })
376    }
377}
378
379/// SQLite-backed durable store.
380#[derive(Clone)]
381pub struct SqliteStore {
382    connection: Arc<Mutex<Connection>>,
383}
384
385impl fmt::Debug for SqliteStore {
386    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
387        f.debug_struct("SqliteStore")
388            .field("connection", &"<sqlite>")
389            .finish()
390    }
391}
392
393impl SqliteStore {
394    /// Opens a SQLite store at `path`, creating parent directories and schema.
395    pub fn open(path: impl AsRef<Path>) -> StoreResult<Self> {
396        let path = path.as_ref();
397        if let Some(parent) = path
398            .parent()
399            .filter(|parent| !parent.as_os_str().is_empty())
400        {
401            std::fs::create_dir_all(parent).map_err(|error| {
402                StoreError::internal(format!("create store directory: {error}"))
403            })?;
404        }
405        let connection = Connection::open(path).map_err(sqlite_error)?;
406        Self::from_connection(connection)
407    }
408
409    /// Opens an in-memory SQLite store.
410    pub fn open_in_memory() -> StoreResult<Self> {
411        let connection = Connection::open_in_memory().map_err(sqlite_error)?;
412        Self::from_connection(connection)
413    }
414
415    fn from_connection(connection: Connection) -> StoreResult<Self> {
416        migrate_sqlite(&connection)?;
417        Ok(Self {
418            connection: Arc::new(Mutex::new(connection)),
419        })
420    }
421
422    /// Inserts or replaces a dialog.
423    pub fn upsert_dialog(&self, dialog: DialogRecord) -> StoreResult<()> {
424        let connection = self.connection.lock().expect("sqlite store poisoned");
425        upsert_sqlite_dialog(&connection, dialog)
426    }
427
428    /// Inserts or replaces a user summary.
429    pub fn upsert_user(&self, user: UserRecord) -> StoreResult<()> {
430        let connection = self.connection.lock().expect("sqlite store poisoned");
431        upsert_sqlite_user(&connection, user)
432    }
433
434    /// Inserts a message into history.
435    pub fn insert_message(&self, message: MessageRecord) -> StoreResult<()> {
436        self.record_message_sync(message)
437    }
438
439    fn save_session_sync(&self, session: StoredSession) -> StoreResult<()> {
440        let auth_json = serde_json::to_string(&session.auth)
441            .map_err(|error| StoreError::internal(format!("encode session auth: {error}")))?;
442        let connection = self.connection.lock().expect("sqlite store poisoned");
443        connection
444            .execute(
445                "INSERT INTO sessions (id, auth_json, account_namespace, updated_at)
446                 VALUES (1, ?1, ?2, ?3)
447                 ON CONFLICT(id) DO UPDATE SET
448                   auth_json = excluded.auth_json,
449                   account_namespace = excluded.account_namespace,
450                   updated_at = excluded.updated_at",
451                params![auth_json, session.account_namespace, now_seconds()],
452            )
453            .map_err(sqlite_error)?;
454        Ok(())
455    }
456
457    fn load_session_sync(&self) -> StoreResult<Option<StoredSession>> {
458        let connection = self.connection.lock().expect("sqlite store poisoned");
459        let row = connection
460            .query_row(
461                "SELECT auth_json, account_namespace FROM sessions WHERE id = 1",
462                [],
463                |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
464            )
465            .optional()
466            .map_err(sqlite_error)?;
467
468        row.map(|(auth_json, account_namespace)| {
469            let auth = serde_json::from_str::<AuthCredential>(&auth_json)
470                .map_err(|error| StoreError::internal(format!("decode session auth: {error}")))?;
471            Ok(StoredSession {
472                auth,
473                account_namespace,
474            })
475        })
476        .transpose()
477    }
478
479    fn clear_session_sync(&self) -> StoreResult<()> {
480        let connection = self.connection.lock().expect("sqlite store poisoned");
481        connection
482            .execute("DELETE FROM sessions WHERE id = 1", [])
483            .map_err(sqlite_error)?;
484        Ok(())
485    }
486
487    fn dialogs_sync(&self, request: DialogsRequest) -> StoreResult<DialogsPage> {
488        let start = parse_cursor(request.cursor.as_deref())?;
489        let limit = request.limit.unwrap_or(50).max(1) as usize;
490        let connection = self.connection.lock().expect("sqlite store poisoned");
491        let rows = {
492            let mut stmt = connection
493                .prepare(
494                    "SELECT d.chat_id, d.peer_user_id, d.title, d.last_message_id,
495                            (SELECT MAX(m.message_id) FROM messages m WHERE m.chat_id = d.chat_id) AS synced_through_message_id,
496                            d.unread_count
497                     FROM dialogs d
498                     ORDER BY COALESCE(last_message_id, 0) DESC, chat_id ASC
499                     LIMIT ?1 OFFSET ?2",
500                )
501                .map_err(sqlite_error)?;
502            stmt.query_map(params![(limit + 1) as i64, start as i64], |row| {
503                Ok(DialogRecord {
504                    chat_id: InlineId::new(row.get::<_, i64>(0)?),
505                    peer_user_id: row.get::<_, Option<i64>>(1)?.map(InlineId::new),
506                    title: row.get::<_, Option<String>>(2)?,
507                    last_message_id: row.get::<_, Option<i64>>(3)?.map(InlineId::new),
508                    synced_through_message_id: row.get::<_, Option<i64>>(4)?.map(InlineId::new),
509                    unread_count: row
510                        .get::<_, Option<i64>>(5)?
511                        .and_then(|value| u32::try_from(value).ok()),
512                })
513            })
514            .map_err(sqlite_error)?
515            .collect::<Result<Vec<_>, _>>()
516            .map_err(sqlite_error)?
517        };
518
519        let has_more = rows.len() > limit;
520        let dialogs = rows.into_iter().take(limit).collect::<Vec<_>>();
521        let users = all_sqlite_users(&connection)?;
522        Ok(DialogsPage {
523            next_cursor: has_more.then(|| (start + dialogs.len()).to_string()),
524            dialogs,
525            users,
526        })
527    }
528
529    fn history_sync(&self, request: HistoryRequest) -> StoreResult<HistoryPage> {
530        let limit = request.limit.unwrap_or(50).max(1) as usize;
531        let connection = self.connection.lock().expect("sqlite store poisoned");
532        let rows = if request.before_message_id.is_some() && request.after_message_id.is_some() {
533            return Err(StoreError::new(
534                ClientErrorCategory::InvalidInput,
535                "history request cannot specify both before_message_id and after_message_id",
536            ));
537        } else if let Some(before) = request.before_message_id {
538            let mut stmt = connection
539                .prepare(
540                    "SELECT chat_id, message_id, sender_id, timestamp, is_outgoing,
541                            content_json, reply_to_message_id, transaction_json
542                     FROM messages
543                     WHERE chat_id = ?1 AND message_id < ?2
544                     ORDER BY message_id DESC
545                     LIMIT ?3",
546                )
547                .map_err(sqlite_error)?;
548            query_message_rows(
549                &mut stmt,
550                params![request.chat_id.get(), before.get(), (limit + 1) as i64],
551            )?
552        } else if let Some(after) = request.after_message_id {
553            let mut stmt = connection
554                .prepare(
555                    "SELECT chat_id, message_id, sender_id, timestamp, is_outgoing,
556                            content_json, reply_to_message_id, transaction_json
557                     FROM messages
558                     WHERE chat_id = ?1 AND message_id > ?2
559                     ORDER BY message_id ASC
560                     LIMIT ?3",
561                )
562                .map_err(sqlite_error)?;
563            query_message_rows(
564                &mut stmt,
565                params![request.chat_id.get(), after.get(), (limit + 1) as i64],
566            )?
567        } else {
568            let mut stmt = connection
569                .prepare(
570                    "SELECT chat_id, message_id, sender_id, timestamp, is_outgoing,
571                            content_json, reply_to_message_id, transaction_json
572                     FROM messages
573                     WHERE chat_id = ?1
574                     ORDER BY message_id DESC
575                     LIMIT ?2",
576                )
577                .map_err(sqlite_error)?;
578            query_message_rows(
579                &mut stmt,
580                params![request.chat_id.get(), (limit + 1) as i64],
581            )?
582        };
583
584        let has_more = rows.len() > limit;
585        let mut messages = rows
586            .into_iter()
587            .take(limit)
588            .map(raw_sqlite_message_to_record)
589            .collect::<StoreResult<Vec<_>>>()?;
590        messages.sort_by_key(|message| (message.timestamp, message.message_id.get()));
591        let users = sqlite_users_for_messages(&connection, &messages)?;
592        Ok(HistoryPage {
593            messages,
594            users,
595            has_more,
596            next_cursor: None,
597        })
598    }
599
600    fn record_users_sync(&self, users: Vec<UserRecord>) -> StoreResult<()> {
601        if users.is_empty() {
602            return Ok(());
603        }
604        let connection = self.connection.lock().expect("sqlite store poisoned");
605        for user in users {
606            upsert_sqlite_user(&connection, user)?;
607        }
608        Ok(())
609    }
610
611    fn record_message_sync(&self, message: MessageRecord) -> StoreResult<()> {
612        let content_json = serde_json::to_string(&message.content)
613            .map_err(|error| StoreError::internal(format!("encode message content: {error}")))?;
614        let transaction_json = message
615            .transaction
616            .as_ref()
617            .map(serde_json::to_string)
618            .transpose()
619            .map_err(|error| {
620                StoreError::internal(format!("encode transaction identity: {error}"))
621            })?;
622        let connection = self.connection.lock().expect("sqlite store poisoned");
623        connection
624            .execute(
625                "INSERT INTO messages (
626                   chat_id, message_id, sender_id, timestamp, is_outgoing,
627                   content_json, reply_to_message_id, transaction_json
628                 )
629                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
630                 ON CONFLICT(chat_id, message_id) DO UPDATE SET
631                   sender_id = excluded.sender_id,
632                   timestamp = excluded.timestamp,
633                   is_outgoing = excluded.is_outgoing,
634                   content_json = excluded.content_json,
635                   reply_to_message_id = excluded.reply_to_message_id,
636                   transaction_json = excluded.transaction_json",
637                params![
638                    message.chat_id.get(),
639                    message.message_id.get(),
640                    message.sender_id.get(),
641                    message.timestamp,
642                    i64::from(message.is_outgoing),
643                    content_json,
644                    message.reply_to_message_id.map(InlineId::get),
645                    transaction_json,
646                ],
647            )
648            .map_err(sqlite_error)?;
649        upsert_sqlite_dialog_last_message(&connection, message.chat_id, message.message_id)?;
650        Ok(())
651    }
652
653    fn record_transaction_sync(&self, transaction: StoredTransaction) -> StoreResult<()> {
654        let identity_json = serde_json::to_string(&transaction.identity).map_err(|error| {
655            StoreError::internal(format!("encode transaction identity: {error}"))
656        })?;
657        let state_json = serde_json::to_string(&transaction.state)
658            .map_err(|error| StoreError::internal(format!("encode transaction state: {error}")))?;
659        let failure_json = transaction
660            .failure
661            .as_ref()
662            .map(serde_json::to_string)
663            .transpose()
664            .map_err(|error| {
665                StoreError::internal(format!("encode transaction failure: {error}"))
666            })?;
667        let connection = self.connection.lock().expect("sqlite store poisoned");
668        connection
669            .execute(
670                "INSERT INTO transactions (
671                   transaction_id, identity_json, state_json, chat_id, message_id,
672                   random_id, external_source, external_id, failure_json, created_at, updated_at
673                 )
674                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
675                 ON CONFLICT(transaction_id) DO UPDATE SET
676                   identity_json = excluded.identity_json,
677                   state_json = excluded.state_json,
678                   chat_id = excluded.chat_id,
679                   message_id = excluded.message_id,
680                   random_id = excluded.random_id,
681                   external_source = excluded.external_source,
682                   external_id = excluded.external_id,
683                   failure_json = excluded.failure_json,
684                   updated_at = excluded.updated_at",
685                params![
686                    transaction.identity.transaction_id.as_str(),
687                    identity_json,
688                    state_json,
689                    transaction.chat_id.map(InlineId::get),
690                    transaction.message_id.map(InlineId::get),
691                    transaction.identity.random_id.get(),
692                    transaction
693                        .identity
694                        .external_id
695                        .as_ref()
696                        .map(|external| external.source()),
697                    transaction
698                        .identity
699                        .external_id
700                        .as_ref()
701                        .map(|external| external.id()),
702                    failure_json,
703                    transaction.created_at,
704                    transaction.updated_at,
705                ],
706            )
707            .map_err(sqlite_error)?;
708        Ok(())
709    }
710
711    fn transaction_sync(
712        &self,
713        transaction_id: TransactionId,
714    ) -> StoreResult<Option<StoredTransaction>> {
715        let connection = self.connection.lock().expect("sqlite store poisoned");
716        let row = connection
717            .query_row(
718                "SELECT identity_json, state_json, chat_id, message_id,
719                        failure_json, created_at, updated_at
720                 FROM transactions
721                 WHERE transaction_id = ?1",
722                params![transaction_id.as_str()],
723                |row| {
724                    Ok(RawSqliteTransaction {
725                        identity_json: row.get(0)?,
726                        state_json: row.get(1)?,
727                        chat_id: row.get(2)?,
728                        message_id: row.get(3)?,
729                        failure_json: row.get(4)?,
730                        created_at: row.get(5)?,
731                        updated_at: row.get(6)?,
732                    })
733                },
734            )
735            .optional()
736            .map_err(sqlite_error)?;
737
738        row.map(raw_sqlite_transaction_to_record).transpose()
739    }
740}
741
742impl ClientStore for SqliteStore {
743    fn save_session(&self, session: StoredSession) -> BoxFuture<'static, StoreResult<()>> {
744        let store = self.clone();
745        Box::pin(async move { store.save_session_sync(session) })
746    }
747
748    fn load_session(&self) -> BoxFuture<'static, StoreResult<Option<StoredSession>>> {
749        let store = self.clone();
750        Box::pin(async move { store.load_session_sync() })
751    }
752
753    fn clear_session(&self) -> BoxFuture<'static, StoreResult<()>> {
754        let store = self.clone();
755        Box::pin(async move { store.clear_session_sync() })
756    }
757
758    fn dialogs(&self, request: DialogsRequest) -> BoxFuture<'static, StoreResult<DialogsPage>> {
759        let store = self.clone();
760        Box::pin(async move { store.dialogs_sync(request) })
761    }
762
763    fn record_dialog(&self, dialog: DialogRecord) -> BoxFuture<'static, StoreResult<()>> {
764        let store = self.clone();
765        Box::pin(async move { store.upsert_dialog(dialog) })
766    }
767
768    fn record_users(&self, users: Vec<UserRecord>) -> BoxFuture<'static, StoreResult<()>> {
769        let store = self.clone();
770        Box::pin(async move { store.record_users_sync(users) })
771    }
772
773    fn history(&self, request: HistoryRequest) -> BoxFuture<'static, StoreResult<HistoryPage>> {
774        let store = self.clone();
775        Box::pin(async move { store.history_sync(request) })
776    }
777
778    fn record_message(&self, message: MessageRecord) -> BoxFuture<'static, StoreResult<()>> {
779        let store = self.clone();
780        Box::pin(async move { store.record_message_sync(message) })
781    }
782
783    fn record_transaction(
784        &self,
785        transaction: StoredTransaction,
786    ) -> BoxFuture<'static, StoreResult<()>> {
787        let store = self.clone();
788        Box::pin(async move { store.record_transaction_sync(transaction) })
789    }
790
791    fn transaction(
792        &self,
793        transaction_id: TransactionId,
794    ) -> BoxFuture<'static, StoreResult<Option<StoredTransaction>>> {
795        let store = self.clone();
796        Box::pin(async move { store.transaction_sync(transaction_id) })
797    }
798}
799
800pub(crate) fn parse_cursor(cursor: Option<&str>) -> StoreResult<usize> {
801    match cursor {
802        Some(cursor) if !cursor.trim().is_empty() => cursor.parse::<usize>().map_err(|_| {
803            StoreError::new(
804                ClientErrorCategory::InvalidInput,
805                "invalid pagination cursor",
806            )
807        }),
808        _ => Ok(0),
809    }
810}
811
812fn migrate_sqlite(connection: &Connection) -> StoreResult<()> {
813    connection
814        .execute_batch(
815            "
816            PRAGMA foreign_keys = ON;
817
818            CREATE TABLE IF NOT EXISTS sessions (
819                id INTEGER PRIMARY KEY CHECK (id = 1),
820                auth_json TEXT NOT NULL,
821                account_namespace TEXT,
822                updated_at INTEGER NOT NULL
823            );
824
825            CREATE TABLE IF NOT EXISTS dialogs (
826                chat_id INTEGER PRIMARY KEY,
827                peer_user_id INTEGER,
828                title TEXT,
829                last_message_id INTEGER,
830                unread_count INTEGER,
831                updated_at INTEGER NOT NULL
832            );
833
834            CREATE TABLE IF NOT EXISTS users (
835                user_id INTEGER PRIMARY KEY,
836                display_name TEXT,
837                username TEXT,
838                first_name TEXT,
839                last_name TEXT,
840                avatar_url TEXT,
841                is_bot INTEGER,
842                updated_at INTEGER NOT NULL
843            );
844
845            CREATE TABLE IF NOT EXISTS messages (
846                chat_id INTEGER NOT NULL,
847                message_id INTEGER NOT NULL,
848                sender_id INTEGER NOT NULL,
849                timestamp INTEGER NOT NULL,
850                is_outgoing INTEGER NOT NULL,
851                content_json TEXT NOT NULL,
852                reply_to_message_id INTEGER,
853                transaction_json TEXT,
854                PRIMARY KEY (chat_id, message_id)
855            );
856
857            CREATE INDEX IF NOT EXISTS idx_messages_chat_message
858                ON messages (chat_id, message_id DESC);
859
860            CREATE TABLE IF NOT EXISTS transactions (
861                transaction_id TEXT PRIMARY KEY,
862                identity_json TEXT NOT NULL,
863                state_json TEXT NOT NULL,
864                chat_id INTEGER,
865                message_id INTEGER,
866                random_id INTEGER NOT NULL,
867                external_source TEXT,
868                external_id TEXT,
869                failure_json TEXT,
870                created_at INTEGER NOT NULL,
871                updated_at INTEGER NOT NULL
872            );
873
874            CREATE INDEX IF NOT EXISTS idx_transactions_external
875                ON transactions (external_source, external_id);
876            CREATE INDEX IF NOT EXISTS idx_transactions_random
877                ON transactions (random_id);
878            ",
879        )
880        .map_err(sqlite_error)?;
881    ensure_sqlite_column(connection, "dialogs", "peer_user_id", "INTEGER")?;
882    Ok(())
883}
884
885fn ensure_sqlite_column(
886    connection: &Connection,
887    table: &str,
888    column: &str,
889    definition: &str,
890) -> StoreResult<()> {
891    let pragma = format!("PRAGMA table_info({table})");
892    let mut stmt = connection.prepare(&pragma).map_err(sqlite_error)?;
893    let columns = stmt
894        .query_map([], |row| row.get::<_, String>(1))
895        .map_err(sqlite_error)?
896        .collect::<Result<Vec<_>, _>>()
897        .map_err(sqlite_error)?;
898    if columns.iter().any(|name| name == column) {
899        return Ok(());
900    }
901    connection
902        .execute(
903            &format!("ALTER TABLE {table} ADD COLUMN {column} {definition}"),
904            [],
905        )
906        .map_err(sqlite_error)?;
907    Ok(())
908}
909
910fn upsert_sqlite_user(connection: &Connection, user: UserRecord) -> StoreResult<()> {
911    connection
912        .execute(
913            "INSERT INTO users (
914               user_id, display_name, username, first_name, last_name,
915               avatar_url, is_bot, updated_at
916             )
917             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
918             ON CONFLICT(user_id) DO UPDATE SET
919               display_name = excluded.display_name,
920               username = excluded.username,
921               first_name = excluded.first_name,
922               last_name = excluded.last_name,
923               avatar_url = excluded.avatar_url,
924               is_bot = excluded.is_bot,
925               updated_at = excluded.updated_at",
926            params![
927                user.user_id.get(),
928                user.display_name,
929                user.username,
930                user.first_name,
931                user.last_name,
932                user.avatar_url,
933                user.is_bot.map(|is_bot| if is_bot { 1_i64 } else { 0_i64 }),
934                now_seconds(),
935            ],
936        )
937        .map_err(sqlite_error)?;
938    Ok(())
939}
940
941fn upsert_sqlite_dialog(connection: &Connection, dialog: DialogRecord) -> StoreResult<()> {
942    connection
943        .execute(
944            "INSERT INTO dialogs (chat_id, peer_user_id, title, last_message_id, unread_count, updated_at)
945             VALUES (?1, ?2, ?3, ?4, ?5, ?6)
946             ON CONFLICT(chat_id) DO UPDATE SET
947               peer_user_id = excluded.peer_user_id,
948               title = excluded.title,
949               last_message_id = excluded.last_message_id,
950               unread_count = excluded.unread_count,
951               updated_at = excluded.updated_at",
952            params![
953                dialog.chat_id.get(),
954                dialog.peer_user_id.map(InlineId::get),
955                dialog.title,
956                dialog.last_message_id.map(InlineId::get),
957                dialog.unread_count.map(i64::from),
958                now_seconds(),
959            ],
960        )
961        .map_err(sqlite_error)?;
962    Ok(())
963}
964
965fn upsert_sqlite_dialog_last_message(
966    connection: &Connection,
967    chat_id: InlineId,
968    message_id: InlineId,
969) -> StoreResult<()> {
970    let updated = connection
971        .execute(
972            "UPDATE dialogs
973             SET last_message_id = ?1, updated_at = ?2
974             WHERE chat_id = ?3",
975            params![message_id.get(), now_seconds(), chat_id.get()],
976        )
977        .map_err(sqlite_error)?;
978    if updated == 0 {
979        connection
980            .execute(
981                "INSERT INTO dialogs (chat_id, title, last_message_id, unread_count, updated_at)
982                 VALUES (?1, NULL, ?2, 0, ?3)",
983                params![chat_id.get(), message_id.get(), now_seconds()],
984            )
985            .map_err(sqlite_error)?;
986    }
987    Ok(())
988}
989
990fn all_sqlite_users(connection: &Connection) -> StoreResult<Vec<UserRecord>> {
991    let mut stmt = connection
992        .prepare(
993            "SELECT user_id, display_name, username, first_name, last_name, avatar_url, is_bot
994             FROM users
995             ORDER BY user_id ASC",
996        )
997        .map_err(sqlite_error)?;
998    query_user_rows(&mut stmt, [])
999}
1000
1001fn sqlite_users_for_messages(
1002    connection: &Connection,
1003    messages: &[MessageRecord],
1004) -> StoreResult<Vec<UserRecord>> {
1005    let mut user_ids = messages
1006        .iter()
1007        .map(|message| message.sender_id.get())
1008        .collect::<Vec<_>>();
1009    user_ids.sort_unstable();
1010    user_ids.dedup();
1011
1012    let mut users = Vec::new();
1013    let mut stmt = connection
1014        .prepare(
1015            "SELECT user_id, display_name, username, first_name, last_name, avatar_url, is_bot
1016             FROM users
1017             WHERE user_id = ?1",
1018        )
1019        .map_err(sqlite_error)?;
1020    for user_id in user_ids {
1021        if let Some(user) = stmt
1022            .query_row(params![user_id], sqlite_user_from_row)
1023            .optional()
1024            .map_err(sqlite_error)?
1025        {
1026            users.push(user);
1027        }
1028    }
1029    Ok(users)
1030}
1031
1032fn query_user_rows<P>(stmt: &mut rusqlite::Statement<'_>, params: P) -> StoreResult<Vec<UserRecord>>
1033where
1034    P: rusqlite::Params,
1035{
1036    stmt.query_map(params, sqlite_user_from_row)
1037        .map_err(sqlite_error)?
1038        .collect::<Result<Vec<_>, _>>()
1039        .map_err(sqlite_error)
1040}
1041
1042fn sqlite_user_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<UserRecord> {
1043    Ok(UserRecord {
1044        user_id: InlineId::new(row.get::<_, i64>(0)?),
1045        display_name: row.get::<_, Option<String>>(1)?,
1046        username: row.get::<_, Option<String>>(2)?,
1047        first_name: row.get::<_, Option<String>>(3)?,
1048        last_name: row.get::<_, Option<String>>(4)?,
1049        avatar_url: row.get::<_, Option<String>>(5)?,
1050        is_bot: row.get::<_, Option<i64>>(6)?.map(|value| value != 0),
1051    })
1052}
1053
1054#[derive(Debug)]
1055struct RawSqliteMessage {
1056    chat_id: i64,
1057    message_id: i64,
1058    sender_id: i64,
1059    timestamp: i64,
1060    is_outgoing: bool,
1061    content_json: String,
1062    reply_to_message_id: Option<i64>,
1063    transaction_json: Option<String>,
1064}
1065
1066fn query_message_rows<P>(
1067    stmt: &mut rusqlite::Statement<'_>,
1068    params: P,
1069) -> StoreResult<Vec<RawSqliteMessage>>
1070where
1071    P: rusqlite::Params,
1072{
1073    stmt.query_map(params, |row| {
1074        Ok(RawSqliteMessage {
1075            chat_id: row.get(0)?,
1076            message_id: row.get(1)?,
1077            sender_id: row.get(2)?,
1078            timestamp: row.get(3)?,
1079            is_outgoing: row.get::<_, i64>(4)? != 0,
1080            content_json: row.get(5)?,
1081            reply_to_message_id: row.get(6)?,
1082            transaction_json: row.get(7)?,
1083        })
1084    })
1085    .map_err(sqlite_error)?
1086    .collect::<Result<Vec<_>, _>>()
1087    .map_err(sqlite_error)
1088}
1089
1090fn raw_sqlite_message_to_record(raw: RawSqliteMessage) -> StoreResult<MessageRecord> {
1091    let content = serde_json::from_str::<MessageContent>(&raw.content_json)
1092        .map_err(|error| StoreError::internal(format!("decode message content: {error}")))?;
1093    let transaction = raw
1094        .transaction_json
1095        .as_deref()
1096        .map(serde_json::from_str::<TransactionIdentity>)
1097        .transpose()
1098        .map_err(|error| StoreError::internal(format!("decode transaction identity: {error}")))?;
1099    Ok(MessageRecord {
1100        chat_id: InlineId::new(raw.chat_id),
1101        message_id: InlineId::new(raw.message_id),
1102        sender_id: InlineId::new(raw.sender_id),
1103        timestamp: raw.timestamp,
1104        is_outgoing: raw.is_outgoing,
1105        content,
1106        reply_to_message_id: raw.reply_to_message_id.map(InlineId::new),
1107        transaction,
1108    })
1109}
1110
1111#[derive(Debug)]
1112struct RawSqliteTransaction {
1113    identity_json: String,
1114    state_json: String,
1115    chat_id: Option<i64>,
1116    message_id: Option<i64>,
1117    failure_json: Option<String>,
1118    created_at: i64,
1119    updated_at: i64,
1120}
1121
1122fn raw_sqlite_transaction_to_record(raw: RawSqliteTransaction) -> StoreResult<StoredTransaction> {
1123    let identity = serde_json::from_str::<TransactionIdentity>(&raw.identity_json)
1124        .map_err(|error| StoreError::internal(format!("decode transaction identity: {error}")))?;
1125    let state = serde_json::from_str::<TransactionState>(&raw.state_json)
1126        .map_err(|error| StoreError::internal(format!("decode transaction state: {error}")))?;
1127    let failure = raw
1128        .failure_json
1129        .as_deref()
1130        .map(serde_json::from_str::<ClientFailure>)
1131        .transpose()
1132        .map_err(|error| StoreError::internal(format!("decode transaction failure: {error}")))?;
1133    Ok(StoredTransaction {
1134        identity,
1135        state,
1136        chat_id: raw.chat_id.map(InlineId::new),
1137        message_id: raw.message_id.map(InlineId::new),
1138        failure,
1139        created_at: raw.created_at,
1140        updated_at: raw.updated_at,
1141    })
1142}
1143
1144fn sqlite_error(error: rusqlite::Error) -> StoreError {
1145    StoreError::internal(format!("sqlite store error: {error}"))
1146}
1147
1148fn now_seconds() -> i64 {
1149    SystemTime::now()
1150        .duration_since(UNIX_EPOCH)
1151        .map(|duration| duration.as_secs() as i64)
1152        .unwrap_or_default()
1153}
1154
1155fn all_users_from_memory(users: &HashMap<i64, UserRecord>) -> Vec<UserRecord> {
1156    let mut users = users.values().cloned().collect::<Vec<_>>();
1157    users.sort_by_key(|user| user.user_id.get());
1158    users
1159}
1160
1161fn max_message_id_from_memory(
1162    messages: &HashMap<i64, Vec<MessageRecord>>,
1163    chat_id: InlineId,
1164) -> Option<InlineId> {
1165    messages
1166        .get(&chat_id.get())?
1167        .iter()
1168        .map(|message| message.message_id.get())
1169        .max()
1170        .map(InlineId::new)
1171}
1172
1173fn dialog_with_synced_through(
1174    mut dialog: DialogRecord,
1175    synced_through_message_id: Option<InlineId>,
1176) -> DialogRecord {
1177    dialog.synced_through_message_id = synced_through_message_id;
1178    dialog
1179}
1180
1181fn users_for_messages_from_memory(
1182    users: &HashMap<i64, UserRecord>,
1183    messages: &[MessageRecord],
1184) -> Vec<UserRecord> {
1185    let mut user_ids = messages
1186        .iter()
1187        .map(|message| message.sender_id.get())
1188        .collect::<Vec<_>>();
1189    user_ids.sort_unstable();
1190    user_ids.dedup();
1191    user_ids
1192        .into_iter()
1193        .filter_map(|user_id| users.get(&user_id).cloned())
1194        .collect()
1195}
1196
1197fn upsert_dialog(dialogs: &mut Vec<DialogRecord>, dialog: DialogRecord) {
1198    if let Some(existing) = dialogs
1199        .iter_mut()
1200        .find(|existing| existing.chat_id == dialog.chat_id)
1201    {
1202        *existing = dialog;
1203        return;
1204    }
1205    dialogs.push(dialog);
1206}
1207
1208fn insert_message(messages: &mut HashMap<i64, Vec<MessageRecord>>, message: MessageRecord) {
1209    let chat_id = message.chat_id.get();
1210    messages.entry(chat_id).or_default().push(message);
1211    if let Some(messages) = messages.get_mut(&chat_id) {
1212        messages.sort_by_key(|message| (message.timestamp, message.message_id.get()));
1213    }
1214}
1215
1216/// Updates or inserts a dialog's last message pointer.
1217pub(crate) fn upsert_dialog_last_message(
1218    dialogs: &mut Vec<DialogRecord>,
1219    chat_id: InlineId,
1220    message_id: InlineId,
1221) {
1222    if let Some(dialog) = dialogs.iter_mut().find(|dialog| dialog.chat_id == chat_id) {
1223        dialog.last_message_id = Some(message_id);
1224        return;
1225    }
1226    dialogs.push(DialogRecord {
1227        chat_id,
1228        peer_user_id: None,
1229        title: None,
1230        last_message_id: Some(message_id),
1231        synced_through_message_id: None,
1232        unread_count: Some(0),
1233    });
1234}
1235
1236#[cfg(test)]
1237mod tests {
1238    use crate::{AuthToken, ExternalId, RandomId};
1239
1240    use super::*;
1241
1242    fn session() -> StoredSession {
1243        StoredSession {
1244            auth: AuthCredential::AccessToken {
1245                token: AuthToken::try_new("secret-token").unwrap(),
1246            },
1247            account_namespace: Some("team".to_owned()),
1248        }
1249    }
1250
1251    #[tokio::test]
1252    async fn stored_session_debug_redacts_token() {
1253        let rendered = format!("{:?}", session());
1254
1255        assert!(rendered.contains("[redacted]"));
1256        assert!(!rendered.contains("secret-token"));
1257    }
1258
1259    #[tokio::test]
1260    async fn in_memory_store_saves_and_clears_session() {
1261        let store = InMemoryStore::new();
1262        store.save_session(session()).await.unwrap();
1263
1264        assert!(store.load_session().await.unwrap().is_some());
1265
1266        store.clear_session().await.unwrap();
1267        assert!(store.load_session().await.unwrap().is_none());
1268    }
1269
1270    #[tokio::test]
1271    async fn in_memory_store_lists_dialogs_and_history() {
1272        let store = InMemoryStore::new();
1273        store.upsert_dialog(DialogRecord {
1274            chat_id: InlineId::new(7),
1275            peer_user_id: None,
1276            title: Some("general".to_owned()),
1277            last_message_id: None,
1278            synced_through_message_id: None,
1279            unread_count: Some(0),
1280        });
1281        store
1282            .record_users(vec![UserRecord {
1283                user_id: InlineId::new(2),
1284                display_name: Some("Ada Lovelace".to_owned()),
1285                username: Some("ada".to_owned()),
1286                first_name: Some("Ada".to_owned()),
1287                last_name: Some("Lovelace".to_owned()),
1288                avatar_url: None,
1289                is_bot: Some(false),
1290            }])
1291            .await
1292            .unwrap();
1293        store
1294            .record_message(MessageRecord {
1295                chat_id: InlineId::new(7),
1296                message_id: InlineId::new(1),
1297                sender_id: InlineId::new(2),
1298                timestamp: 1,
1299                is_outgoing: false,
1300                content: MessageContent::Text {
1301                    text: "hello".to_owned(),
1302                },
1303                reply_to_message_id: None,
1304                transaction: None,
1305            })
1306            .await
1307            .unwrap();
1308
1309        let dialogs = store.dialogs(DialogsRequest::default()).await.unwrap();
1310        assert_eq!(dialogs.dialogs.len(), 1);
1311        assert_eq!(
1312            dialogs.dialogs[0].synced_through_message_id,
1313            Some(InlineId::new(1))
1314        );
1315        assert_eq!(dialogs.users.len(), 1);
1316        assert_eq!(
1317            dialogs.users[0].display_name.as_deref(),
1318            Some("Ada Lovelace")
1319        );
1320
1321        let history = store
1322            .history(HistoryRequest {
1323                chat_id: InlineId::new(7),
1324                limit: Some(10),
1325                before_message_id: None,
1326                after_message_id: None,
1327            })
1328            .await
1329            .unwrap();
1330        assert_eq!(history.messages.len(), 1);
1331        assert_eq!(history.users.len(), 1);
1332        assert_eq!(history.users[0].user_id, InlineId::new(2));
1333    }
1334
1335    #[tokio::test]
1336    async fn in_memory_store_records_transactions() {
1337        let store = InMemoryStore::new();
1338        let transaction = stored_transaction("txn-mem", TransactionState::Queued);
1339
1340        store.record_transaction(transaction.clone()).await.unwrap();
1341
1342        let loaded = store
1343            .transaction(transaction.identity.transaction_id.clone())
1344            .await
1345            .unwrap()
1346            .unwrap();
1347        assert_eq!(
1348            loaded.identity.transaction_id,
1349            transaction.identity.transaction_id
1350        );
1351        assert_eq!(loaded.state, TransactionState::Queued);
1352    }
1353
1354    #[tokio::test]
1355    async fn in_memory_store_fetches_history_after_checkpoint() {
1356        let store = InMemoryStore::new();
1357        for message_id in 1..=3 {
1358            store
1359                .record_message(MessageRecord {
1360                    chat_id: InlineId::new(7),
1361                    message_id: InlineId::new(message_id),
1362                    sender_id: InlineId::new(2),
1363                    timestamp: message_id,
1364                    is_outgoing: false,
1365                    content: MessageContent::Text {
1366                        text: format!("message {message_id}"),
1367                    },
1368                    reply_to_message_id: None,
1369                    transaction: None,
1370                })
1371                .await
1372                .unwrap();
1373        }
1374
1375        let history = store
1376            .history(HistoryRequest {
1377                chat_id: InlineId::new(7),
1378                limit: Some(1),
1379                before_message_id: None,
1380                after_message_id: Some(InlineId::new(1)),
1381            })
1382            .await
1383            .unwrap();
1384
1385        assert_eq!(history.messages.len(), 1);
1386        assert_eq!(history.messages[0].message_id, InlineId::new(2));
1387        assert!(history.has_more);
1388    }
1389
1390    #[tokio::test]
1391    async fn sqlite_store_persists_session_dialogs_and_history() {
1392        let path = sqlite_temp_path("persist");
1393        let store = SqliteStore::open(&path).unwrap();
1394
1395        store.save_session(session()).await.unwrap();
1396        store
1397            .upsert_dialog(DialogRecord {
1398                chat_id: InlineId::new(7),
1399                peer_user_id: Some(InlineId::new(2)),
1400                title: Some("general".to_owned()),
1401                last_message_id: None,
1402                synced_through_message_id: None,
1403                unread_count: Some(3),
1404            })
1405            .unwrap();
1406        store
1407            .record_users(vec![UserRecord {
1408                user_id: InlineId::new(2),
1409                display_name: Some("Grace Hopper".to_owned()),
1410                username: Some("grace".to_owned()),
1411                first_name: Some("Grace".to_owned()),
1412                last_name: Some("Hopper".to_owned()),
1413                avatar_url: Some("https://cdn.inline.test/grace.jpg".to_owned()),
1414                is_bot: Some(false),
1415            }])
1416            .await
1417            .unwrap();
1418        store
1419            .record_message(MessageRecord {
1420                chat_id: InlineId::new(7),
1421                message_id: InlineId::new(10),
1422                sender_id: InlineId::new(2),
1423                timestamp: 100,
1424                is_outgoing: false,
1425                content: MessageContent::Text {
1426                    text: "persisted".to_owned(),
1427                },
1428                reply_to_message_id: None,
1429                transaction: None,
1430            })
1431            .await
1432            .unwrap();
1433        drop(store);
1434
1435        let reopened = SqliteStore::open(&path).unwrap();
1436        let session = reopened.load_session().await.unwrap().unwrap();
1437        assert_eq!(session.account_namespace.as_deref(), Some("team"));
1438        assert!(!format!("{reopened:?}").contains(path.to_string_lossy().as_ref()));
1439
1440        let dialogs = reopened.dialogs(DialogsRequest::default()).await.unwrap();
1441        assert_eq!(dialogs.dialogs.len(), 1);
1442        assert_eq!(dialogs.dialogs[0].chat_id, InlineId::new(7));
1443        assert_eq!(dialogs.dialogs[0].peer_user_id, Some(InlineId::new(2)));
1444        assert_eq!(dialogs.dialogs[0].last_message_id, Some(InlineId::new(10)));
1445        assert_eq!(
1446            dialogs.dialogs[0].synced_through_message_id,
1447            Some(InlineId::new(10))
1448        );
1449        assert_eq!(dialogs.users.len(), 1);
1450        assert_eq!(
1451            dialogs.users[0].display_name.as_deref(),
1452            Some("Grace Hopper")
1453        );
1454
1455        let history = reopened
1456            .history(HistoryRequest {
1457                chat_id: InlineId::new(7),
1458                limit: Some(10),
1459                before_message_id: None,
1460                after_message_id: None,
1461            })
1462            .await
1463            .unwrap();
1464        assert_eq!(history.messages.len(), 1);
1465        assert_eq!(history.messages[0].message_id, InlineId::new(10));
1466        assert!(matches!(
1467            history.messages[0].content,
1468            MessageContent::Text { ref text } if text == "persisted"
1469        ));
1470        assert_eq!(history.users.len(), 1);
1471        assert_eq!(
1472            history.users[0].avatar_url.as_deref(),
1473            Some("https://cdn.inline.test/grace.jpg")
1474        );
1475
1476        let _ = std::fs::remove_file(path);
1477    }
1478
1479    #[tokio::test]
1480    async fn sqlite_store_persists_transactions() {
1481        let path = sqlite_temp_path("transactions");
1482        let store = SqliteStore::open(&path).unwrap();
1483        let transaction = stored_transaction("txn-sqlite", TransactionState::Sent)
1484            .with_chat_id(InlineId::new(7))
1485            .with_message_id(InlineId::new(11));
1486
1487        store.record_transaction(transaction.clone()).await.unwrap();
1488        drop(store);
1489
1490        let reopened = SqliteStore::open(&path).unwrap();
1491        let loaded = reopened
1492            .transaction(transaction.identity.transaction_id.clone())
1493            .await
1494            .unwrap()
1495            .unwrap();
1496
1497        assert_eq!(
1498            loaded.identity.transaction_id,
1499            transaction.identity.transaction_id
1500        );
1501        assert_eq!(
1502            loaded.identity.external_id,
1503            transaction.identity.external_id
1504        );
1505        assert_eq!(loaded.state, TransactionState::Sent);
1506        assert_eq!(loaded.chat_id, Some(InlineId::new(7)));
1507        assert_eq!(loaded.message_id, Some(InlineId::new(11)));
1508
1509        let _ = std::fs::remove_file(path);
1510    }
1511
1512    #[tokio::test]
1513    async fn sqlite_store_replaces_messages_by_chat_and_message_id() {
1514        let store = SqliteStore::open_in_memory().unwrap();
1515
1516        store
1517            .record_message(MessageRecord {
1518                chat_id: InlineId::new(7),
1519                message_id: InlineId::new(10),
1520                sender_id: InlineId::new(2),
1521                timestamp: 100,
1522                is_outgoing: false,
1523                content: MessageContent::Text {
1524                    text: "first".to_owned(),
1525                },
1526                reply_to_message_id: None,
1527                transaction: None,
1528            })
1529            .await
1530            .unwrap();
1531        store
1532            .record_message(MessageRecord {
1533                chat_id: InlineId::new(7),
1534                message_id: InlineId::new(10),
1535                sender_id: InlineId::new(2),
1536                timestamp: 101,
1537                is_outgoing: false,
1538                content: MessageContent::Text {
1539                    text: "updated".to_owned(),
1540                },
1541                reply_to_message_id: None,
1542                transaction: None,
1543            })
1544            .await
1545            .unwrap();
1546
1547        let history = store
1548            .history(HistoryRequest {
1549                chat_id: InlineId::new(7),
1550                limit: Some(10),
1551                before_message_id: None,
1552                after_message_id: None,
1553            })
1554            .await
1555            .unwrap();
1556        assert_eq!(history.messages.len(), 1);
1557        assert!(matches!(
1558            history.messages[0].content,
1559            MessageContent::Text { ref text } if text == "updated"
1560        ));
1561    }
1562
1563    fn sqlite_temp_path(label: &str) -> std::path::PathBuf {
1564        let unique = SystemTime::now()
1565            .duration_since(UNIX_EPOCH)
1566            .unwrap()
1567            .as_nanos();
1568        std::env::temp_dir().join(format!(
1569            "inline-client-{label}-{}-{unique}.db",
1570            std::process::id()
1571        ))
1572    }
1573
1574    fn stored_transaction(id: &str, state: TransactionState) -> StoredTransaction {
1575        StoredTransaction::new(
1576            TransactionIdentity::new(
1577                TransactionId::try_new(id).unwrap(),
1578                Some(ExternalId::try_new("host-event", "event-1").unwrap()),
1579                RandomId::new(42),
1580            ),
1581            state,
1582        )
1583    }
1584}