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.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                    title: row.get::<_, Option<String>>(1)?,
506                    last_message_id: row.get::<_, Option<i64>>(2)?.map(InlineId::new),
507                    synced_through_message_id: row.get::<_, Option<i64>>(3)?.map(InlineId::new),
508                    unread_count: row
509                        .get::<_, Option<i64>>(4)?
510                        .and_then(|value| u32::try_from(value).ok()),
511                })
512            })
513            .map_err(sqlite_error)?
514            .collect::<Result<Vec<_>, _>>()
515            .map_err(sqlite_error)?
516        };
517
518        let has_more = rows.len() > limit;
519        let dialogs = rows.into_iter().take(limit).collect::<Vec<_>>();
520        let users = all_sqlite_users(&connection)?;
521        Ok(DialogsPage {
522            next_cursor: has_more.then(|| (start + dialogs.len()).to_string()),
523            dialogs,
524            users,
525        })
526    }
527
528    fn history_sync(&self, request: HistoryRequest) -> StoreResult<HistoryPage> {
529        let limit = request.limit.unwrap_or(50).max(1) as usize;
530        let connection = self.connection.lock().expect("sqlite store poisoned");
531        let rows = if request.before_message_id.is_some() && request.after_message_id.is_some() {
532            return Err(StoreError::new(
533                ClientErrorCategory::InvalidInput,
534                "history request cannot specify both before_message_id and after_message_id",
535            ));
536        } else if let Some(before) = request.before_message_id {
537            let mut stmt = connection
538                .prepare(
539                    "SELECT chat_id, message_id, sender_id, timestamp, is_outgoing,
540                            content_json, reply_to_message_id, transaction_json
541                     FROM messages
542                     WHERE chat_id = ?1 AND message_id < ?2
543                     ORDER BY message_id DESC
544                     LIMIT ?3",
545                )
546                .map_err(sqlite_error)?;
547            query_message_rows(
548                &mut stmt,
549                params![request.chat_id.get(), before.get(), (limit + 1) as i64],
550            )?
551        } else if let Some(after) = request.after_message_id {
552            let mut stmt = connection
553                .prepare(
554                    "SELECT chat_id, message_id, sender_id, timestamp, is_outgoing,
555                            content_json, reply_to_message_id, transaction_json
556                     FROM messages
557                     WHERE chat_id = ?1 AND message_id > ?2
558                     ORDER BY message_id ASC
559                     LIMIT ?3",
560                )
561                .map_err(sqlite_error)?;
562            query_message_rows(
563                &mut stmt,
564                params![request.chat_id.get(), after.get(), (limit + 1) as i64],
565            )?
566        } else {
567            let mut stmt = connection
568                .prepare(
569                    "SELECT chat_id, message_id, sender_id, timestamp, is_outgoing,
570                            content_json, reply_to_message_id, transaction_json
571                     FROM messages
572                     WHERE chat_id = ?1
573                     ORDER BY message_id DESC
574                     LIMIT ?2",
575                )
576                .map_err(sqlite_error)?;
577            query_message_rows(
578                &mut stmt,
579                params![request.chat_id.get(), (limit + 1) as i64],
580            )?
581        };
582
583        let has_more = rows.len() > limit;
584        let mut messages = rows
585            .into_iter()
586            .take(limit)
587            .map(raw_sqlite_message_to_record)
588            .collect::<StoreResult<Vec<_>>>()?;
589        messages.sort_by_key(|message| (message.timestamp, message.message_id.get()));
590        let users = sqlite_users_for_messages(&connection, &messages)?;
591        Ok(HistoryPage {
592            messages,
593            users,
594            has_more,
595            next_cursor: None,
596        })
597    }
598
599    fn record_users_sync(&self, users: Vec<UserRecord>) -> StoreResult<()> {
600        if users.is_empty() {
601            return Ok(());
602        }
603        let connection = self.connection.lock().expect("sqlite store poisoned");
604        for user in users {
605            upsert_sqlite_user(&connection, user)?;
606        }
607        Ok(())
608    }
609
610    fn record_message_sync(&self, message: MessageRecord) -> StoreResult<()> {
611        let content_json = serde_json::to_string(&message.content)
612            .map_err(|error| StoreError::internal(format!("encode message content: {error}")))?;
613        let transaction_json = message
614            .transaction
615            .as_ref()
616            .map(serde_json::to_string)
617            .transpose()
618            .map_err(|error| {
619                StoreError::internal(format!("encode transaction identity: {error}"))
620            })?;
621        let connection = self.connection.lock().expect("sqlite store poisoned");
622        connection
623            .execute(
624                "INSERT INTO messages (
625                   chat_id, message_id, sender_id, timestamp, is_outgoing,
626                   content_json, reply_to_message_id, transaction_json
627                 )
628                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
629                 ON CONFLICT(chat_id, message_id) DO UPDATE SET
630                   sender_id = excluded.sender_id,
631                   timestamp = excluded.timestamp,
632                   is_outgoing = excluded.is_outgoing,
633                   content_json = excluded.content_json,
634                   reply_to_message_id = excluded.reply_to_message_id,
635                   transaction_json = excluded.transaction_json",
636                params![
637                    message.chat_id.get(),
638                    message.message_id.get(),
639                    message.sender_id.get(),
640                    message.timestamp,
641                    i64::from(message.is_outgoing),
642                    content_json,
643                    message.reply_to_message_id.map(InlineId::get),
644                    transaction_json,
645                ],
646            )
647            .map_err(sqlite_error)?;
648        upsert_sqlite_dialog_last_message(&connection, message.chat_id, message.message_id)?;
649        Ok(())
650    }
651
652    fn record_transaction_sync(&self, transaction: StoredTransaction) -> StoreResult<()> {
653        let identity_json = serde_json::to_string(&transaction.identity).map_err(|error| {
654            StoreError::internal(format!("encode transaction identity: {error}"))
655        })?;
656        let state_json = serde_json::to_string(&transaction.state)
657            .map_err(|error| StoreError::internal(format!("encode transaction state: {error}")))?;
658        let failure_json = transaction
659            .failure
660            .as_ref()
661            .map(serde_json::to_string)
662            .transpose()
663            .map_err(|error| {
664                StoreError::internal(format!("encode transaction failure: {error}"))
665            })?;
666        let connection = self.connection.lock().expect("sqlite store poisoned");
667        connection
668            .execute(
669                "INSERT INTO transactions (
670                   transaction_id, identity_json, state_json, chat_id, message_id,
671                   random_id, external_source, external_id, failure_json, created_at, updated_at
672                 )
673                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
674                 ON CONFLICT(transaction_id) DO UPDATE SET
675                   identity_json = excluded.identity_json,
676                   state_json = excluded.state_json,
677                   chat_id = excluded.chat_id,
678                   message_id = excluded.message_id,
679                   random_id = excluded.random_id,
680                   external_source = excluded.external_source,
681                   external_id = excluded.external_id,
682                   failure_json = excluded.failure_json,
683                   updated_at = excluded.updated_at",
684                params![
685                    transaction.identity.transaction_id.as_str(),
686                    identity_json,
687                    state_json,
688                    transaction.chat_id.map(InlineId::get),
689                    transaction.message_id.map(InlineId::get),
690                    transaction.identity.random_id.get(),
691                    transaction
692                        .identity
693                        .external_id
694                        .as_ref()
695                        .map(|external| external.source()),
696                    transaction
697                        .identity
698                        .external_id
699                        .as_ref()
700                        .map(|external| external.id()),
701                    failure_json,
702                    transaction.created_at,
703                    transaction.updated_at,
704                ],
705            )
706            .map_err(sqlite_error)?;
707        Ok(())
708    }
709
710    fn transaction_sync(
711        &self,
712        transaction_id: TransactionId,
713    ) -> StoreResult<Option<StoredTransaction>> {
714        let connection = self.connection.lock().expect("sqlite store poisoned");
715        let row = connection
716            .query_row(
717                "SELECT identity_json, state_json, chat_id, message_id,
718                        failure_json, created_at, updated_at
719                 FROM transactions
720                 WHERE transaction_id = ?1",
721                params![transaction_id.as_str()],
722                |row| {
723                    Ok(RawSqliteTransaction {
724                        identity_json: row.get(0)?,
725                        state_json: row.get(1)?,
726                        chat_id: row.get(2)?,
727                        message_id: row.get(3)?,
728                        failure_json: row.get(4)?,
729                        created_at: row.get(5)?,
730                        updated_at: row.get(6)?,
731                    })
732                },
733            )
734            .optional()
735            .map_err(sqlite_error)?;
736
737        row.map(raw_sqlite_transaction_to_record).transpose()
738    }
739}
740
741impl ClientStore for SqliteStore {
742    fn save_session(&self, session: StoredSession) -> BoxFuture<'static, StoreResult<()>> {
743        let store = self.clone();
744        Box::pin(async move { store.save_session_sync(session) })
745    }
746
747    fn load_session(&self) -> BoxFuture<'static, StoreResult<Option<StoredSession>>> {
748        let store = self.clone();
749        Box::pin(async move { store.load_session_sync() })
750    }
751
752    fn clear_session(&self) -> BoxFuture<'static, StoreResult<()>> {
753        let store = self.clone();
754        Box::pin(async move { store.clear_session_sync() })
755    }
756
757    fn dialogs(&self, request: DialogsRequest) -> BoxFuture<'static, StoreResult<DialogsPage>> {
758        let store = self.clone();
759        Box::pin(async move { store.dialogs_sync(request) })
760    }
761
762    fn record_dialog(&self, dialog: DialogRecord) -> BoxFuture<'static, StoreResult<()>> {
763        let store = self.clone();
764        Box::pin(async move { store.upsert_dialog(dialog) })
765    }
766
767    fn record_users(&self, users: Vec<UserRecord>) -> BoxFuture<'static, StoreResult<()>> {
768        let store = self.clone();
769        Box::pin(async move { store.record_users_sync(users) })
770    }
771
772    fn history(&self, request: HistoryRequest) -> BoxFuture<'static, StoreResult<HistoryPage>> {
773        let store = self.clone();
774        Box::pin(async move { store.history_sync(request) })
775    }
776
777    fn record_message(&self, message: MessageRecord) -> BoxFuture<'static, StoreResult<()>> {
778        let store = self.clone();
779        Box::pin(async move { store.record_message_sync(message) })
780    }
781
782    fn record_transaction(
783        &self,
784        transaction: StoredTransaction,
785    ) -> BoxFuture<'static, StoreResult<()>> {
786        let store = self.clone();
787        Box::pin(async move { store.record_transaction_sync(transaction) })
788    }
789
790    fn transaction(
791        &self,
792        transaction_id: TransactionId,
793    ) -> BoxFuture<'static, StoreResult<Option<StoredTransaction>>> {
794        let store = self.clone();
795        Box::pin(async move { store.transaction_sync(transaction_id) })
796    }
797}
798
799pub(crate) fn parse_cursor(cursor: Option<&str>) -> StoreResult<usize> {
800    match cursor {
801        Some(cursor) if !cursor.trim().is_empty() => cursor.parse::<usize>().map_err(|_| {
802            StoreError::new(
803                ClientErrorCategory::InvalidInput,
804                "invalid pagination cursor",
805            )
806        }),
807        _ => Ok(0),
808    }
809}
810
811fn migrate_sqlite(connection: &Connection) -> StoreResult<()> {
812    connection
813        .execute_batch(
814            "
815            PRAGMA foreign_keys = ON;
816
817            CREATE TABLE IF NOT EXISTS sessions (
818                id INTEGER PRIMARY KEY CHECK (id = 1),
819                auth_json TEXT NOT NULL,
820                account_namespace TEXT,
821                updated_at INTEGER NOT NULL
822            );
823
824            CREATE TABLE IF NOT EXISTS dialogs (
825                chat_id INTEGER PRIMARY KEY,
826                title TEXT,
827                last_message_id INTEGER,
828                unread_count INTEGER,
829                updated_at INTEGER NOT NULL
830            );
831
832            CREATE TABLE IF NOT EXISTS users (
833                user_id INTEGER PRIMARY KEY,
834                display_name TEXT,
835                username TEXT,
836                first_name TEXT,
837                last_name TEXT,
838                avatar_url TEXT,
839                is_bot INTEGER,
840                updated_at INTEGER NOT NULL
841            );
842
843            CREATE TABLE IF NOT EXISTS messages (
844                chat_id INTEGER NOT NULL,
845                message_id INTEGER NOT NULL,
846                sender_id INTEGER NOT NULL,
847                timestamp INTEGER NOT NULL,
848                is_outgoing INTEGER NOT NULL,
849                content_json TEXT NOT NULL,
850                reply_to_message_id INTEGER,
851                transaction_json TEXT,
852                PRIMARY KEY (chat_id, message_id)
853            );
854
855            CREATE INDEX IF NOT EXISTS idx_messages_chat_message
856                ON messages (chat_id, message_id DESC);
857
858            CREATE TABLE IF NOT EXISTS transactions (
859                transaction_id TEXT PRIMARY KEY,
860                identity_json TEXT NOT NULL,
861                state_json TEXT NOT NULL,
862                chat_id INTEGER,
863                message_id INTEGER,
864                random_id INTEGER NOT NULL,
865                external_source TEXT,
866                external_id TEXT,
867                failure_json TEXT,
868                created_at INTEGER NOT NULL,
869                updated_at INTEGER NOT NULL
870            );
871
872            CREATE INDEX IF NOT EXISTS idx_transactions_external
873                ON transactions (external_source, external_id);
874            CREATE INDEX IF NOT EXISTS idx_transactions_random
875                ON transactions (random_id);
876            ",
877        )
878        .map_err(sqlite_error)?;
879    Ok(())
880}
881
882fn upsert_sqlite_user(connection: &Connection, user: UserRecord) -> StoreResult<()> {
883    connection
884        .execute(
885            "INSERT INTO users (
886               user_id, display_name, username, first_name, last_name,
887               avatar_url, is_bot, updated_at
888             )
889             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
890             ON CONFLICT(user_id) DO UPDATE SET
891               display_name = excluded.display_name,
892               username = excluded.username,
893               first_name = excluded.first_name,
894               last_name = excluded.last_name,
895               avatar_url = excluded.avatar_url,
896               is_bot = excluded.is_bot,
897               updated_at = excluded.updated_at",
898            params![
899                user.user_id.get(),
900                user.display_name,
901                user.username,
902                user.first_name,
903                user.last_name,
904                user.avatar_url,
905                user.is_bot.map(|is_bot| if is_bot { 1_i64 } else { 0_i64 }),
906                now_seconds(),
907            ],
908        )
909        .map_err(sqlite_error)?;
910    Ok(())
911}
912
913fn upsert_sqlite_dialog(connection: &Connection, dialog: DialogRecord) -> StoreResult<()> {
914    connection
915        .execute(
916            "INSERT INTO dialogs (chat_id, title, last_message_id, unread_count, updated_at)
917             VALUES (?1, ?2, ?3, ?4, ?5)
918             ON CONFLICT(chat_id) DO UPDATE SET
919               title = excluded.title,
920               last_message_id = excluded.last_message_id,
921               unread_count = excluded.unread_count,
922               updated_at = excluded.updated_at",
923            params![
924                dialog.chat_id.get(),
925                dialog.title,
926                dialog.last_message_id.map(InlineId::get),
927                dialog.unread_count.map(i64::from),
928                now_seconds(),
929            ],
930        )
931        .map_err(sqlite_error)?;
932    Ok(())
933}
934
935fn upsert_sqlite_dialog_last_message(
936    connection: &Connection,
937    chat_id: InlineId,
938    message_id: InlineId,
939) -> StoreResult<()> {
940    let updated = connection
941        .execute(
942            "UPDATE dialogs
943             SET last_message_id = ?1, updated_at = ?2
944             WHERE chat_id = ?3",
945            params![message_id.get(), now_seconds(), chat_id.get()],
946        )
947        .map_err(sqlite_error)?;
948    if updated == 0 {
949        connection
950            .execute(
951                "INSERT INTO dialogs (chat_id, title, last_message_id, unread_count, updated_at)
952                 VALUES (?1, NULL, ?2, 0, ?3)",
953                params![chat_id.get(), message_id.get(), now_seconds()],
954            )
955            .map_err(sqlite_error)?;
956    }
957    Ok(())
958}
959
960fn all_sqlite_users(connection: &Connection) -> StoreResult<Vec<UserRecord>> {
961    let mut stmt = connection
962        .prepare(
963            "SELECT user_id, display_name, username, first_name, last_name, avatar_url, is_bot
964             FROM users
965             ORDER BY user_id ASC",
966        )
967        .map_err(sqlite_error)?;
968    query_user_rows(&mut stmt, [])
969}
970
971fn sqlite_users_for_messages(
972    connection: &Connection,
973    messages: &[MessageRecord],
974) -> StoreResult<Vec<UserRecord>> {
975    let mut user_ids = messages
976        .iter()
977        .map(|message| message.sender_id.get())
978        .collect::<Vec<_>>();
979    user_ids.sort_unstable();
980    user_ids.dedup();
981
982    let mut users = Vec::new();
983    let mut stmt = connection
984        .prepare(
985            "SELECT user_id, display_name, username, first_name, last_name, avatar_url, is_bot
986             FROM users
987             WHERE user_id = ?1",
988        )
989        .map_err(sqlite_error)?;
990    for user_id in user_ids {
991        if let Some(user) = stmt
992            .query_row(params![user_id], sqlite_user_from_row)
993            .optional()
994            .map_err(sqlite_error)?
995        {
996            users.push(user);
997        }
998    }
999    Ok(users)
1000}
1001
1002fn query_user_rows<P>(stmt: &mut rusqlite::Statement<'_>, params: P) -> StoreResult<Vec<UserRecord>>
1003where
1004    P: rusqlite::Params,
1005{
1006    stmt.query_map(params, sqlite_user_from_row)
1007        .map_err(sqlite_error)?
1008        .collect::<Result<Vec<_>, _>>()
1009        .map_err(sqlite_error)
1010}
1011
1012fn sqlite_user_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<UserRecord> {
1013    Ok(UserRecord {
1014        user_id: InlineId::new(row.get::<_, i64>(0)?),
1015        display_name: row.get::<_, Option<String>>(1)?,
1016        username: row.get::<_, Option<String>>(2)?,
1017        first_name: row.get::<_, Option<String>>(3)?,
1018        last_name: row.get::<_, Option<String>>(4)?,
1019        avatar_url: row.get::<_, Option<String>>(5)?,
1020        is_bot: row.get::<_, Option<i64>>(6)?.map(|value| value != 0),
1021    })
1022}
1023
1024#[derive(Debug)]
1025struct RawSqliteMessage {
1026    chat_id: i64,
1027    message_id: i64,
1028    sender_id: i64,
1029    timestamp: i64,
1030    is_outgoing: bool,
1031    content_json: String,
1032    reply_to_message_id: Option<i64>,
1033    transaction_json: Option<String>,
1034}
1035
1036fn query_message_rows<P>(
1037    stmt: &mut rusqlite::Statement<'_>,
1038    params: P,
1039) -> StoreResult<Vec<RawSqliteMessage>>
1040where
1041    P: rusqlite::Params,
1042{
1043    stmt.query_map(params, |row| {
1044        Ok(RawSqliteMessage {
1045            chat_id: row.get(0)?,
1046            message_id: row.get(1)?,
1047            sender_id: row.get(2)?,
1048            timestamp: row.get(3)?,
1049            is_outgoing: row.get::<_, i64>(4)? != 0,
1050            content_json: row.get(5)?,
1051            reply_to_message_id: row.get(6)?,
1052            transaction_json: row.get(7)?,
1053        })
1054    })
1055    .map_err(sqlite_error)?
1056    .collect::<Result<Vec<_>, _>>()
1057    .map_err(sqlite_error)
1058}
1059
1060fn raw_sqlite_message_to_record(raw: RawSqliteMessage) -> StoreResult<MessageRecord> {
1061    let content = serde_json::from_str::<MessageContent>(&raw.content_json)
1062        .map_err(|error| StoreError::internal(format!("decode message content: {error}")))?;
1063    let transaction = raw
1064        .transaction_json
1065        .as_deref()
1066        .map(serde_json::from_str::<TransactionIdentity>)
1067        .transpose()
1068        .map_err(|error| StoreError::internal(format!("decode transaction identity: {error}")))?;
1069    Ok(MessageRecord {
1070        chat_id: InlineId::new(raw.chat_id),
1071        message_id: InlineId::new(raw.message_id),
1072        sender_id: InlineId::new(raw.sender_id),
1073        timestamp: raw.timestamp,
1074        is_outgoing: raw.is_outgoing,
1075        content,
1076        reply_to_message_id: raw.reply_to_message_id.map(InlineId::new),
1077        transaction,
1078    })
1079}
1080
1081#[derive(Debug)]
1082struct RawSqliteTransaction {
1083    identity_json: String,
1084    state_json: String,
1085    chat_id: Option<i64>,
1086    message_id: Option<i64>,
1087    failure_json: Option<String>,
1088    created_at: i64,
1089    updated_at: i64,
1090}
1091
1092fn raw_sqlite_transaction_to_record(raw: RawSqliteTransaction) -> StoreResult<StoredTransaction> {
1093    let identity = serde_json::from_str::<TransactionIdentity>(&raw.identity_json)
1094        .map_err(|error| StoreError::internal(format!("decode transaction identity: {error}")))?;
1095    let state = serde_json::from_str::<TransactionState>(&raw.state_json)
1096        .map_err(|error| StoreError::internal(format!("decode transaction state: {error}")))?;
1097    let failure = raw
1098        .failure_json
1099        .as_deref()
1100        .map(serde_json::from_str::<ClientFailure>)
1101        .transpose()
1102        .map_err(|error| StoreError::internal(format!("decode transaction failure: {error}")))?;
1103    Ok(StoredTransaction {
1104        identity,
1105        state,
1106        chat_id: raw.chat_id.map(InlineId::new),
1107        message_id: raw.message_id.map(InlineId::new),
1108        failure,
1109        created_at: raw.created_at,
1110        updated_at: raw.updated_at,
1111    })
1112}
1113
1114fn sqlite_error(error: rusqlite::Error) -> StoreError {
1115    StoreError::internal(format!("sqlite store error: {error}"))
1116}
1117
1118fn now_seconds() -> i64 {
1119    SystemTime::now()
1120        .duration_since(UNIX_EPOCH)
1121        .map(|duration| duration.as_secs() as i64)
1122        .unwrap_or_default()
1123}
1124
1125fn all_users_from_memory(users: &HashMap<i64, UserRecord>) -> Vec<UserRecord> {
1126    let mut users = users.values().cloned().collect::<Vec<_>>();
1127    users.sort_by_key(|user| user.user_id.get());
1128    users
1129}
1130
1131fn max_message_id_from_memory(
1132    messages: &HashMap<i64, Vec<MessageRecord>>,
1133    chat_id: InlineId,
1134) -> Option<InlineId> {
1135    messages
1136        .get(&chat_id.get())?
1137        .iter()
1138        .map(|message| message.message_id.get())
1139        .max()
1140        .map(InlineId::new)
1141}
1142
1143fn dialog_with_synced_through(
1144    mut dialog: DialogRecord,
1145    synced_through_message_id: Option<InlineId>,
1146) -> DialogRecord {
1147    dialog.synced_through_message_id = synced_through_message_id;
1148    dialog
1149}
1150
1151fn users_for_messages_from_memory(
1152    users: &HashMap<i64, UserRecord>,
1153    messages: &[MessageRecord],
1154) -> Vec<UserRecord> {
1155    let mut user_ids = messages
1156        .iter()
1157        .map(|message| message.sender_id.get())
1158        .collect::<Vec<_>>();
1159    user_ids.sort_unstable();
1160    user_ids.dedup();
1161    user_ids
1162        .into_iter()
1163        .filter_map(|user_id| users.get(&user_id).cloned())
1164        .collect()
1165}
1166
1167fn upsert_dialog(dialogs: &mut Vec<DialogRecord>, dialog: DialogRecord) {
1168    if let Some(existing) = dialogs
1169        .iter_mut()
1170        .find(|existing| existing.chat_id == dialog.chat_id)
1171    {
1172        *existing = dialog;
1173        return;
1174    }
1175    dialogs.push(dialog);
1176}
1177
1178fn insert_message(messages: &mut HashMap<i64, Vec<MessageRecord>>, message: MessageRecord) {
1179    let chat_id = message.chat_id.get();
1180    messages.entry(chat_id).or_default().push(message);
1181    if let Some(messages) = messages.get_mut(&chat_id) {
1182        messages.sort_by_key(|message| (message.timestamp, message.message_id.get()));
1183    }
1184}
1185
1186/// Updates or inserts a dialog's last message pointer.
1187pub(crate) fn upsert_dialog_last_message(
1188    dialogs: &mut Vec<DialogRecord>,
1189    chat_id: InlineId,
1190    message_id: InlineId,
1191) {
1192    if let Some(dialog) = dialogs.iter_mut().find(|dialog| dialog.chat_id == chat_id) {
1193        dialog.last_message_id = Some(message_id);
1194        return;
1195    }
1196    dialogs.push(DialogRecord {
1197        chat_id,
1198        title: None,
1199        last_message_id: Some(message_id),
1200        synced_through_message_id: None,
1201        unread_count: Some(0),
1202    });
1203}
1204
1205#[cfg(test)]
1206mod tests {
1207    use crate::{AuthToken, ExternalId, RandomId};
1208
1209    use super::*;
1210
1211    fn session() -> StoredSession {
1212        StoredSession {
1213            auth: AuthCredential::AccessToken {
1214                token: AuthToken::try_new("secret-token").unwrap(),
1215            },
1216            account_namespace: Some("team".to_owned()),
1217        }
1218    }
1219
1220    #[tokio::test]
1221    async fn stored_session_debug_redacts_token() {
1222        let rendered = format!("{:?}", session());
1223
1224        assert!(rendered.contains("[redacted]"));
1225        assert!(!rendered.contains("secret-token"));
1226    }
1227
1228    #[tokio::test]
1229    async fn in_memory_store_saves_and_clears_session() {
1230        let store = InMemoryStore::new();
1231        store.save_session(session()).await.unwrap();
1232
1233        assert!(store.load_session().await.unwrap().is_some());
1234
1235        store.clear_session().await.unwrap();
1236        assert!(store.load_session().await.unwrap().is_none());
1237    }
1238
1239    #[tokio::test]
1240    async fn in_memory_store_lists_dialogs_and_history() {
1241        let store = InMemoryStore::new();
1242        store.upsert_dialog(DialogRecord {
1243            chat_id: InlineId::new(7),
1244            title: Some("general".to_owned()),
1245            last_message_id: None,
1246            synced_through_message_id: None,
1247            unread_count: Some(0),
1248        });
1249        store
1250            .record_users(vec![UserRecord {
1251                user_id: InlineId::new(2),
1252                display_name: Some("Ada Lovelace".to_owned()),
1253                username: Some("ada".to_owned()),
1254                first_name: Some("Ada".to_owned()),
1255                last_name: Some("Lovelace".to_owned()),
1256                avatar_url: None,
1257                is_bot: Some(false),
1258            }])
1259            .await
1260            .unwrap();
1261        store
1262            .record_message(MessageRecord {
1263                chat_id: InlineId::new(7),
1264                message_id: InlineId::new(1),
1265                sender_id: InlineId::new(2),
1266                timestamp: 1,
1267                is_outgoing: false,
1268                content: MessageContent::Text {
1269                    text: "hello".to_owned(),
1270                },
1271                reply_to_message_id: None,
1272                transaction: None,
1273            })
1274            .await
1275            .unwrap();
1276
1277        let dialogs = store.dialogs(DialogsRequest::default()).await.unwrap();
1278        assert_eq!(dialogs.dialogs.len(), 1);
1279        assert_eq!(
1280            dialogs.dialogs[0].synced_through_message_id,
1281            Some(InlineId::new(1))
1282        );
1283        assert_eq!(dialogs.users.len(), 1);
1284        assert_eq!(
1285            dialogs.users[0].display_name.as_deref(),
1286            Some("Ada Lovelace")
1287        );
1288
1289        let history = store
1290            .history(HistoryRequest {
1291                chat_id: InlineId::new(7),
1292                limit: Some(10),
1293                before_message_id: None,
1294                after_message_id: None,
1295            })
1296            .await
1297            .unwrap();
1298        assert_eq!(history.messages.len(), 1);
1299        assert_eq!(history.users.len(), 1);
1300        assert_eq!(history.users[0].user_id, InlineId::new(2));
1301    }
1302
1303    #[tokio::test]
1304    async fn in_memory_store_records_transactions() {
1305        let store = InMemoryStore::new();
1306        let transaction = stored_transaction("txn-mem", TransactionState::Queued);
1307
1308        store.record_transaction(transaction.clone()).await.unwrap();
1309
1310        let loaded = store
1311            .transaction(transaction.identity.transaction_id.clone())
1312            .await
1313            .unwrap()
1314            .unwrap();
1315        assert_eq!(
1316            loaded.identity.transaction_id,
1317            transaction.identity.transaction_id
1318        );
1319        assert_eq!(loaded.state, TransactionState::Queued);
1320    }
1321
1322    #[tokio::test]
1323    async fn in_memory_store_fetches_history_after_checkpoint() {
1324        let store = InMemoryStore::new();
1325        for message_id in 1..=3 {
1326            store
1327                .record_message(MessageRecord {
1328                    chat_id: InlineId::new(7),
1329                    message_id: InlineId::new(message_id),
1330                    sender_id: InlineId::new(2),
1331                    timestamp: message_id,
1332                    is_outgoing: false,
1333                    content: MessageContent::Text {
1334                        text: format!("message {message_id}"),
1335                    },
1336                    reply_to_message_id: None,
1337                    transaction: None,
1338                })
1339                .await
1340                .unwrap();
1341        }
1342
1343        let history = store
1344            .history(HistoryRequest {
1345                chat_id: InlineId::new(7),
1346                limit: Some(1),
1347                before_message_id: None,
1348                after_message_id: Some(InlineId::new(1)),
1349            })
1350            .await
1351            .unwrap();
1352
1353        assert_eq!(history.messages.len(), 1);
1354        assert_eq!(history.messages[0].message_id, InlineId::new(2));
1355        assert!(history.has_more);
1356    }
1357
1358    #[tokio::test]
1359    async fn sqlite_store_persists_session_dialogs_and_history() {
1360        let path = sqlite_temp_path("persist");
1361        let store = SqliteStore::open(&path).unwrap();
1362
1363        store.save_session(session()).await.unwrap();
1364        store
1365            .upsert_dialog(DialogRecord {
1366                chat_id: InlineId::new(7),
1367                title: Some("general".to_owned()),
1368                last_message_id: None,
1369                synced_through_message_id: None,
1370                unread_count: Some(3),
1371            })
1372            .unwrap();
1373        store
1374            .record_users(vec![UserRecord {
1375                user_id: InlineId::new(2),
1376                display_name: Some("Grace Hopper".to_owned()),
1377                username: Some("grace".to_owned()),
1378                first_name: Some("Grace".to_owned()),
1379                last_name: Some("Hopper".to_owned()),
1380                avatar_url: Some("https://cdn.inline.test/grace.jpg".to_owned()),
1381                is_bot: Some(false),
1382            }])
1383            .await
1384            .unwrap();
1385        store
1386            .record_message(MessageRecord {
1387                chat_id: InlineId::new(7),
1388                message_id: InlineId::new(10),
1389                sender_id: InlineId::new(2),
1390                timestamp: 100,
1391                is_outgoing: false,
1392                content: MessageContent::Text {
1393                    text: "persisted".to_owned(),
1394                },
1395                reply_to_message_id: None,
1396                transaction: None,
1397            })
1398            .await
1399            .unwrap();
1400        drop(store);
1401
1402        let reopened = SqliteStore::open(&path).unwrap();
1403        let session = reopened.load_session().await.unwrap().unwrap();
1404        assert_eq!(session.account_namespace.as_deref(), Some("team"));
1405        assert!(!format!("{reopened:?}").contains(path.to_string_lossy().as_ref()));
1406
1407        let dialogs = reopened.dialogs(DialogsRequest::default()).await.unwrap();
1408        assert_eq!(dialogs.dialogs.len(), 1);
1409        assert_eq!(dialogs.dialogs[0].chat_id, InlineId::new(7));
1410        assert_eq!(dialogs.dialogs[0].last_message_id, Some(InlineId::new(10)));
1411        assert_eq!(
1412            dialogs.dialogs[0].synced_through_message_id,
1413            Some(InlineId::new(10))
1414        );
1415        assert_eq!(dialogs.users.len(), 1);
1416        assert_eq!(
1417            dialogs.users[0].display_name.as_deref(),
1418            Some("Grace Hopper")
1419        );
1420
1421        let history = reopened
1422            .history(HistoryRequest {
1423                chat_id: InlineId::new(7),
1424                limit: Some(10),
1425                before_message_id: None,
1426                after_message_id: None,
1427            })
1428            .await
1429            .unwrap();
1430        assert_eq!(history.messages.len(), 1);
1431        assert_eq!(history.messages[0].message_id, InlineId::new(10));
1432        assert!(matches!(
1433            history.messages[0].content,
1434            MessageContent::Text { ref text } if text == "persisted"
1435        ));
1436        assert_eq!(history.users.len(), 1);
1437        assert_eq!(
1438            history.users[0].avatar_url.as_deref(),
1439            Some("https://cdn.inline.test/grace.jpg")
1440        );
1441
1442        let _ = std::fs::remove_file(path);
1443    }
1444
1445    #[tokio::test]
1446    async fn sqlite_store_persists_transactions() {
1447        let path = sqlite_temp_path("transactions");
1448        let store = SqliteStore::open(&path).unwrap();
1449        let transaction = stored_transaction("txn-sqlite", TransactionState::Sent)
1450            .with_chat_id(InlineId::new(7))
1451            .with_message_id(InlineId::new(11));
1452
1453        store.record_transaction(transaction.clone()).await.unwrap();
1454        drop(store);
1455
1456        let reopened = SqliteStore::open(&path).unwrap();
1457        let loaded = reopened
1458            .transaction(transaction.identity.transaction_id.clone())
1459            .await
1460            .unwrap()
1461            .unwrap();
1462
1463        assert_eq!(
1464            loaded.identity.transaction_id,
1465            transaction.identity.transaction_id
1466        );
1467        assert_eq!(
1468            loaded.identity.external_id,
1469            transaction.identity.external_id
1470        );
1471        assert_eq!(loaded.state, TransactionState::Sent);
1472        assert_eq!(loaded.chat_id, Some(InlineId::new(7)));
1473        assert_eq!(loaded.message_id, Some(InlineId::new(11)));
1474
1475        let _ = std::fs::remove_file(path);
1476    }
1477
1478    #[tokio::test]
1479    async fn sqlite_store_replaces_messages_by_chat_and_message_id() {
1480        let store = SqliteStore::open_in_memory().unwrap();
1481
1482        store
1483            .record_message(MessageRecord {
1484                chat_id: InlineId::new(7),
1485                message_id: InlineId::new(10),
1486                sender_id: InlineId::new(2),
1487                timestamp: 100,
1488                is_outgoing: false,
1489                content: MessageContent::Text {
1490                    text: "first".to_owned(),
1491                },
1492                reply_to_message_id: None,
1493                transaction: None,
1494            })
1495            .await
1496            .unwrap();
1497        store
1498            .record_message(MessageRecord {
1499                chat_id: InlineId::new(7),
1500                message_id: InlineId::new(10),
1501                sender_id: InlineId::new(2),
1502                timestamp: 101,
1503                is_outgoing: false,
1504                content: MessageContent::Text {
1505                    text: "updated".to_owned(),
1506                },
1507                reply_to_message_id: None,
1508                transaction: None,
1509            })
1510            .await
1511            .unwrap();
1512
1513        let history = store
1514            .history(HistoryRequest {
1515                chat_id: InlineId::new(7),
1516                limit: Some(10),
1517                before_message_id: None,
1518                after_message_id: None,
1519            })
1520            .await
1521            .unwrap();
1522        assert_eq!(history.messages.len(), 1);
1523        assert!(matches!(
1524            history.messages[0].content,
1525            MessageContent::Text { ref text } if text == "updated"
1526        ));
1527    }
1528
1529    fn sqlite_temp_path(label: &str) -> std::path::PathBuf {
1530        let unique = SystemTime::now()
1531            .duration_since(UNIX_EPOCH)
1532            .unwrap()
1533            .as_nanos();
1534        std::env::temp_dir().join(format!(
1535            "inline-client-{label}-{}-{unique}.db",
1536            std::process::id()
1537        ))
1538    }
1539
1540    fn stored_transaction(id: &str, state: TransactionState) -> StoredTransaction {
1541        StoredTransaction::new(
1542            TransactionIdentity::new(
1543                TransactionId::try_new(id).unwrap(),
1544                Some(ExternalId::try_new("host-event", "event-1").unwrap()),
1545                RandomId::new(42),
1546            ),
1547            state,
1548        )
1549    }
1550}