1use 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
25pub type StoreResult<T> = Result<T, StoreError>;
27
28#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
30#[error("{category:?}: {message}")]
31pub struct StoreError {
32 pub category: ClientErrorCategory,
34 pub message: String,
36}
37
38impl StoreError {
39 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#[derive(Clone, PartialEq, Eq)]
54pub struct StoredSession {
55 pub auth: AuthCredential,
57 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
70pub trait ClientStore: fmt::Debug + Send + Sync + 'static {
72 fn save_session(&self, session: StoredSession) -> BoxFuture<'static, StoreResult<()>>;
74
75 fn load_session(&self) -> BoxFuture<'static, StoreResult<Option<StoredSession>>>;
77
78 fn clear_session(&self) -> BoxFuture<'static, StoreResult<()>>;
80
81 fn dialogs(&self, request: DialogsRequest) -> BoxFuture<'static, StoreResult<DialogsPage>>;
83
84 fn record_dialog(&self, dialog: DialogRecord) -> BoxFuture<'static, StoreResult<()>>;
86
87 fn record_users(&self, users: Vec<UserRecord>) -> BoxFuture<'static, StoreResult<()>>;
89
90 fn history(&self, request: HistoryRequest) -> BoxFuture<'static, StoreResult<HistoryPage>>;
92
93 fn record_message(&self, message: MessageRecord) -> BoxFuture<'static, StoreResult<()>>;
95
96 fn record_transaction(
98 &self,
99 transaction: StoredTransaction,
100 ) -> BoxFuture<'static, StoreResult<()>>;
101
102 fn transaction(
104 &self,
105 transaction_id: TransactionId,
106 ) -> BoxFuture<'static, StoreResult<Option<StoredTransaction>>>;
107}
108
109#[derive(Clone, Debug, PartialEq, Eq)]
111pub struct StoredTransaction {
112 pub identity: TransactionIdentity,
114 pub state: TransactionState,
116 pub chat_id: Option<InlineId>,
118 pub message_id: Option<InlineId>,
120 pub failure: Option<ClientFailure>,
122 pub created_at: i64,
124 pub updated_at: i64,
126}
127
128impl StoredTransaction {
129 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 pub fn with_chat_id(mut self, chat_id: InlineId) -> Self {
145 self.chat_id = Some(chat_id);
146 self
147 }
148
149 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 pub fn with_failure(mut self, failure: ClientFailure) -> Self {
158 self.failure = Some(failure);
159 self
160 }
161
162 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#[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 pub fn new() -> Self {
190 Self::default()
191 }
192
193 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 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 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#[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 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 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 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 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 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
1216pub(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}