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.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
1186pub(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}