use std::{
collections::{HashMap, HashSet},
fmt,
fs::OpenOptions,
path::Path,
sync::{Arc, Mutex},
time::{SystemTime, UNIX_EPOCH},
};
use futures_util::future::BoxFuture;
use rusqlite::{Connection, OptionalExtension, Row, params, types::Type};
use serde::{Deserialize, Serialize};
use crate::{
AuthCredential, ChatParticipantRecord, ClientErrorCategory, ClientEvent, ClientFailure,
DialogFollowMode, DialogNotificationMode, DialogRecord, DialogsOrder, DialogsPage,
DialogsRequest, EventReliability, HistoryPage, HistoryRequest, InlineId, MessageContent,
MessageRecord, SpaceMemberRecord, SpaceRecord, TransactionEvent, TransactionId,
TransactionIdentity, TransactionState, UserRecord, UserSettingsRecord,
};
pub(crate) fn select_history_window(
mut messages: Vec<MessageRecord>,
limit: usize,
before_message_id: Option<InlineId>,
after_message_id: Option<InlineId>,
) -> (Vec<MessageRecord>, bool) {
if let Some(before) = before_message_id {
messages.retain(|message| message.message_id.get() < before.get());
}
if let Some(after) = after_message_id {
messages.retain(|message| message.message_id.get() > after.get());
}
messages.sort_by_key(|message| message.message_id.get());
let limit = limit.max(1);
let has_more = messages.len() > limit;
if has_more {
if after_message_id.is_some() {
messages.truncate(limit);
} else {
messages = messages.split_off(messages.len() - limit);
}
}
messages.sort_by_key(|message| (message.timestamp, message.message_id.get()));
(messages, has_more)
}
pub type StoreResult<T> = Result<T, StoreError>;
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[error("{category:?}: {message}")]
pub struct StoreError {
pub category: ClientErrorCategory,
pub message: String,
}
impl StoreError {
pub fn new(category: ClientErrorCategory, message: impl Into<String>) -> Self {
Self {
category,
message: message.into(),
}
}
fn internal(message: impl Into<String>) -> Self {
Self::new(ClientErrorCategory::Internal, message)
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct StoredSession {
pub auth: AuthCredential,
pub account_namespace: Option<String>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct SyncState {
pub last_sync_date: i64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum SyncBucketPeer {
User {
user_id: InlineId,
},
Chat {
chat_id: InlineId,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum SyncBucketKey {
User,
Space {
space_id: InlineId,
},
Chat {
peer: SyncBucketPeer,
},
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct SyncBucketState {
pub seq: i64,
pub date: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PendingSyncBatch {
pub key: SyncBucketKey,
pub committed_state: SyncBucketState,
pub payload: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ClientEventDelivery {
pub delivery_id: Option<u64>,
pub event: ClientEvent,
}
impl ClientEventDelivery {
pub fn ephemeral(event: ClientEvent) -> Self {
Self {
delivery_id: None,
event,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StoredReaction {
pub chat_id: InlineId,
pub message_id: InlineId,
pub user_id: InlineId,
pub reaction: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StoredReadState {
pub chat_id: InlineId,
pub read_max_id: Option<InlineId>,
pub unread_count: Option<u32>,
pub marked_unread: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccountStateSnapshot {
pub deleted_chat_ids: Vec<InlineId>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChatStateSnapshot {
pub chat_id: InlineId,
pub dialog: Option<DialogRecord>,
pub deleted: bool,
pub deleted_message_ids: Vec<InlineId>,
pub reactions: Vec<StoredReaction>,
pub reaction_snapshot_message_ids: Vec<InlineId>,
pub read_state: Option<StoredReadState>,
pub participants: Vec<ChatParticipantRecord>,
pub participants_complete: bool,
}
impl fmt::Debug for StoredSession {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StoredSession")
.field("auth", &self.auth)
.field(
"account_namespace",
&self.account_namespace.as_ref().map(|_| "<redacted>"),
)
.finish()
}
}
pub trait ClientStore: fmt::Debug + Send + Sync + 'static {
fn save_session(&self, session: StoredSession) -> BoxFuture<'static, StoreResult<()>>;
fn load_session(&self) -> BoxFuture<'static, StoreResult<Option<StoredSession>>>;
fn clear_session(&self) -> BoxFuture<'static, StoreResult<()>>;
fn clear_account_data(&self) -> BoxFuture<'static, StoreResult<()>>;
fn sync_state(&self) -> BoxFuture<'static, StoreResult<SyncState>>;
fn save_sync_state(&self, state: SyncState) -> BoxFuture<'static, StoreResult<()>>;
fn clear_sync_state(&self) -> BoxFuture<'static, StoreResult<()>>;
fn sync_bucket_state(
&self,
key: SyncBucketKey,
) -> BoxFuture<'static, StoreResult<SyncBucketState>>;
fn save_sync_bucket_state(
&self,
key: SyncBucketKey,
state: SyncBucketState,
) -> BoxFuture<'static, StoreResult<()>>;
fn save_sync_bucket_states(
&self,
states: Vec<(SyncBucketKey, SyncBucketState)>,
) -> BoxFuture<'static, StoreResult<()>>;
fn remove_sync_bucket_state(&self, key: SyncBucketKey) -> BoxFuture<'static, StoreResult<()>>;
fn save_pending_sync_batch(
&self,
batch: PendingSyncBatch,
) -> BoxFuture<'static, StoreResult<()>>;
fn pending_sync_batches(&self) -> BoxFuture<'static, StoreResult<Vec<PendingSyncBatch>>>;
fn discard_pending_sync_batch(
&self,
_key: SyncBucketKey,
) -> BoxFuture<'static, StoreResult<()>> {
Box::pin(async {
Err(StoreError::new(
ClientErrorCategory::Unsupported,
"store does not support discarding invalid sync journals",
))
})
}
fn commit_pending_sync_batch(
&self,
key: SyncBucketKey,
state: SyncBucketState,
) -> BoxFuture<'static, StoreResult<()>>;
fn commit_pending_sync_batch_with_events(
&self,
key: SyncBucketKey,
state: SyncBucketState,
events: Vec<ClientEvent>,
) -> BoxFuture<'static, StoreResult<Vec<ClientEventDelivery>>> {
if events
.iter()
.any(|event| event.reliability() == EventReliability::Lossless)
{
return Box::pin(async {
Err(StoreError::new(
ClientErrorCategory::Unsupported,
"client store does not support a durable event outbox",
))
});
}
let commit = self.commit_pending_sync_batch(key, state);
Box::pin(async move {
commit.await?;
Ok(events
.into_iter()
.map(ClientEventDelivery::ephemeral)
.collect())
})
}
fn append_client_events(
&self,
events: Vec<ClientEvent>,
) -> BoxFuture<'static, StoreResult<Vec<ClientEventDelivery>>> {
Box::pin(async move {
if events
.iter()
.any(|event| event.reliability() == EventReliability::Lossless)
{
return Err(StoreError::new(
ClientErrorCategory::Unsupported,
"client store does not support a durable event outbox",
));
}
Ok(events
.into_iter()
.map(ClientEventDelivery::ephemeral)
.collect())
})
}
fn pending_client_events(&self) -> BoxFuture<'static, StoreResult<Vec<ClientEventDelivery>>> {
Box::pin(async { Ok(Vec::new()) })
}
fn acknowledge_client_event(&self, _delivery_id: u64) -> BoxFuture<'static, StoreResult<()>> {
Box::pin(async { Ok(()) })
}
fn dialogs(&self, request: DialogsRequest) -> BoxFuture<'static, StoreResult<DialogsPage>>;
fn dialog(&self, chat_id: InlineId) -> BoxFuture<'static, StoreResult<Option<DialogRecord>>>;
fn record_dialog(&self, dialog: DialogRecord) -> BoxFuture<'static, StoreResult<()>>;
fn remove_dialog(&self, chat_id: InlineId) -> BoxFuture<'static, StoreResult<()>>;
fn deleted_chat_ids(&self) -> BoxFuture<'static, StoreResult<Vec<InlineId>>>;
fn record_users(&self, users: Vec<UserRecord>) -> BoxFuture<'static, StoreResult<()>>;
fn user(&self, _user_id: InlineId) -> BoxFuture<'static, StoreResult<Option<UserRecord>>> {
Box::pin(async { Ok(None) })
}
fn record_space(&self, space: SpaceRecord) -> BoxFuture<'static, StoreResult<()>>;
fn space(&self, space_id: InlineId) -> BoxFuture<'static, StoreResult<Option<SpaceRecord>>>;
fn record_space_member(&self, member: SpaceMemberRecord)
-> BoxFuture<'static, StoreResult<()>>;
fn record_space_members(
&self,
space_id: InlineId,
members: Vec<SpaceMemberRecord>,
) -> BoxFuture<'static, StoreResult<()>>;
fn remove_space_member(
&self,
space_id: InlineId,
user_id: InlineId,
) -> BoxFuture<'static, StoreResult<()>>;
fn space_members(
&self,
space_id: InlineId,
) -> BoxFuture<'static, StoreResult<Vec<SpaceMemberRecord>>>;
fn record_user_settings(
&self,
settings: UserSettingsRecord,
) -> BoxFuture<'static, StoreResult<()>>;
fn user_settings(&self) -> BoxFuture<'static, StoreResult<Option<UserSettingsRecord>>>;
fn record_chat_participants(
&self,
chat_id: InlineId,
participants: Vec<ChatParticipantRecord>,
) -> BoxFuture<'static, StoreResult<()>>;
fn record_chat_participant(
&self,
chat_id: InlineId,
participant: ChatParticipantRecord,
) -> BoxFuture<'static, StoreResult<()>>;
fn remove_chat_participant(
&self,
chat_id: InlineId,
user_id: InlineId,
) -> BoxFuture<'static, StoreResult<()>>;
fn chat_participants(
&self,
chat_id: InlineId,
) -> BoxFuture<'static, StoreResult<Vec<ChatParticipantRecord>>>;
fn chat_participants_complete(
&self,
chat_id: InlineId,
) -> BoxFuture<'static, StoreResult<bool>>;
fn history(&self, request: HistoryRequest) -> BoxFuture<'static, StoreResult<HistoryPage>>;
fn record_message(&self, message: MessageRecord) -> BoxFuture<'static, StoreResult<()>>;
fn record_message_deleted(
&self,
chat_id: InlineId,
message_id: InlineId,
) -> BoxFuture<'static, StoreResult<()>>;
fn message_deleted(
&self,
chat_id: InlineId,
message_id: InlineId,
) -> BoxFuture<'static, StoreResult<bool>>;
fn deleted_message_ids(
&self,
chat_id: InlineId,
) -> BoxFuture<'static, StoreResult<Vec<InlineId>>>;
fn clear_chat_messages(
&self,
chat_id: InlineId,
before_date: Option<i64>,
) -> BoxFuture<'static, StoreResult<Vec<InlineId>>>;
fn chat_ids_in_space(
&self,
space_id: InlineId,
) -> BoxFuture<'static, StoreResult<Vec<InlineId>>>;
fn record_reaction(&self, reaction: StoredReaction) -> BoxFuture<'static, StoreResult<()>>;
fn remove_reaction(&self, reaction: StoredReaction) -> BoxFuture<'static, StoreResult<()>>;
fn replace_message_reactions(
&self,
chat_id: InlineId,
message_id: InlineId,
reactions: Vec<StoredReaction>,
) -> BoxFuture<'static, StoreResult<()>>;
fn reactions(
&self,
chat_id: InlineId,
message_id: InlineId,
) -> BoxFuture<'static, StoreResult<Vec<StoredReaction>>>;
fn reactions_for_chat(
&self,
chat_id: InlineId,
) -> BoxFuture<'static, StoreResult<Vec<StoredReaction>>>;
fn reaction_snapshot_message_ids(
&self,
chat_id: InlineId,
) -> BoxFuture<'static, StoreResult<Vec<InlineId>>>;
fn record_read_state(&self, state: StoredReadState) -> BoxFuture<'static, StoreResult<()>>;
fn read_state(
&self,
chat_id: InlineId,
) -> BoxFuture<'static, StoreResult<Option<StoredReadState>>>;
fn record_transaction(
&self,
transaction: StoredTransaction,
) -> BoxFuture<'static, StoreResult<()>>;
fn transaction(
&self,
transaction_id: TransactionId,
) -> BoxFuture<'static, StoreResult<Option<StoredTransaction>>>;
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredTransaction {
pub identity: TransactionIdentity,
pub state: TransactionState,
pub chat_id: Option<InlineId>,
pub message_id: Option<InlineId>,
pub failure: Option<ClientFailure>,
pub created_at: i64,
pub updated_at: i64,
}
impl StoredTransaction {
pub fn new(identity: TransactionIdentity, state: TransactionState) -> Self {
let now = now_seconds();
Self {
identity,
state,
chat_id: None,
message_id: None,
failure: None,
created_at: now,
updated_at: now,
}
}
pub fn with_chat_id(mut self, chat_id: InlineId) -> Self {
self.chat_id = Some(chat_id);
self
}
pub fn with_message_id(mut self, message_id: InlineId) -> Self {
self.message_id = Some(message_id);
self.identity.final_message_id = Some(message_id);
self
}
pub fn with_failure(mut self, failure: ClientFailure) -> Self {
self.failure = Some(failure);
self
}
pub fn event(&self) -> TransactionEvent {
TransactionEvent {
identity: self.identity.clone(),
state: self.state,
failure: self.failure.clone(),
}
}
}
#[derive(Clone, Debug, Default)]
pub struct InMemoryStore {
state: Arc<Mutex<InMemoryStoreState>>,
}
#[derive(Debug, Default)]
struct InMemoryStoreState {
session: Option<StoredSession>,
sync_state: SyncState,
sync_buckets: HashMap<SyncBucketKey, SyncBucketState>,
pending_sync_batches: HashMap<SyncBucketKey, PendingSyncBatch>,
client_event_outbox: Vec<ClientEventDelivery>,
next_client_event_id: u64,
dialogs: Vec<DialogRecord>,
chat_tombstones: HashSet<i64>,
users: HashMap<i64, UserRecord>,
spaces: HashMap<i64, SpaceRecord>,
space_members: HashMap<(i64, i64), SpaceMemberRecord>,
user_settings: Option<UserSettingsRecord>,
participants: HashMap<i64, HashMap<i64, ChatParticipantRecord>>,
participant_snapshots: HashSet<i64>,
messages: HashMap<i64, Vec<MessageRecord>>,
message_tombstones: HashSet<(i64, i64)>,
reactions: HashMap<(i64, i64, i64, String), StoredReaction>,
reaction_snapshots: HashSet<(i64, i64)>,
read_states: HashMap<i64, StoredReadState>,
transactions: HashMap<String, StoredTransaction>,
}
fn append_memory_client_events(
state: &mut InMemoryStoreState,
events: Vec<ClientEvent>,
) -> Vec<ClientEventDelivery> {
events
.into_iter()
.map(|event| {
if event.reliability() == EventReliability::BestEffort {
return ClientEventDelivery::ephemeral(event);
}
state.next_client_event_id = state.next_client_event_id.saturating_add(1).max(1);
let delivery = ClientEventDelivery {
delivery_id: Some(state.next_client_event_id),
event,
};
state.client_event_outbox.push(delivery.clone());
delivery
})
.collect()
}
impl InMemoryStore {
pub fn new() -> Self {
Self::default()
}
pub fn upsert_dialog(&self, dialog: DialogRecord) {
let mut state = self.state.lock().expect("in-memory store poisoned");
state.chat_tombstones.remove(&dialog.chat_id.get());
upsert_dialog(&mut state.dialogs, dialog);
}
pub fn upsert_user(&self, user: UserRecord) {
let mut state = self.state.lock().expect("in-memory store poisoned");
state.users.insert(user.user_id.get(), user);
}
pub fn insert_message(&self, message: MessageRecord) {
let mut state = self.state.lock().expect("in-memory store poisoned");
state
.message_tombstones
.remove(&(message.chat_id.get(), message.message_id.get()));
insert_message(&mut state.messages, message);
}
}
impl ClientStore for InMemoryStore {
fn save_session(&self, session: StoredSession) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
store
.state
.lock()
.expect("in-memory store poisoned")
.session = Some(session);
Ok(())
})
}
fn load_session(&self) -> BoxFuture<'static, StoreResult<Option<StoredSession>>> {
let store = self.clone();
Box::pin(async move {
Ok(store
.state
.lock()
.expect("in-memory store poisoned")
.session
.clone())
})
}
fn clear_session(&self) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
store
.state
.lock()
.expect("in-memory store poisoned")
.session = None;
Ok(())
})
}
fn clear_account_data(&self) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
let mut state = store.state.lock().expect("in-memory store poisoned");
let session = state.session.take();
*state = InMemoryStoreState::default();
state.session = session;
Ok(())
})
}
fn sync_state(&self) -> BoxFuture<'static, StoreResult<SyncState>> {
let store = self.clone();
Box::pin(async move {
Ok(store
.state
.lock()
.expect("in-memory store poisoned")
.sync_state)
})
}
fn save_sync_state(&self, state: SyncState) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
store
.state
.lock()
.expect("in-memory store poisoned")
.sync_state = state;
Ok(())
})
}
fn clear_sync_state(&self) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
let mut state = store.state.lock().expect("in-memory store poisoned");
state.sync_state = SyncState::default();
state.sync_buckets.clear();
Ok(())
})
}
fn sync_bucket_state(
&self,
key: SyncBucketKey,
) -> BoxFuture<'static, StoreResult<SyncBucketState>> {
let store = self.clone();
Box::pin(async move {
Ok(store
.state
.lock()
.expect("in-memory store poisoned")
.sync_buckets
.get(&key)
.copied()
.unwrap_or_default())
})
}
fn save_sync_bucket_state(
&self,
key: SyncBucketKey,
state: SyncBucketState,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
store
.state
.lock()
.expect("in-memory store poisoned")
.sync_buckets
.insert(key, state);
Ok(())
})
}
fn save_sync_bucket_states(
&self,
states: Vec<(SyncBucketKey, SyncBucketState)>,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
let mut stored = store.state.lock().expect("in-memory store poisoned");
stored.sync_buckets.extend(states);
Ok(())
})
}
fn remove_sync_bucket_state(&self, key: SyncBucketKey) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
store
.state
.lock()
.expect("in-memory store poisoned")
.sync_buckets
.remove(&key);
Ok(())
})
}
fn save_pending_sync_batch(
&self,
batch: PendingSyncBatch,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
store
.state
.lock()
.expect("in-memory store poisoned")
.pending_sync_batches
.insert(batch.key, batch);
Ok(())
})
}
fn pending_sync_batches(&self) -> BoxFuture<'static, StoreResult<Vec<PendingSyncBatch>>> {
let store = self.clone();
Box::pin(async move {
let mut batches = store
.state
.lock()
.expect("in-memory store poisoned")
.pending_sync_batches
.values()
.cloned()
.collect::<Vec<_>>();
batches.sort_by_key(|batch| sync_bucket_sort_key(batch.key));
Ok(batches)
})
}
fn discard_pending_sync_batch(
&self,
key: SyncBucketKey,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
store
.state
.lock()
.expect("in-memory store poisoned")
.pending_sync_batches
.remove(&key);
Ok(())
})
}
fn commit_pending_sync_batch(
&self,
key: SyncBucketKey,
state: SyncBucketState,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
let mut stored = store.state.lock().expect("in-memory store poisoned");
stored.sync_buckets.insert(key, state);
stored.pending_sync_batches.remove(&key);
Ok(())
})
}
fn commit_pending_sync_batch_with_events(
&self,
key: SyncBucketKey,
state: SyncBucketState,
events: Vec<ClientEvent>,
) -> BoxFuture<'static, StoreResult<Vec<ClientEventDelivery>>> {
let store = self.clone();
Box::pin(async move {
let mut stored = store.state.lock().expect("in-memory store poisoned");
let deliveries = append_memory_client_events(&mut stored, events);
stored.sync_buckets.insert(key, state);
stored.pending_sync_batches.remove(&key);
Ok(deliveries)
})
}
fn append_client_events(
&self,
events: Vec<ClientEvent>,
) -> BoxFuture<'static, StoreResult<Vec<ClientEventDelivery>>> {
let store = self.clone();
Box::pin(async move {
let mut stored = store.state.lock().expect("in-memory store poisoned");
Ok(append_memory_client_events(&mut stored, events))
})
}
fn pending_client_events(&self) -> BoxFuture<'static, StoreResult<Vec<ClientEventDelivery>>> {
let store = self.clone();
Box::pin(async move {
Ok(store
.state
.lock()
.expect("in-memory store poisoned")
.client_event_outbox
.clone())
})
}
fn acknowledge_client_event(&self, delivery_id: u64) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
store
.state
.lock()
.expect("in-memory store poisoned")
.client_event_outbox
.retain(|delivery| delivery.delivery_id != Some(delivery_id));
Ok(())
})
}
fn dialogs(&self, request: DialogsRequest) -> BoxFuture<'static, StoreResult<DialogsPage>> {
let store = self.clone();
Box::pin(async move {
let state = store.state.lock().expect("in-memory store poisoned");
let limit = request.limit.unwrap_or(50).max(1) as usize;
let (dialogs, next_cursor) = match request.order {
DialogsOrder::RecentActivity => {
let start = parse_cursor(request.cursor.as_deref())?;
let dialogs = state
.dialogs
.iter()
.skip(start)
.take(limit)
.cloned()
.collect::<Vec<_>>();
let next = start + dialogs.len();
(
dialogs,
(next < state.dialogs.len()).then(|| next.to_string()),
)
}
DialogsOrder::StableChatId => {
let after = parse_stable_dialog_cursor(request.cursor.as_deref())?;
let mut candidates = state
.dialogs
.iter()
.filter(|dialog| dialog.chat_id.get() > after)
.cloned()
.collect::<Vec<_>>();
candidates.sort_by_key(|dialog| dialog.chat_id.get());
let has_more = candidates.len() > limit;
candidates.truncate(limit);
let next = has_more
.then(|| {
candidates
.last()
.map(|dialog| dialog.chat_id.get().to_string())
})
.flatten();
(candidates, next)
}
};
let dialogs = dialogs
.into_iter()
.map(|dialog| {
let chat_id = dialog.chat_id;
dialog_with_synced_through(
dialog,
max_message_id_from_memory(&state.messages, chat_id),
)
})
.collect::<Vec<_>>();
let users = all_users_from_memory(&state.users);
Ok(DialogsPage {
dialogs,
users,
next_cursor,
})
})
}
fn dialog(&self, chat_id: InlineId) -> BoxFuture<'static, StoreResult<Option<DialogRecord>>> {
let store = self.clone();
Box::pin(async move {
let state = store.state.lock().expect("in-memory store poisoned");
Ok(state
.dialogs
.iter()
.find(|dialog| dialog.chat_id == chat_id)
.cloned()
.map(|dialog| {
dialog_with_synced_through(
dialog,
max_message_id_from_memory(&state.messages, chat_id),
)
}))
})
}
fn record_dialog(&self, dialog: DialogRecord) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
store.upsert_dialog(dialog);
Ok(())
})
}
fn remove_dialog(&self, chat_id: InlineId) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
let mut state = store.state.lock().expect("in-memory store poisoned");
state.dialogs.retain(|dialog| dialog.chat_id != chat_id);
if let Some(messages) = state.messages.remove(&chat_id.get()) {
state.message_tombstones.extend(
messages
.into_iter()
.map(|message| (chat_id.get(), message.message_id.get())),
);
}
state.participants.remove(&chat_id.get());
state.participant_snapshots.remove(&chat_id.get());
state
.reactions
.retain(|_, reaction| reaction.chat_id != chat_id);
state
.reaction_snapshots
.retain(|(stored_chat_id, _)| *stored_chat_id != chat_id.get());
state.read_states.remove(&chat_id.get());
state.chat_tombstones.insert(chat_id.get());
Ok(())
})
}
fn deleted_chat_ids(&self) -> BoxFuture<'static, StoreResult<Vec<InlineId>>> {
let store = self.clone();
Box::pin(async move {
let mut ids = store
.state
.lock()
.expect("in-memory store poisoned")
.chat_tombstones
.iter()
.copied()
.map(InlineId::new)
.collect::<Vec<_>>();
ids.sort_by_key(|id| id.get());
Ok(ids)
})
}
fn record_users(&self, users: Vec<UserRecord>) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
let mut state = store.state.lock().expect("in-memory store poisoned");
for user in users {
state.users.insert(user.user_id.get(), user);
}
Ok(())
})
}
fn user(&self, user_id: InlineId) -> BoxFuture<'static, StoreResult<Option<UserRecord>>> {
let store = self.clone();
Box::pin(async move {
Ok(store
.state
.lock()
.expect("in-memory store poisoned")
.users
.get(&user_id.get())
.cloned())
})
}
fn record_space(&self, space: SpaceRecord) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
store
.state
.lock()
.expect("in-memory store poisoned")
.spaces
.insert(space.space_id.get(), space);
Ok(())
})
}
fn space(&self, space_id: InlineId) -> BoxFuture<'static, StoreResult<Option<SpaceRecord>>> {
let store = self.clone();
Box::pin(async move {
Ok(store
.state
.lock()
.expect("in-memory store poisoned")
.spaces
.get(&space_id.get())
.cloned())
})
}
fn record_space_member(
&self,
member: SpaceMemberRecord,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
store
.state
.lock()
.expect("in-memory store poisoned")
.space_members
.insert((member.space_id.get(), member.user_id.get()), member);
Ok(())
})
}
fn record_space_members(
&self,
space_id: InlineId,
members: Vec<SpaceMemberRecord>,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
if members.iter().any(|member| member.space_id != space_id) {
return Err(StoreError::new(
ClientErrorCategory::InvalidInput,
"space member snapshot contains a different space id",
));
}
let mut state = store.state.lock().expect("in-memory store poisoned");
state
.space_members
.retain(|(stored_space_id, _), _| *stored_space_id != space_id.get());
for member in members {
state
.space_members
.insert((space_id.get(), member.user_id.get()), member);
}
Ok(())
})
}
fn remove_space_member(
&self,
space_id: InlineId,
user_id: InlineId,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
store
.state
.lock()
.expect("in-memory store poisoned")
.space_members
.remove(&(space_id.get(), user_id.get()));
Ok(())
})
}
fn space_members(
&self,
space_id: InlineId,
) -> BoxFuture<'static, StoreResult<Vec<SpaceMemberRecord>>> {
let store = self.clone();
Box::pin(async move {
let mut members = store
.state
.lock()
.expect("in-memory store poisoned")
.space_members
.iter()
.filter(|((stored_space_id, _), _)| *stored_space_id == space_id.get())
.map(|(_, member)| member.clone())
.collect::<Vec<_>>();
members.sort_by_key(|member| member.user_id.get());
Ok(members)
})
}
fn record_user_settings(
&self,
settings: UserSettingsRecord,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
store
.state
.lock()
.expect("in-memory store poisoned")
.user_settings = Some(settings);
Ok(())
})
}
fn user_settings(&self) -> BoxFuture<'static, StoreResult<Option<UserSettingsRecord>>> {
let store = self.clone();
Box::pin(async move {
Ok(store
.state
.lock()
.expect("in-memory store poisoned")
.user_settings
.clone())
})
}
fn record_chat_participants(
&self,
chat_id: InlineId,
participants: Vec<ChatParticipantRecord>,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
let snapshot = participants
.into_iter()
.map(|participant| (participant.user_id.get(), participant))
.collect();
let mut state = store.state.lock().expect("in-memory store poisoned");
state.participants.insert(chat_id.get(), snapshot);
state.participant_snapshots.insert(chat_id.get());
Ok(())
})
}
fn record_chat_participant(
&self,
chat_id: InlineId,
participant: ChatParticipantRecord,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
store
.state
.lock()
.expect("in-memory store poisoned")
.participants
.entry(chat_id.get())
.or_default()
.insert(participant.user_id.get(), participant);
Ok(())
})
}
fn remove_chat_participant(
&self,
chat_id: InlineId,
user_id: InlineId,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
if let Some(participants) = store
.state
.lock()
.expect("in-memory store poisoned")
.participants
.get_mut(&chat_id.get())
{
participants.remove(&user_id.get());
}
Ok(())
})
}
fn chat_participants(
&self,
chat_id: InlineId,
) -> BoxFuture<'static, StoreResult<Vec<ChatParticipantRecord>>> {
let store = self.clone();
Box::pin(async move {
let mut participants = store
.state
.lock()
.expect("in-memory store poisoned")
.participants
.get(&chat_id.get())
.map(|participants| participants.values().cloned().collect::<Vec<_>>())
.unwrap_or_default();
sort_participants(&mut participants);
Ok(participants)
})
}
fn chat_participants_complete(
&self,
chat_id: InlineId,
) -> BoxFuture<'static, StoreResult<bool>> {
let store = self.clone();
Box::pin(async move {
Ok(store
.state
.lock()
.expect("in-memory store poisoned")
.participant_snapshots
.contains(&chat_id.get()))
})
}
fn history(&self, request: HistoryRequest) -> BoxFuture<'static, StoreResult<HistoryPage>> {
let store = self.clone();
Box::pin(async move {
let state = store.state.lock().expect("in-memory store poisoned");
let messages = state
.messages
.get(&request.chat_id.get())
.cloned()
.unwrap_or_default();
if request.before_message_id.is_some() && request.after_message_id.is_some() {
return Err(StoreError::new(
ClientErrorCategory::InvalidInput,
"history request cannot specify both before_message_id and after_message_id",
));
}
let limit = request.limit.unwrap_or(50).max(1) as usize;
let (messages, has_more) = select_history_window(
messages,
limit,
request.before_message_id,
request.after_message_id,
);
let users = users_for_messages_from_memory(&state.users, &messages);
Ok(HistoryPage {
messages,
users,
has_more,
next_cursor: None,
})
})
}
fn record_message(&self, message: MessageRecord) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
let mut state = store.state.lock().expect("in-memory store poisoned");
state
.message_tombstones
.remove(&(message.chat_id.get(), message.message_id.get()));
insert_message(&mut state.messages, message);
Ok(())
})
}
fn record_message_deleted(
&self,
chat_id: InlineId,
message_id: InlineId,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
let mut state = store.state.lock().expect("in-memory store poisoned");
if let Some(messages) = state.messages.get_mut(&chat_id.get()) {
messages.retain(|message| message.message_id != message_id);
}
state.reactions.retain(|_, reaction| {
reaction.chat_id != chat_id || reaction.message_id != message_id
});
state
.reaction_snapshots
.remove(&(chat_id.get(), message_id.get()));
state
.message_tombstones
.insert((chat_id.get(), message_id.get()));
Ok(())
})
}
fn message_deleted(
&self,
chat_id: InlineId,
message_id: InlineId,
) -> BoxFuture<'static, StoreResult<bool>> {
let store = self.clone();
Box::pin(async move {
Ok(store
.state
.lock()
.expect("in-memory store poisoned")
.message_tombstones
.contains(&(chat_id.get(), message_id.get())))
})
}
fn deleted_message_ids(
&self,
chat_id: InlineId,
) -> BoxFuture<'static, StoreResult<Vec<InlineId>>> {
let store = self.clone();
Box::pin(async move {
let mut ids = store
.state
.lock()
.expect("in-memory store poisoned")
.message_tombstones
.iter()
.filter(|(stored_chat_id, _)| *stored_chat_id == chat_id.get())
.map(|(_, message_id)| InlineId::new(*message_id))
.collect::<Vec<_>>();
ids.sort_by_key(|id| id.get());
Ok(ids)
})
}
fn clear_chat_messages(
&self,
chat_id: InlineId,
before_date: Option<i64>,
) -> BoxFuture<'static, StoreResult<Vec<InlineId>>> {
let store = self.clone();
Box::pin(async move {
let mut state = store.state.lock().expect("in-memory store poisoned");
let mut removed = Vec::new();
if let Some(messages) = state.messages.get_mut(&chat_id.get()) {
messages.retain(|message| {
let should_remove = before_date.is_none_or(|date| message.timestamp < date);
if should_remove {
removed.push(message.message_id);
}
!should_remove
});
}
let removed_ids = removed.iter().map(|id| id.get()).collect::<HashSet<_>>();
state.reactions.retain(|_, reaction| {
reaction.chat_id != chat_id || !removed_ids.contains(&reaction.message_id.get())
});
state
.reaction_snapshots
.retain(|(stored_chat_id, message_id)| {
*stored_chat_id != chat_id.get() || !removed_ids.contains(message_id)
});
state.message_tombstones.extend(
removed_ids
.iter()
.map(|message_id| (chat_id.get(), *message_id)),
);
let last_message_id = state.messages.get(&chat_id.get()).and_then(|messages| {
messages
.iter()
.map(|message| message.message_id)
.max_by_key(|id| id.get())
});
if let Some(dialog) = state
.dialogs
.iter_mut()
.find(|dialog| dialog.chat_id == chat_id)
{
dialog.last_message_id = last_message_id;
}
removed.sort_by_key(|id| id.get());
Ok(removed)
})
}
fn chat_ids_in_space(
&self,
space_id: InlineId,
) -> BoxFuture<'static, StoreResult<Vec<InlineId>>> {
let store = self.clone();
Box::pin(async move {
let mut chat_ids = store
.state
.lock()
.expect("in-memory store poisoned")
.dialogs
.iter()
.filter(|dialog| dialog.space_id == Some(space_id))
.map(|dialog| dialog.chat_id)
.collect::<Vec<_>>();
chat_ids.sort_by_key(|id| id.get());
Ok(chat_ids)
})
}
fn record_reaction(&self, reaction: StoredReaction) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
let key = reaction_key(&reaction);
store
.state
.lock()
.expect("in-memory store poisoned")
.reactions
.insert(key, reaction);
Ok(())
})
}
fn remove_reaction(&self, reaction: StoredReaction) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
store
.state
.lock()
.expect("in-memory store poisoned")
.reactions
.remove(&reaction_key(&reaction));
Ok(())
})
}
fn replace_message_reactions(
&self,
chat_id: InlineId,
message_id: InlineId,
reactions: Vec<StoredReaction>,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
let mut state = store.state.lock().expect("in-memory store poisoned");
state.reactions.retain(|_, reaction| {
reaction.chat_id != chat_id || reaction.message_id != message_id
});
for reaction in reactions {
state.reactions.insert(reaction_key(&reaction), reaction);
}
state
.reaction_snapshots
.insert((chat_id.get(), message_id.get()));
Ok(())
})
}
fn reactions(
&self,
chat_id: InlineId,
message_id: InlineId,
) -> BoxFuture<'static, StoreResult<Vec<StoredReaction>>> {
let store = self.clone();
Box::pin(async move {
let mut reactions = store
.state
.lock()
.expect("in-memory store poisoned")
.reactions
.values()
.filter(|reaction| reaction.chat_id == chat_id && reaction.message_id == message_id)
.cloned()
.collect::<Vec<_>>();
sort_reactions(&mut reactions);
Ok(reactions)
})
}
fn reactions_for_chat(
&self,
chat_id: InlineId,
) -> BoxFuture<'static, StoreResult<Vec<StoredReaction>>> {
let store = self.clone();
Box::pin(async move {
let mut reactions = store
.state
.lock()
.expect("in-memory store poisoned")
.reactions
.values()
.filter(|reaction| reaction.chat_id == chat_id)
.cloned()
.collect::<Vec<_>>();
reactions.sort_by(|left, right| {
(
left.message_id.get(),
left.user_id.get(),
left.reaction.as_str(),
)
.cmp(&(
right.message_id.get(),
right.user_id.get(),
right.reaction.as_str(),
))
});
Ok(reactions)
})
}
fn reaction_snapshot_message_ids(
&self,
chat_id: InlineId,
) -> BoxFuture<'static, StoreResult<Vec<InlineId>>> {
let store = self.clone();
Box::pin(async move {
let mut ids = store
.state
.lock()
.expect("in-memory store poisoned")
.reaction_snapshots
.iter()
.filter(|(stored_chat_id, _)| *stored_chat_id == chat_id.get())
.map(|(_, message_id)| InlineId::new(*message_id))
.collect::<Vec<_>>();
ids.sort_by_key(|id| id.get());
Ok(ids)
})
}
fn record_read_state(&self, state: StoredReadState) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
store
.state
.lock()
.expect("in-memory store poisoned")
.read_states
.insert(state.chat_id.get(), state);
Ok(())
})
}
fn read_state(
&self,
chat_id: InlineId,
) -> BoxFuture<'static, StoreResult<Option<StoredReadState>>> {
let store = self.clone();
Box::pin(async move {
Ok(store
.state
.lock()
.expect("in-memory store poisoned")
.read_states
.get(&chat_id.get())
.copied())
})
}
fn record_transaction(
&self,
transaction: StoredTransaction,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
let mut state = store.state.lock().expect("in-memory store poisoned");
state.transactions.insert(
transaction.identity.transaction_id.as_str().to_owned(),
transaction,
);
Ok(())
})
}
fn transaction(
&self,
transaction_id: TransactionId,
) -> BoxFuture<'static, StoreResult<Option<StoredTransaction>>> {
let store = self.clone();
Box::pin(async move {
Ok(store
.state
.lock()
.expect("in-memory store poisoned")
.transactions
.get(transaction_id.as_str())
.cloned())
})
}
}
#[derive(Clone)]
pub struct SqliteStore {
connection: Arc<Mutex<Connection>>,
}
impl fmt::Debug for SqliteStore {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SqliteStore")
.field("connection", &"<sqlite>")
.finish()
}
}
impl SqliteStore {
pub fn open(path: impl AsRef<Path>) -> StoreResult<Self> {
let path = path.as_ref();
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
std::fs::create_dir_all(parent).map_err(|error| {
StoreError::internal(format!("create store directory: {error}"))
})?;
}
prepare_private_sqlite_file(path)?;
let connection = Connection::open(path).map_err(sqlite_error)?;
Self::from_connection(connection)
}
pub fn open_in_memory() -> StoreResult<Self> {
let connection = Connection::open_in_memory().map_err(sqlite_error)?;
Self::from_connection(connection)
}
fn from_connection(connection: Connection) -> StoreResult<Self> {
migrate_sqlite(&connection)?;
Ok(Self {
connection: Arc::new(Mutex::new(connection)),
})
}
pub fn upsert_dialog(&self, dialog: DialogRecord) -> StoreResult<()> {
let connection = self.connection.lock().expect("sqlite store poisoned");
upsert_sqlite_dialog(&connection, dialog)
}
pub fn upsert_user(&self, user: UserRecord) -> StoreResult<()> {
let connection = self.connection.lock().expect("sqlite store poisoned");
upsert_sqlite_user(&connection, user)
}
pub fn insert_message(&self, message: MessageRecord) -> StoreResult<()> {
self.record_message_sync(message)
}
fn save_session_sync(&self, session: StoredSession) -> StoreResult<()> {
let auth_json = serde_json::to_string(&session.auth)
.map_err(|error| StoreError::internal(format!("encode session auth: {error}")))?;
let connection = self.connection.lock().expect("sqlite store poisoned");
connection
.execute(
"INSERT INTO sessions (id, auth_json, account_namespace, updated_at)
VALUES (1, ?1, ?2, ?3)
ON CONFLICT(id) DO UPDATE SET
auth_json = excluded.auth_json,
account_namespace = excluded.account_namespace,
updated_at = excluded.updated_at",
params![auth_json, session.account_namespace, now_seconds()],
)
.map_err(sqlite_error)?;
Ok(())
}
fn load_session_sync(&self) -> StoreResult<Option<StoredSession>> {
let connection = self.connection.lock().expect("sqlite store poisoned");
let row = connection
.query_row(
"SELECT auth_json, account_namespace FROM sessions WHERE id = 1",
[],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
)
.optional()
.map_err(sqlite_error)?;
row.map(|(auth_json, account_namespace)| {
let auth = serde_json::from_str::<AuthCredential>(&auth_json)
.map_err(|error| StoreError::internal(format!("decode session auth: {error}")))?;
Ok(StoredSession {
auth,
account_namespace,
})
})
.transpose()
}
fn clear_session_sync(&self) -> StoreResult<()> {
let connection = self.connection.lock().expect("sqlite store poisoned");
connection
.execute("DELETE FROM sessions WHERE id = 1", [])
.map_err(sqlite_error)?;
Ok(())
}
fn clear_account_data_sync(&self) -> StoreResult<()> {
let mut connection = self.connection.lock().expect("sqlite store poisoned");
let transaction = connection.transaction().map_err(sqlite_error)?;
for table in [
"sync_state",
"sync_buckets",
"pending_sync_batches",
"client_event_outbox",
"dialogs",
"chat_tombstones",
"users",
"spaces",
"space_members",
"user_settings",
"chat_participants",
"chat_participant_snapshots",
"messages",
"message_tombstones",
"reactions",
"reaction_snapshots",
"read_states",
"transactions",
] {
transaction
.execute(&format!("DELETE FROM {table}"), [])
.map_err(sqlite_error)?;
}
transaction.commit().map_err(sqlite_error)?;
Ok(())
}
fn sync_state_sync(&self) -> StoreResult<SyncState> {
let connection = self.connection.lock().expect("sqlite store poisoned");
let last_sync_date = connection
.query_row(
"SELECT last_sync_date FROM sync_state WHERE id = 1",
[],
|row| row.get::<_, i64>(0),
)
.optional()
.map_err(sqlite_error)?
.unwrap_or_default();
Ok(SyncState { last_sync_date })
}
fn save_sync_state_sync(&self, state: SyncState) -> StoreResult<()> {
let connection = self.connection.lock().expect("sqlite store poisoned");
connection
.execute(
"INSERT INTO sync_state (id, last_sync_date, updated_at)
VALUES (1, ?1, ?2)
ON CONFLICT(id) DO UPDATE SET
last_sync_date = excluded.last_sync_date,
updated_at = excluded.updated_at",
params![state.last_sync_date, now_seconds()],
)
.map_err(sqlite_error)?;
Ok(())
}
fn clear_sync_state_sync(&self) -> StoreResult<()> {
let mut connection = self.connection.lock().expect("sqlite store poisoned");
let transaction = connection.transaction().map_err(sqlite_error)?;
transaction
.execute("DELETE FROM sync_state", [])
.map_err(sqlite_error)?;
transaction
.execute("DELETE FROM sync_buckets", [])
.map_err(sqlite_error)?;
transaction.commit().map_err(sqlite_error)?;
Ok(())
}
fn sync_bucket_state_sync(&self, key: SyncBucketKey) -> StoreResult<SyncBucketState> {
let (kind, entity_id) = sync_bucket_key_parts(key);
let connection = self.connection.lock().expect("sqlite store poisoned");
let state = connection
.query_row(
"SELECT seq, date FROM sync_buckets
WHERE bucket_kind = ?1 AND entity_id = ?2",
params![kind, entity_id],
|row| {
Ok(SyncBucketState {
seq: row.get(0)?,
date: row.get(1)?,
})
},
)
.optional()
.map_err(sqlite_error)?
.unwrap_or_default();
Ok(state)
}
fn save_sync_bucket_state_sync(
&self,
key: SyncBucketKey,
state: SyncBucketState,
) -> StoreResult<()> {
let (kind, entity_id) = sync_bucket_key_parts(key);
let connection = self.connection.lock().expect("sqlite store poisoned");
upsert_sync_bucket(&connection, kind, entity_id, state)
}
fn save_sync_bucket_states_sync(
&self,
states: Vec<(SyncBucketKey, SyncBucketState)>,
) -> StoreResult<()> {
let mut connection = self.connection.lock().expect("sqlite store poisoned");
let transaction = connection.transaction().map_err(sqlite_error)?;
for (key, state) in states {
let (kind, entity_id) = sync_bucket_key_parts(key);
upsert_sync_bucket(&transaction, kind, entity_id, state)?;
}
transaction.commit().map_err(sqlite_error)?;
Ok(())
}
fn remove_sync_bucket_state_sync(&self, key: SyncBucketKey) -> StoreResult<()> {
let (kind, entity_id) = sync_bucket_key_parts(key);
let connection = self.connection.lock().expect("sqlite store poisoned");
connection
.execute(
"DELETE FROM sync_buckets WHERE bucket_kind = ?1 AND entity_id = ?2",
params![kind, entity_id],
)
.map_err(sqlite_error)?;
Ok(())
}
fn save_pending_sync_batch_sync(&self, batch: PendingSyncBatch) -> StoreResult<()> {
let (kind, entity_id) = sync_bucket_key_parts(batch.key);
let connection = self.connection.lock().expect("sqlite store poisoned");
connection
.execute(
"INSERT INTO pending_sync_batches (
bucket_kind, entity_id, seq, date, payload, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(bucket_kind, entity_id) DO UPDATE SET
seq = excluded.seq,
date = excluded.date,
payload = excluded.payload,
updated_at = excluded.updated_at",
params![
kind,
entity_id,
batch.committed_state.seq,
batch.committed_state.date,
batch.payload,
now_seconds(),
],
)
.map_err(sqlite_error)?;
Ok(())
}
fn pending_sync_batches_sync(&self) -> StoreResult<Vec<PendingSyncBatch>> {
let connection = self.connection.lock().expect("sqlite store poisoned");
let mut statement = connection
.prepare(
"SELECT bucket_kind, entity_id, seq, date, payload
FROM pending_sync_batches
ORDER BY bucket_kind ASC, entity_id ASC",
)
.map_err(sqlite_error)?;
statement
.query_map([], |row| {
let kind = row.get::<_, String>(0)?;
let entity_id = row.get::<_, i64>(1)?;
let key = sync_bucket_key_from_parts(&kind, entity_id).map_err(|error| {
rusqlite::Error::FromSqlConversionFailure(0, Type::Text, Box::new(error))
})?;
Ok(PendingSyncBatch {
key,
committed_state: SyncBucketState {
seq: row.get(2)?,
date: row.get(3)?,
},
payload: row.get(4)?,
})
})
.map_err(sqlite_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(sqlite_error)
}
fn discard_pending_sync_batch_sync(&self, key: SyncBucketKey) -> StoreResult<()> {
let (kind, entity_id) = sync_bucket_key_parts(key);
let connection = self.connection.lock().expect("sqlite store poisoned");
connection
.execute(
"DELETE FROM pending_sync_batches
WHERE bucket_kind = ?1 AND entity_id = ?2",
params![kind, entity_id],
)
.map_err(sqlite_error)?;
Ok(())
}
fn commit_pending_sync_batch_sync(
&self,
key: SyncBucketKey,
state: SyncBucketState,
events: Vec<ClientEvent>,
) -> StoreResult<Vec<ClientEventDelivery>> {
let (kind, entity_id) = sync_bucket_key_parts(key);
let mut connection = self.connection.lock().expect("sqlite store poisoned");
let transaction = connection.transaction().map_err(sqlite_error)?;
let deliveries = append_sqlite_client_events(&transaction, events)?;
upsert_sync_bucket(&transaction, kind, entity_id, state)?;
transaction
.execute(
"DELETE FROM pending_sync_batches
WHERE bucket_kind = ?1 AND entity_id = ?2",
params![kind, entity_id],
)
.map_err(sqlite_error)?;
transaction.commit().map_err(sqlite_error)?;
Ok(deliveries)
}
fn append_client_events_sync(
&self,
events: Vec<ClientEvent>,
) -> StoreResult<Vec<ClientEventDelivery>> {
let mut connection = self.connection.lock().expect("sqlite store poisoned");
let transaction = connection.transaction().map_err(sqlite_error)?;
let deliveries = append_sqlite_client_events(&transaction, events)?;
transaction.commit().map_err(sqlite_error)?;
Ok(deliveries)
}
fn pending_client_events_sync(&self) -> StoreResult<Vec<ClientEventDelivery>> {
let connection = self.connection.lock().expect("sqlite store poisoned");
let mut statement = connection
.prepare(
"SELECT delivery_id, event_json
FROM client_event_outbox
ORDER BY delivery_id ASC",
)
.map_err(sqlite_error)?;
statement
.query_map([], |row| {
let delivery_id = row.get::<_, i64>(0)?;
let event_json = row.get::<_, String>(1)?;
let event = serde_json::from_str(&event_json).map_err(|error| {
rusqlite::Error::FromSqlConversionFailure(1, Type::Text, Box::new(error))
})?;
Ok(ClientEventDelivery {
delivery_id: Some(u64::try_from(delivery_id).map_err(|error| {
rusqlite::Error::FromSqlConversionFailure(0, Type::Integer, Box::new(error))
})?),
event,
})
})
.map_err(sqlite_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(sqlite_error)
}
fn acknowledge_client_event_sync(&self, delivery_id: u64) -> StoreResult<()> {
let delivery_id = i64::try_from(delivery_id)
.map_err(|_| StoreError::internal("client event delivery ID exceeded SQLite range"))?;
let connection = self.connection.lock().expect("sqlite store poisoned");
connection
.execute(
"DELETE FROM client_event_outbox WHERE delivery_id = ?1",
params![delivery_id],
)
.map_err(sqlite_error)?;
Ok(())
}
fn dialogs_sync(&self, request: DialogsRequest) -> StoreResult<DialogsPage> {
let limit = request.limit.unwrap_or(50).max(1) as usize;
let connection = self.connection.lock().expect("sqlite store poisoned");
let rows = match request.order {
DialogsOrder::RecentActivity => {
let start = parse_cursor(request.cursor.as_deref())?;
let mut stmt = connection.prepare(
"SELECT d.chat_id, d.peer_user_id, d.title, d.emoji, d.last_message_id,
(SELECT MAX(m.message_id) FROM messages m WHERE m.chat_id = d.chat_id) AS synced_through_message_id,
d.unread_count, d.space_id, d.is_public, d.archived, d.pinned,
d.open, d.chat_list_hidden, d.list_order, d.pinned_order,
d.notification_mode, d.follow_mode, d.pinned_message_ids_json
FROM dialogs d
ORDER BY COALESCE(last_message_id, 0) DESC, chat_id ASC
LIMIT ?1 OFFSET ?2",
).map_err(sqlite_error)?;
stmt.query_map(
params![(limit + 1) as i64, start as i64],
sqlite_dialog_from_row,
)
.map_err(sqlite_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(sqlite_error)?
}
DialogsOrder::StableChatId => {
let after = parse_stable_dialog_cursor(request.cursor.as_deref())?;
let mut stmt = connection.prepare(
"SELECT d.chat_id, d.peer_user_id, d.title, d.emoji, d.last_message_id,
(SELECT MAX(m.message_id) FROM messages m WHERE m.chat_id = d.chat_id) AS synced_through_message_id,
d.unread_count, d.space_id, d.is_public, d.archived, d.pinned,
d.open, d.chat_list_hidden, d.list_order, d.pinned_order,
d.notification_mode, d.follow_mode, d.pinned_message_ids_json
FROM dialogs d
WHERE d.chat_id > ?1
ORDER BY d.chat_id ASC
LIMIT ?2",
).map_err(sqlite_error)?;
stmt.query_map(params![after, (limit + 1) as i64], sqlite_dialog_from_row)
.map_err(sqlite_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(sqlite_error)?
}
};
let has_more = rows.len() > limit;
let dialogs = rows.into_iter().take(limit).collect::<Vec<_>>();
let users = all_sqlite_users(&connection)?;
let next_cursor = match request.order {
DialogsOrder::RecentActivity => {
let start = parse_cursor(request.cursor.as_deref())?;
has_more.then(|| (start + dialogs.len()).to_string())
}
DialogsOrder::StableChatId => has_more
.then(|| {
dialogs
.last()
.map(|dialog| dialog.chat_id.get().to_string())
})
.flatten(),
};
Ok(DialogsPage {
next_cursor,
dialogs,
users,
})
}
fn dialog_sync(&self, chat_id: InlineId) -> StoreResult<Option<DialogRecord>> {
let connection = self.connection.lock().expect("sqlite store poisoned");
connection
.query_row(
"SELECT d.chat_id, d.peer_user_id, d.title, d.emoji, d.last_message_id,
(SELECT MAX(m.message_id) FROM messages m WHERE m.chat_id = d.chat_id),
d.unread_count, d.space_id, d.is_public, d.archived, d.pinned,
d.open, d.chat_list_hidden, d.list_order, d.pinned_order,
d.notification_mode, d.follow_mode, d.pinned_message_ids_json
FROM dialogs d WHERE d.chat_id = ?1",
params![chat_id.get()],
sqlite_dialog_from_row,
)
.optional()
.map_err(sqlite_error)
}
fn remove_dialog_sync(&self, chat_id: InlineId) -> StoreResult<()> {
let mut connection = self.connection.lock().expect("sqlite store poisoned");
let transaction = connection.transaction().map_err(sqlite_error)?;
transaction
.execute(
"INSERT INTO chat_tombstones (chat_id, deleted_at)
VALUES (?1, ?2)
ON CONFLICT(chat_id) DO UPDATE SET deleted_at = excluded.deleted_at",
params![chat_id.get(), now_seconds()],
)
.map_err(sqlite_error)?;
transaction
.execute(
"INSERT INTO message_tombstones (chat_id, message_id, deleted_at)
SELECT chat_id, message_id, ?2 FROM messages WHERE chat_id = ?1
ON CONFLICT(chat_id, message_id) DO UPDATE SET deleted_at = excluded.deleted_at",
params![chat_id.get(), now_seconds()],
)
.map_err(sqlite_error)?;
for table in [
"dialogs",
"messages",
"chat_participants",
"chat_participant_snapshots",
"reactions",
"reaction_snapshots",
"read_states",
] {
transaction
.execute(
&format!("DELETE FROM {table} WHERE chat_id = ?1"),
params![chat_id.get()],
)
.map_err(sqlite_error)?;
}
transaction.commit().map_err(sqlite_error)?;
Ok(())
}
fn deleted_chat_ids_sync(&self) -> StoreResult<Vec<InlineId>> {
let connection = self.connection.lock().expect("sqlite store poisoned");
let mut statement = connection
.prepare("SELECT chat_id FROM chat_tombstones ORDER BY chat_id ASC")
.map_err(sqlite_error)?;
statement
.query_map([], |row| row.get::<_, i64>(0).map(InlineId::new))
.map_err(sqlite_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(sqlite_error)
}
fn history_sync(&self, request: HistoryRequest) -> StoreResult<HistoryPage> {
let limit = request.limit.unwrap_or(50).max(1) as usize;
let connection = self.connection.lock().expect("sqlite store poisoned");
let rows = if request.before_message_id.is_some() && request.after_message_id.is_some() {
return Err(StoreError::new(
ClientErrorCategory::InvalidInput,
"history request cannot specify both before_message_id and after_message_id",
));
} else if let Some(before) = request.before_message_id {
let mut stmt = connection
.prepare(
"SELECT chat_id, message_id, sender_id, timestamp, is_outgoing,
content_json, reply_to_message_id, transaction_json
FROM messages
WHERE chat_id = ?1 AND message_id < ?2
ORDER BY message_id DESC
LIMIT ?3",
)
.map_err(sqlite_error)?;
query_message_rows(
&mut stmt,
params![request.chat_id.get(), before.get(), (limit + 1) as i64],
)?
} else if let Some(after) = request.after_message_id {
let mut stmt = connection
.prepare(
"SELECT chat_id, message_id, sender_id, timestamp, is_outgoing,
content_json, reply_to_message_id, transaction_json
FROM messages
WHERE chat_id = ?1 AND message_id > ?2
ORDER BY message_id ASC
LIMIT ?3",
)
.map_err(sqlite_error)?;
query_message_rows(
&mut stmt,
params![request.chat_id.get(), after.get(), (limit + 1) as i64],
)?
} else {
let mut stmt = connection
.prepare(
"SELECT chat_id, message_id, sender_id, timestamp, is_outgoing,
content_json, reply_to_message_id, transaction_json
FROM messages
WHERE chat_id = ?1
ORDER BY message_id DESC
LIMIT ?2",
)
.map_err(sqlite_error)?;
query_message_rows(
&mut stmt,
params![request.chat_id.get(), (limit + 1) as i64],
)?
};
let messages = rows
.into_iter()
.map(raw_sqlite_message_to_record)
.collect::<StoreResult<Vec<_>>>()?;
let (messages, has_more) = select_history_window(
messages,
limit,
request.before_message_id,
request.after_message_id,
);
let users = sqlite_users_for_messages(&connection, &messages)?;
Ok(HistoryPage {
messages,
users,
has_more,
next_cursor: None,
})
}
fn record_users_sync(&self, users: Vec<UserRecord>) -> StoreResult<()> {
if users.is_empty() {
return Ok(());
}
let connection = self.connection.lock().expect("sqlite store poisoned");
for user in users {
upsert_sqlite_user(&connection, user)?;
}
Ok(())
}
fn user_sync(&self, user_id: InlineId) -> StoreResult<Option<UserRecord>> {
let connection = self.connection.lock().expect("sqlite store poisoned");
connection
.query_row(
"SELECT user_id, display_name, username, first_name, last_name, avatar_url, is_bot
FROM users WHERE user_id = ?1",
params![user_id.get()],
sqlite_user_from_row,
)
.optional()
.map_err(sqlite_error)
}
fn record_space_sync(&self, space: SpaceRecord) -> StoreResult<()> {
let payload = serde_json::to_string(&space)
.map_err(|error| StoreError::internal(format!("encode space: {error}")))?;
let connection = self.connection.lock().expect("sqlite store poisoned");
connection
.execute(
"INSERT INTO spaces (space_id, record_json, updated_at)
VALUES (?1, ?2, ?3)
ON CONFLICT(space_id) DO UPDATE SET
record_json = excluded.record_json,
updated_at = excluded.updated_at",
params![space.space_id.get(), payload, now_seconds()],
)
.map_err(sqlite_error)?;
Ok(())
}
fn space_sync(&self, space_id: InlineId) -> StoreResult<Option<SpaceRecord>> {
let connection = self.connection.lock().expect("sqlite store poisoned");
connection
.query_row(
"SELECT record_json FROM spaces WHERE space_id = ?1",
params![space_id.get()],
|row| row.get::<_, String>(0),
)
.optional()
.map_err(sqlite_error)?
.map(|payload| {
serde_json::from_str(&payload)
.map_err(|error| StoreError::internal(format!("decode space: {error}")))
})
.transpose()
}
fn record_space_member_sync(&self, member: SpaceMemberRecord) -> StoreResult<()> {
let payload = serde_json::to_string(&member)
.map_err(|error| StoreError::internal(format!("encode space member: {error}")))?;
let connection = self.connection.lock().expect("sqlite store poisoned");
connection
.execute(
"INSERT INTO space_members (space_id, user_id, record_json, updated_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(space_id, user_id) DO UPDATE SET
record_json = excluded.record_json,
updated_at = excluded.updated_at",
params![
member.space_id.get(),
member.user_id.get(),
payload,
now_seconds()
],
)
.map_err(sqlite_error)?;
Ok(())
}
fn record_space_members_sync(
&self,
space_id: InlineId,
members: Vec<SpaceMemberRecord>,
) -> StoreResult<()> {
if members.iter().any(|member| member.space_id != space_id) {
return Err(StoreError::new(
ClientErrorCategory::InvalidInput,
"space member snapshot contains a different space id",
));
}
let mut encoded = Vec::with_capacity(members.len());
for member in members {
let payload = serde_json::to_string(&member)
.map_err(|error| StoreError::internal(format!("encode space member: {error}")))?;
encoded.push((member, payload));
}
let mut connection = self.connection.lock().expect("sqlite store poisoned");
let transaction = connection.transaction().map_err(sqlite_error)?;
transaction
.execute(
"DELETE FROM space_members WHERE space_id = ?1",
params![space_id.get()],
)
.map_err(sqlite_error)?;
for (member, payload) in encoded {
transaction
.execute(
"INSERT INTO space_members (space_id, user_id, record_json, updated_at)
VALUES (?1, ?2, ?3, ?4)",
params![
member.space_id.get(),
member.user_id.get(),
payload,
now_seconds()
],
)
.map_err(sqlite_error)?;
}
transaction.commit().map_err(sqlite_error)?;
Ok(())
}
fn remove_space_member_sync(&self, space_id: InlineId, user_id: InlineId) -> StoreResult<()> {
let connection = self.connection.lock().expect("sqlite store poisoned");
connection
.execute(
"DELETE FROM space_members WHERE space_id = ?1 AND user_id = ?2",
params![space_id.get(), user_id.get()],
)
.map_err(sqlite_error)?;
Ok(())
}
fn space_members_sync(&self, space_id: InlineId) -> StoreResult<Vec<SpaceMemberRecord>> {
let connection = self.connection.lock().expect("sqlite store poisoned");
let mut statement = connection
.prepare(
"SELECT record_json FROM space_members
WHERE space_id = ?1 ORDER BY user_id ASC",
)
.map_err(sqlite_error)?;
let payloads = statement
.query_map(params![space_id.get()], |row| row.get::<_, String>(0))
.map_err(sqlite_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(sqlite_error)?;
payloads
.into_iter()
.map(|payload| {
serde_json::from_str(&payload)
.map_err(|error| StoreError::internal(format!("decode space member: {error}")))
})
.collect()
}
fn record_user_settings_sync(&self, settings: UserSettingsRecord) -> StoreResult<()> {
let payload = serde_json::to_string(&settings)
.map_err(|error| StoreError::internal(format!("encode user settings: {error}")))?;
let connection = self.connection.lock().expect("sqlite store poisoned");
connection
.execute(
"INSERT INTO user_settings (id, settings_json, updated_at)
VALUES (1, ?1, ?2)
ON CONFLICT(id) DO UPDATE SET
settings_json = excluded.settings_json,
updated_at = excluded.updated_at",
params![payload, now_seconds()],
)
.map_err(sqlite_error)?;
Ok(())
}
fn user_settings_sync(&self) -> StoreResult<Option<UserSettingsRecord>> {
let connection = self.connection.lock().expect("sqlite store poisoned");
connection
.query_row(
"SELECT settings_json FROM user_settings WHERE id = 1",
[],
|row| row.get::<_, String>(0),
)
.optional()
.map_err(sqlite_error)?
.map(|payload| {
serde_json::from_str(&payload)
.map_err(|error| StoreError::internal(format!("decode user settings: {error}")))
})
.transpose()
}
fn record_chat_participants_sync(
&self,
chat_id: InlineId,
participants: Vec<ChatParticipantRecord>,
) -> StoreResult<()> {
let mut connection = self.connection.lock().expect("sqlite store poisoned");
let transaction = connection.transaction().map_err(sqlite_error)?;
transaction
.execute(
"DELETE FROM chat_participants WHERE chat_id = ?1",
params![chat_id.get()],
)
.map_err(sqlite_error)?;
for participant in participants {
upsert_sqlite_participant(&transaction, chat_id, participant)?;
}
transaction
.execute(
"INSERT INTO chat_participant_snapshots (chat_id, updated_at)
VALUES (?1, ?2)
ON CONFLICT(chat_id) DO UPDATE SET updated_at = excluded.updated_at",
params![chat_id.get(), now_seconds()],
)
.map_err(sqlite_error)?;
transaction.commit().map_err(sqlite_error)?;
Ok(())
}
fn record_chat_participant_sync(
&self,
chat_id: InlineId,
participant: ChatParticipantRecord,
) -> StoreResult<()> {
let connection = self.connection.lock().expect("sqlite store poisoned");
upsert_sqlite_participant(&connection, chat_id, participant)
}
fn remove_chat_participant_sync(
&self,
chat_id: InlineId,
user_id: InlineId,
) -> StoreResult<()> {
let connection = self.connection.lock().expect("sqlite store poisoned");
connection
.execute(
"DELETE FROM chat_participants WHERE chat_id = ?1 AND user_id = ?2",
params![chat_id.get(), user_id.get()],
)
.map_err(sqlite_error)?;
Ok(())
}
fn chat_participants_sync(&self, chat_id: InlineId) -> StoreResult<Vec<ChatParticipantRecord>> {
let connection = self.connection.lock().expect("sqlite store poisoned");
let mut statement = connection
.prepare(
"SELECT user_id, date FROM chat_participants
WHERE chat_id = ?1 ORDER BY user_id ASC",
)
.map_err(sqlite_error)?;
statement
.query_map(params![chat_id.get()], |row| {
Ok(ChatParticipantRecord {
user_id: InlineId::new(row.get(0)?),
date: row.get(1)?,
})
})
.map_err(sqlite_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(sqlite_error)
}
fn chat_participants_complete_sync(&self, chat_id: InlineId) -> StoreResult<bool> {
let connection = self.connection.lock().expect("sqlite store poisoned");
connection
.query_row(
"SELECT 1 FROM chat_participant_snapshots WHERE chat_id = ?1",
params![chat_id.get()],
|_| Ok(()),
)
.optional()
.map(|row| row.is_some())
.map_err(sqlite_error)
}
fn record_message_sync(&self, message: MessageRecord) -> StoreResult<()> {
let content_json = serde_json::to_string(&message.content)
.map_err(|error| StoreError::internal(format!("encode message content: {error}")))?;
let transaction_json = message
.transaction
.as_ref()
.map(serde_json::to_string)
.transpose()
.map_err(|error| {
StoreError::internal(format!("encode transaction identity: {error}"))
})?;
let connection = self.connection.lock().expect("sqlite store poisoned");
connection
.execute(
"INSERT INTO messages (
chat_id, message_id, sender_id, timestamp, is_outgoing,
content_json, reply_to_message_id, transaction_json
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(chat_id, message_id) DO UPDATE SET
sender_id = excluded.sender_id,
timestamp = excluded.timestamp,
is_outgoing = excluded.is_outgoing,
content_json = excluded.content_json,
reply_to_message_id = excluded.reply_to_message_id,
transaction_json = excluded.transaction_json",
params![
message.chat_id.get(),
message.message_id.get(),
message.sender_id.get(),
message.timestamp,
i64::from(message.is_outgoing),
content_json,
message.reply_to_message_id.map(InlineId::get),
transaction_json,
],
)
.map_err(sqlite_error)?;
connection
.execute(
"DELETE FROM message_tombstones WHERE chat_id = ?1 AND message_id = ?2",
params![message.chat_id.get(), message.message_id.get()],
)
.map_err(sqlite_error)?;
upsert_sqlite_dialog_last_message(&connection, message.chat_id, message.message_id)?;
Ok(())
}
fn record_message_deleted_sync(
&self,
chat_id: InlineId,
message_id: InlineId,
) -> StoreResult<()> {
let mut connection = self.connection.lock().expect("sqlite store poisoned");
let transaction = connection.transaction().map_err(sqlite_error)?;
transaction
.execute(
"DELETE FROM messages WHERE chat_id = ?1 AND message_id = ?2",
params![chat_id.get(), message_id.get()],
)
.map_err(sqlite_error)?;
transaction
.execute(
"DELETE FROM reactions WHERE chat_id = ?1 AND message_id = ?2",
params![chat_id.get(), message_id.get()],
)
.map_err(sqlite_error)?;
transaction
.execute(
"DELETE FROM reaction_snapshots WHERE chat_id = ?1 AND message_id = ?2",
params![chat_id.get(), message_id.get()],
)
.map_err(sqlite_error)?;
transaction
.execute(
"INSERT INTO message_tombstones (chat_id, message_id, deleted_at)
VALUES (?1, ?2, ?3)
ON CONFLICT(chat_id, message_id) DO UPDATE SET
deleted_at = excluded.deleted_at",
params![chat_id.get(), message_id.get(), now_seconds()],
)
.map_err(sqlite_error)?;
transaction.commit().map_err(sqlite_error)?;
Ok(())
}
fn message_deleted_sync(&self, chat_id: InlineId, message_id: InlineId) -> StoreResult<bool> {
let connection = self.connection.lock().expect("sqlite store poisoned");
connection
.query_row(
"SELECT 1 FROM message_tombstones WHERE chat_id = ?1 AND message_id = ?2",
params![chat_id.get(), message_id.get()],
|_| Ok(()),
)
.optional()
.map(|row| row.is_some())
.map_err(sqlite_error)
}
fn deleted_message_ids_sync(&self, chat_id: InlineId) -> StoreResult<Vec<InlineId>> {
let connection = self.connection.lock().expect("sqlite store poisoned");
let mut statement = connection
.prepare(
"SELECT message_id FROM message_tombstones
WHERE chat_id = ?1 ORDER BY message_id ASC",
)
.map_err(sqlite_error)?;
statement
.query_map(params![chat_id.get()], |row| {
row.get::<_, i64>(0).map(InlineId::new)
})
.map_err(sqlite_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(sqlite_error)
}
fn clear_chat_messages_sync(
&self,
chat_id: InlineId,
before_date: Option<i64>,
) -> StoreResult<Vec<InlineId>> {
let mut connection = self.connection.lock().expect("sqlite store poisoned");
let transaction = connection.transaction().map_err(sqlite_error)?;
let removed = {
let (query, parameters): (&str, Vec<i64>) = match before_date {
Some(date) => (
"SELECT message_id FROM messages
WHERE chat_id = ?1 AND timestamp < ?2 ORDER BY message_id ASC",
vec![chat_id.get(), date],
),
None => (
"SELECT message_id FROM messages
WHERE chat_id = ?1 ORDER BY message_id ASC",
vec![chat_id.get()],
),
};
let mut statement = transaction.prepare(query).map_err(sqlite_error)?;
statement
.query_map(rusqlite::params_from_iter(parameters), |row| {
row.get::<_, i64>(0).map(InlineId::new)
})
.map_err(sqlite_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(sqlite_error)?
};
for message_id in &removed {
transaction
.execute(
"DELETE FROM reactions WHERE chat_id = ?1 AND message_id = ?2",
params![chat_id.get(), message_id.get()],
)
.map_err(sqlite_error)?;
transaction
.execute(
"DELETE FROM reaction_snapshots WHERE chat_id = ?1 AND message_id = ?2",
params![chat_id.get(), message_id.get()],
)
.map_err(sqlite_error)?;
transaction
.execute(
"INSERT INTO message_tombstones (chat_id, message_id, deleted_at)
VALUES (?1, ?2, ?3)
ON CONFLICT(chat_id, message_id) DO UPDATE SET deleted_at = excluded.deleted_at",
params![chat_id.get(), message_id.get(), now_seconds()],
)
.map_err(sqlite_error)?;
}
match before_date {
Some(date) => transaction
.execute(
"DELETE FROM messages WHERE chat_id = ?1 AND timestamp < ?2",
params![chat_id.get(), date],
)
.map_err(sqlite_error)?,
None => transaction
.execute(
"DELETE FROM messages WHERE chat_id = ?1",
params![chat_id.get()],
)
.map_err(sqlite_error)?,
};
transaction
.execute(
"UPDATE dialogs SET
last_message_id = (SELECT MAX(message_id) FROM messages WHERE chat_id = ?1),
updated_at = ?2
WHERE chat_id = ?1",
params![chat_id.get(), now_seconds()],
)
.map_err(sqlite_error)?;
transaction.commit().map_err(sqlite_error)?;
Ok(removed)
}
fn chat_ids_in_space_sync(&self, space_id: InlineId) -> StoreResult<Vec<InlineId>> {
let connection = self.connection.lock().expect("sqlite store poisoned");
let mut statement = connection
.prepare("SELECT chat_id FROM dialogs WHERE space_id = ?1 ORDER BY chat_id ASC")
.map_err(sqlite_error)?;
statement
.query_map(params![space_id.get()], |row| {
row.get::<_, i64>(0).map(InlineId::new)
})
.map_err(sqlite_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(sqlite_error)
}
fn record_reaction_sync(&self, reaction: StoredReaction) -> StoreResult<()> {
let connection = self.connection.lock().expect("sqlite store poisoned");
connection
.execute(
"INSERT INTO reactions (chat_id, message_id, user_id, reaction, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(chat_id, message_id, user_id, reaction) DO UPDATE SET
updated_at = excluded.updated_at",
params![
reaction.chat_id.get(),
reaction.message_id.get(),
reaction.user_id.get(),
reaction.reaction,
now_seconds(),
],
)
.map_err(sqlite_error)?;
Ok(())
}
fn remove_reaction_sync(&self, reaction: StoredReaction) -> StoreResult<()> {
let connection = self.connection.lock().expect("sqlite store poisoned");
connection
.execute(
"DELETE FROM reactions
WHERE chat_id = ?1 AND message_id = ?2 AND user_id = ?3 AND reaction = ?4",
params![
reaction.chat_id.get(),
reaction.message_id.get(),
reaction.user_id.get(),
reaction.reaction,
],
)
.map_err(sqlite_error)?;
Ok(())
}
fn replace_message_reactions_sync(
&self,
chat_id: InlineId,
message_id: InlineId,
reactions: Vec<StoredReaction>,
) -> StoreResult<()> {
let mut connection = self.connection.lock().expect("sqlite store poisoned");
let transaction = connection.transaction().map_err(sqlite_error)?;
transaction
.execute(
"DELETE FROM reactions WHERE chat_id = ?1 AND message_id = ?2",
params![chat_id.get(), message_id.get()],
)
.map_err(sqlite_error)?;
for reaction in reactions {
transaction
.execute(
"INSERT INTO reactions (chat_id, message_id, user_id, reaction, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
chat_id.get(),
message_id.get(),
reaction.user_id.get(),
reaction.reaction,
now_seconds(),
],
)
.map_err(sqlite_error)?;
}
transaction
.execute(
"INSERT INTO reaction_snapshots (chat_id, message_id, updated_at)
VALUES (?1, ?2, ?3)
ON CONFLICT(chat_id, message_id) DO UPDATE SET updated_at = excluded.updated_at",
params![chat_id.get(), message_id.get(), now_seconds()],
)
.map_err(sqlite_error)?;
transaction.commit().map_err(sqlite_error)?;
Ok(())
}
fn reactions_sync(
&self,
chat_id: InlineId,
message_id: InlineId,
) -> StoreResult<Vec<StoredReaction>> {
let connection = self.connection.lock().expect("sqlite store poisoned");
let mut statement = connection
.prepare(
"SELECT user_id, reaction FROM reactions
WHERE chat_id = ?1 AND message_id = ?2
ORDER BY user_id ASC, reaction ASC",
)
.map_err(sqlite_error)?;
statement
.query_map(params![chat_id.get(), message_id.get()], |row| {
Ok(StoredReaction {
chat_id,
message_id,
user_id: InlineId::new(row.get(0)?),
reaction: row.get(1)?,
})
})
.map_err(sqlite_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(sqlite_error)
}
fn reactions_for_chat_sync(&self, chat_id: InlineId) -> StoreResult<Vec<StoredReaction>> {
let connection = self.connection.lock().expect("sqlite store poisoned");
let mut statement = connection
.prepare(
"SELECT message_id, user_id, reaction FROM reactions
WHERE chat_id = ?1
ORDER BY message_id ASC, user_id ASC, reaction ASC",
)
.map_err(sqlite_error)?;
statement
.query_map(params![chat_id.get()], |row| {
Ok(StoredReaction {
chat_id,
message_id: InlineId::new(row.get(0)?),
user_id: InlineId::new(row.get(1)?),
reaction: row.get(2)?,
})
})
.map_err(sqlite_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(sqlite_error)
}
fn reaction_snapshot_message_ids_sync(&self, chat_id: InlineId) -> StoreResult<Vec<InlineId>> {
let connection = self.connection.lock().expect("sqlite store poisoned");
let mut statement = connection
.prepare(
"SELECT message_id FROM reaction_snapshots
WHERE chat_id = ?1 ORDER BY message_id ASC",
)
.map_err(sqlite_error)?;
statement
.query_map(params![chat_id.get()], |row| {
row.get::<_, i64>(0).map(InlineId::new)
})
.map_err(sqlite_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(sqlite_error)
}
fn record_read_state_sync(&self, state: StoredReadState) -> StoreResult<()> {
let connection = self.connection.lock().expect("sqlite store poisoned");
connection
.execute(
"INSERT INTO read_states (
chat_id, read_max_id, unread_count, marked_unread, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(chat_id) DO UPDATE SET
read_max_id = excluded.read_max_id,
unread_count = excluded.unread_count,
marked_unread = excluded.marked_unread,
updated_at = excluded.updated_at",
params![
state.chat_id.get(),
state.read_max_id.map(InlineId::get),
state.unread_count.map(i64::from),
i64::from(state.marked_unread),
now_seconds(),
],
)
.map_err(sqlite_error)?;
Ok(())
}
fn read_state_sync(&self, chat_id: InlineId) -> StoreResult<Option<StoredReadState>> {
let connection = self.connection.lock().expect("sqlite store poisoned");
connection
.query_row(
"SELECT read_max_id, unread_count, marked_unread
FROM read_states WHERE chat_id = ?1",
params![chat_id.get()],
|row| {
Ok(StoredReadState {
chat_id,
read_max_id: row.get::<_, Option<i64>>(0)?.map(InlineId::new),
unread_count: row
.get::<_, Option<i64>>(1)?
.and_then(|value| u32::try_from(value).ok()),
marked_unread: row.get::<_, i64>(2)? != 0,
})
},
)
.optional()
.map_err(sqlite_error)
}
fn record_transaction_sync(&self, transaction: StoredTransaction) -> StoreResult<()> {
let identity_json = serde_json::to_string(&transaction.identity).map_err(|error| {
StoreError::internal(format!("encode transaction identity: {error}"))
})?;
let state_json = serde_json::to_string(&transaction.state)
.map_err(|error| StoreError::internal(format!("encode transaction state: {error}")))?;
let failure_json = transaction
.failure
.as_ref()
.map(serde_json::to_string)
.transpose()
.map_err(|error| {
StoreError::internal(format!("encode transaction failure: {error}"))
})?;
let connection = self.connection.lock().expect("sqlite store poisoned");
connection
.execute(
"INSERT INTO transactions (
transaction_id, identity_json, state_json, chat_id, message_id,
random_id, external_source, external_id, failure_json, created_at, updated_at
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
ON CONFLICT(transaction_id) DO UPDATE SET
identity_json = excluded.identity_json,
state_json = excluded.state_json,
chat_id = excluded.chat_id,
message_id = excluded.message_id,
random_id = excluded.random_id,
external_source = excluded.external_source,
external_id = excluded.external_id,
failure_json = excluded.failure_json,
updated_at = excluded.updated_at",
params![
transaction.identity.transaction_id.as_str(),
identity_json,
state_json,
transaction.chat_id.map(InlineId::get),
transaction.message_id.map(InlineId::get),
transaction.identity.random_id.get(),
transaction
.identity
.external_id
.as_ref()
.map(|external| external.source()),
transaction
.identity
.external_id
.as_ref()
.map(|external| external.id()),
failure_json,
transaction.created_at,
transaction.updated_at,
],
)
.map_err(sqlite_error)?;
Ok(())
}
fn transaction_sync(
&self,
transaction_id: TransactionId,
) -> StoreResult<Option<StoredTransaction>> {
let connection = self.connection.lock().expect("sqlite store poisoned");
let row = connection
.query_row(
"SELECT identity_json, state_json, chat_id, message_id,
failure_json, created_at, updated_at
FROM transactions
WHERE transaction_id = ?1",
params![transaction_id.as_str()],
|row| {
Ok(RawSqliteTransaction {
identity_json: row.get(0)?,
state_json: row.get(1)?,
chat_id: row.get(2)?,
message_id: row.get(3)?,
failure_json: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
})
},
)
.optional()
.map_err(sqlite_error)?;
row.map(raw_sqlite_transaction_to_record).transpose()
}
}
fn append_sqlite_client_events(
connection: &Connection,
events: Vec<ClientEvent>,
) -> StoreResult<Vec<ClientEventDelivery>> {
events
.into_iter()
.map(|event| {
if event.reliability() == EventReliability::BestEffort {
return Ok(ClientEventDelivery::ephemeral(event));
}
let event_json = serde_json::to_string(&event)
.map_err(|error| StoreError::internal(format!("encode client event: {error}")))?;
connection
.execute(
"INSERT INTO client_event_outbox (event_json, created_at) VALUES (?1, ?2)",
params![event_json, now_seconds()],
)
.map_err(sqlite_error)?;
let delivery_id = u64::try_from(connection.last_insert_rowid())
.map_err(|_| StoreError::internal("stored client event delivery ID was invalid"))?;
Ok(ClientEventDelivery {
delivery_id: Some(delivery_id),
event,
})
})
.collect()
}
fn prepare_private_sqlite_file(path: &Path) -> StoreResult<()> {
let mut options = OpenOptions::new();
options.create(true).truncate(false).read(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let _file = options
.open(path)
.map_err(|error| StoreError::internal(format!("prepare SQLite store file: {error}")))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(
|error| StoreError::internal(format!("secure SQLite store permissions: {error}")),
)?;
}
Ok(())
}
impl ClientStore for SqliteStore {
fn save_session(&self, session: StoredSession) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.save_session_sync(session) })
}
fn load_session(&self) -> BoxFuture<'static, StoreResult<Option<StoredSession>>> {
let store = self.clone();
Box::pin(async move { store.load_session_sync() })
}
fn clear_session(&self) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.clear_session_sync() })
}
fn clear_account_data(&self) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.clear_account_data_sync() })
}
fn sync_state(&self) -> BoxFuture<'static, StoreResult<SyncState>> {
let store = self.clone();
Box::pin(async move { store.sync_state_sync() })
}
fn save_sync_state(&self, state: SyncState) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.save_sync_state_sync(state) })
}
fn clear_sync_state(&self) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.clear_sync_state_sync() })
}
fn sync_bucket_state(
&self,
key: SyncBucketKey,
) -> BoxFuture<'static, StoreResult<SyncBucketState>> {
let store = self.clone();
Box::pin(async move { store.sync_bucket_state_sync(key) })
}
fn save_sync_bucket_state(
&self,
key: SyncBucketKey,
state: SyncBucketState,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.save_sync_bucket_state_sync(key, state) })
}
fn save_sync_bucket_states(
&self,
states: Vec<(SyncBucketKey, SyncBucketState)>,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.save_sync_bucket_states_sync(states) })
}
fn remove_sync_bucket_state(&self, key: SyncBucketKey) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.remove_sync_bucket_state_sync(key) })
}
fn save_pending_sync_batch(
&self,
batch: PendingSyncBatch,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.save_pending_sync_batch_sync(batch) })
}
fn pending_sync_batches(&self) -> BoxFuture<'static, StoreResult<Vec<PendingSyncBatch>>> {
let store = self.clone();
Box::pin(async move { store.pending_sync_batches_sync() })
}
fn discard_pending_sync_batch(
&self,
key: SyncBucketKey,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.discard_pending_sync_batch_sync(key) })
}
fn commit_pending_sync_batch(
&self,
key: SyncBucketKey,
state: SyncBucketState,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move {
store
.commit_pending_sync_batch_sync(key, state, Vec::new())
.map(|_| ())
})
}
fn commit_pending_sync_batch_with_events(
&self,
key: SyncBucketKey,
state: SyncBucketState,
events: Vec<ClientEvent>,
) -> BoxFuture<'static, StoreResult<Vec<ClientEventDelivery>>> {
let store = self.clone();
Box::pin(async move { store.commit_pending_sync_batch_sync(key, state, events) })
}
fn append_client_events(
&self,
events: Vec<ClientEvent>,
) -> BoxFuture<'static, StoreResult<Vec<ClientEventDelivery>>> {
let store = self.clone();
Box::pin(async move { store.append_client_events_sync(events) })
}
fn pending_client_events(&self) -> BoxFuture<'static, StoreResult<Vec<ClientEventDelivery>>> {
let store = self.clone();
Box::pin(async move { store.pending_client_events_sync() })
}
fn acknowledge_client_event(&self, delivery_id: u64) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.acknowledge_client_event_sync(delivery_id) })
}
fn dialogs(&self, request: DialogsRequest) -> BoxFuture<'static, StoreResult<DialogsPage>> {
let store = self.clone();
Box::pin(async move { store.dialogs_sync(request) })
}
fn dialog(&self, chat_id: InlineId) -> BoxFuture<'static, StoreResult<Option<DialogRecord>>> {
let store = self.clone();
Box::pin(async move { store.dialog_sync(chat_id) })
}
fn record_dialog(&self, dialog: DialogRecord) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.upsert_dialog(dialog) })
}
fn remove_dialog(&self, chat_id: InlineId) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.remove_dialog_sync(chat_id) })
}
fn deleted_chat_ids(&self) -> BoxFuture<'static, StoreResult<Vec<InlineId>>> {
let store = self.clone();
Box::pin(async move { store.deleted_chat_ids_sync() })
}
fn record_users(&self, users: Vec<UserRecord>) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.record_users_sync(users) })
}
fn user(&self, user_id: InlineId) -> BoxFuture<'static, StoreResult<Option<UserRecord>>> {
let store = self.clone();
Box::pin(async move { store.user_sync(user_id) })
}
fn record_space(&self, space: SpaceRecord) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.record_space_sync(space) })
}
fn space(&self, space_id: InlineId) -> BoxFuture<'static, StoreResult<Option<SpaceRecord>>> {
let store = self.clone();
Box::pin(async move { store.space_sync(space_id) })
}
fn record_space_member(
&self,
member: SpaceMemberRecord,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.record_space_member_sync(member) })
}
fn record_space_members(
&self,
space_id: InlineId,
members: Vec<SpaceMemberRecord>,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.record_space_members_sync(space_id, members) })
}
fn remove_space_member(
&self,
space_id: InlineId,
user_id: InlineId,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.remove_space_member_sync(space_id, user_id) })
}
fn space_members(
&self,
space_id: InlineId,
) -> BoxFuture<'static, StoreResult<Vec<SpaceMemberRecord>>> {
let store = self.clone();
Box::pin(async move { store.space_members_sync(space_id) })
}
fn record_user_settings(
&self,
settings: UserSettingsRecord,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.record_user_settings_sync(settings) })
}
fn user_settings(&self) -> BoxFuture<'static, StoreResult<Option<UserSettingsRecord>>> {
let store = self.clone();
Box::pin(async move { store.user_settings_sync() })
}
fn record_chat_participants(
&self,
chat_id: InlineId,
participants: Vec<ChatParticipantRecord>,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.record_chat_participants_sync(chat_id, participants) })
}
fn record_chat_participant(
&self,
chat_id: InlineId,
participant: ChatParticipantRecord,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.record_chat_participant_sync(chat_id, participant) })
}
fn remove_chat_participant(
&self,
chat_id: InlineId,
user_id: InlineId,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.remove_chat_participant_sync(chat_id, user_id) })
}
fn chat_participants(
&self,
chat_id: InlineId,
) -> BoxFuture<'static, StoreResult<Vec<ChatParticipantRecord>>> {
let store = self.clone();
Box::pin(async move { store.chat_participants_sync(chat_id) })
}
fn chat_participants_complete(
&self,
chat_id: InlineId,
) -> BoxFuture<'static, StoreResult<bool>> {
let store = self.clone();
Box::pin(async move { store.chat_participants_complete_sync(chat_id) })
}
fn history(&self, request: HistoryRequest) -> BoxFuture<'static, StoreResult<HistoryPage>> {
let store = self.clone();
Box::pin(async move { store.history_sync(request) })
}
fn record_message(&self, message: MessageRecord) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.record_message_sync(message) })
}
fn record_message_deleted(
&self,
chat_id: InlineId,
message_id: InlineId,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.record_message_deleted_sync(chat_id, message_id) })
}
fn message_deleted(
&self,
chat_id: InlineId,
message_id: InlineId,
) -> BoxFuture<'static, StoreResult<bool>> {
let store = self.clone();
Box::pin(async move { store.message_deleted_sync(chat_id, message_id) })
}
fn deleted_message_ids(
&self,
chat_id: InlineId,
) -> BoxFuture<'static, StoreResult<Vec<InlineId>>> {
let store = self.clone();
Box::pin(async move { store.deleted_message_ids_sync(chat_id) })
}
fn clear_chat_messages(
&self,
chat_id: InlineId,
before_date: Option<i64>,
) -> BoxFuture<'static, StoreResult<Vec<InlineId>>> {
let store = self.clone();
Box::pin(async move { store.clear_chat_messages_sync(chat_id, before_date) })
}
fn chat_ids_in_space(
&self,
space_id: InlineId,
) -> BoxFuture<'static, StoreResult<Vec<InlineId>>> {
let store = self.clone();
Box::pin(async move { store.chat_ids_in_space_sync(space_id) })
}
fn record_reaction(&self, reaction: StoredReaction) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.record_reaction_sync(reaction) })
}
fn remove_reaction(&self, reaction: StoredReaction) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.remove_reaction_sync(reaction) })
}
fn replace_message_reactions(
&self,
chat_id: InlineId,
message_id: InlineId,
reactions: Vec<StoredReaction>,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(
async move { store.replace_message_reactions_sync(chat_id, message_id, reactions) },
)
}
fn reactions(
&self,
chat_id: InlineId,
message_id: InlineId,
) -> BoxFuture<'static, StoreResult<Vec<StoredReaction>>> {
let store = self.clone();
Box::pin(async move { store.reactions_sync(chat_id, message_id) })
}
fn reactions_for_chat(
&self,
chat_id: InlineId,
) -> BoxFuture<'static, StoreResult<Vec<StoredReaction>>> {
let store = self.clone();
Box::pin(async move { store.reactions_for_chat_sync(chat_id) })
}
fn reaction_snapshot_message_ids(
&self,
chat_id: InlineId,
) -> BoxFuture<'static, StoreResult<Vec<InlineId>>> {
let store = self.clone();
Box::pin(async move { store.reaction_snapshot_message_ids_sync(chat_id) })
}
fn record_read_state(&self, state: StoredReadState) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.record_read_state_sync(state) })
}
fn read_state(
&self,
chat_id: InlineId,
) -> BoxFuture<'static, StoreResult<Option<StoredReadState>>> {
let store = self.clone();
Box::pin(async move { store.read_state_sync(chat_id) })
}
fn record_transaction(
&self,
transaction: StoredTransaction,
) -> BoxFuture<'static, StoreResult<()>> {
let store = self.clone();
Box::pin(async move { store.record_transaction_sync(transaction) })
}
fn transaction(
&self,
transaction_id: TransactionId,
) -> BoxFuture<'static, StoreResult<Option<StoredTransaction>>> {
let store = self.clone();
Box::pin(async move { store.transaction_sync(transaction_id) })
}
}
pub(crate) fn parse_cursor(cursor: Option<&str>) -> StoreResult<usize> {
match cursor {
Some(cursor) if !cursor.trim().is_empty() => cursor.parse::<usize>().map_err(|_| {
StoreError::new(
ClientErrorCategory::InvalidInput,
"invalid pagination cursor",
)
}),
_ => Ok(0),
}
}
fn parse_stable_dialog_cursor(cursor: Option<&str>) -> StoreResult<i64> {
match cursor {
Some(cursor) if !cursor.trim().is_empty() => {
let value = cursor.parse::<i64>().map_err(|_| {
StoreError::new(
ClientErrorCategory::InvalidInput,
"invalid stable dialog pagination cursor",
)
})?;
if value < 0 {
return Err(StoreError::new(
ClientErrorCategory::InvalidInput,
"invalid stable dialog pagination cursor",
));
}
Ok(value)
}
_ => Ok(0),
}
}
fn migrate_sqlite(connection: &Connection) -> StoreResult<()> {
connection
.execute_batch(
"
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY CHECK (id = 1),
auth_json TEXT NOT NULL,
account_namespace TEXT,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sync_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
last_sync_date INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sync_buckets (
bucket_kind TEXT NOT NULL,
entity_id INTEGER NOT NULL,
seq INTEGER NOT NULL,
date INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (bucket_kind, entity_id)
);
CREATE TABLE IF NOT EXISTS pending_sync_batches (
bucket_kind TEXT NOT NULL,
entity_id INTEGER NOT NULL,
seq INTEGER NOT NULL,
date INTEGER NOT NULL,
payload BLOB NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (bucket_kind, entity_id)
);
CREATE TABLE IF NOT EXISTS client_event_outbox (
delivery_id INTEGER PRIMARY KEY AUTOINCREMENT,
event_json TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS dialogs (
chat_id INTEGER PRIMARY KEY,
peer_user_id INTEGER,
title TEXT,
emoji TEXT,
last_message_id INTEGER,
unread_count INTEGER,
space_id INTEGER,
is_public INTEGER,
archived INTEGER,
pinned INTEGER,
open INTEGER,
chat_list_hidden INTEGER,
list_order TEXT,
pinned_order TEXT,
notification_mode TEXT,
follow_mode TEXT,
pinned_message_ids_json TEXT NOT NULL DEFAULT '[]',
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS chat_tombstones (
chat_id INTEGER PRIMARY KEY,
deleted_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
user_id INTEGER PRIMARY KEY,
display_name TEXT,
username TEXT,
first_name TEXT,
last_name TEXT,
avatar_url TEXT,
is_bot INTEGER,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS spaces (
space_id INTEGER PRIMARY KEY,
record_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS space_members (
space_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
record_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (space_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_space_members_space
ON space_members (space_id, user_id);
CREATE TABLE IF NOT EXISTS user_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
settings_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS chat_participants (
chat_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
date INTEGER,
updated_at INTEGER NOT NULL,
PRIMARY KEY (chat_id, user_id)
);
CREATE TABLE IF NOT EXISTS chat_participant_snapshots (
chat_id INTEGER PRIMARY KEY,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
chat_id INTEGER NOT NULL,
message_id INTEGER NOT NULL,
sender_id INTEGER NOT NULL,
timestamp INTEGER NOT NULL,
is_outgoing INTEGER NOT NULL,
content_json TEXT NOT NULL,
reply_to_message_id INTEGER,
transaction_json TEXT,
PRIMARY KEY (chat_id, message_id)
);
CREATE INDEX IF NOT EXISTS idx_messages_chat_message
ON messages (chat_id, message_id DESC);
CREATE TABLE IF NOT EXISTS message_tombstones (
chat_id INTEGER NOT NULL,
message_id INTEGER NOT NULL,
deleted_at INTEGER NOT NULL,
PRIMARY KEY (chat_id, message_id)
);
CREATE TABLE IF NOT EXISTS reactions (
chat_id INTEGER NOT NULL,
message_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
reaction TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (chat_id, message_id, user_id, reaction)
);
CREATE INDEX IF NOT EXISTS idx_reactions_message
ON reactions (chat_id, message_id);
CREATE TABLE IF NOT EXISTS reaction_snapshots (
chat_id INTEGER NOT NULL,
message_id INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (chat_id, message_id)
);
CREATE TABLE IF NOT EXISTS read_states (
chat_id INTEGER PRIMARY KEY,
read_max_id INTEGER,
unread_count INTEGER,
marked_unread INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS transactions (
transaction_id TEXT PRIMARY KEY,
identity_json TEXT NOT NULL,
state_json TEXT NOT NULL,
chat_id INTEGER,
message_id INTEGER,
random_id INTEGER NOT NULL,
external_source TEXT,
external_id TEXT,
failure_json TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_transactions_external
ON transactions (external_source, external_id);
CREATE INDEX IF NOT EXISTS idx_transactions_random
ON transactions (random_id);
",
)
.map_err(sqlite_error)?;
ensure_sqlite_column(connection, "sessions", "account_namespace", "TEXT")?;
ensure_sqlite_column(connection, "dialogs", "peer_user_id", "INTEGER")?;
ensure_sqlite_column(connection, "dialogs", "emoji", "TEXT")?;
ensure_sqlite_column(connection, "dialogs", "space_id", "INTEGER")?;
ensure_sqlite_column(connection, "dialogs", "is_public", "INTEGER")?;
ensure_sqlite_column(connection, "dialogs", "archived", "INTEGER")?;
ensure_sqlite_column(connection, "dialogs", "pinned", "INTEGER")?;
ensure_sqlite_column(connection, "dialogs", "open", "INTEGER")?;
ensure_sqlite_column(connection, "dialogs", "chat_list_hidden", "INTEGER")?;
ensure_sqlite_column(connection, "dialogs", "list_order", "TEXT")?;
ensure_sqlite_column(connection, "dialogs", "pinned_order", "TEXT")?;
ensure_sqlite_column(connection, "dialogs", "notification_mode", "TEXT")?;
ensure_sqlite_column(connection, "dialogs", "follow_mode", "TEXT")?;
ensure_sqlite_column(
connection,
"dialogs",
"pinned_message_ids_json",
"TEXT NOT NULL DEFAULT '[]'",
)?;
Ok(())
}
fn ensure_sqlite_column(
connection: &Connection,
table: &str,
column: &str,
definition: &str,
) -> StoreResult<()> {
let pragma = format!("PRAGMA table_info({table})");
let mut stmt = connection.prepare(&pragma).map_err(sqlite_error)?;
let columns = stmt
.query_map([], |row| row.get::<_, String>(1))
.map_err(sqlite_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(sqlite_error)?;
if columns.iter().any(|name| name == column) {
return Ok(());
}
connection
.execute(
&format!("ALTER TABLE {table} ADD COLUMN {column} {definition}"),
[],
)
.map_err(sqlite_error)?;
Ok(())
}
fn sync_bucket_key_parts(key: SyncBucketKey) -> (&'static str, i64) {
match key {
SyncBucketKey::User => ("user", 0),
SyncBucketKey::Space { space_id } => ("space", space_id.get()),
SyncBucketKey::Chat {
peer: SyncBucketPeer::User { user_id },
} => ("chat_user", user_id.get()),
SyncBucketKey::Chat {
peer: SyncBucketPeer::Chat { chat_id },
} => ("chat_chat", chat_id.get()),
}
}
fn sync_bucket_key_from_parts(kind: &str, entity_id: i64) -> StoreResult<SyncBucketKey> {
match kind {
"user" if entity_id == 0 => Ok(SyncBucketKey::User),
"space" => Ok(SyncBucketKey::Space {
space_id: InlineId::new(entity_id),
}),
"chat_user" => Ok(SyncBucketKey::Chat {
peer: SyncBucketPeer::User {
user_id: InlineId::new(entity_id),
},
}),
"chat_chat" => Ok(SyncBucketKey::Chat {
peer: SyncBucketPeer::Chat {
chat_id: InlineId::new(entity_id),
},
}),
_ => Err(StoreError::internal(format!(
"invalid stored sync bucket {kind:?}/{entity_id}"
))),
}
}
fn sync_bucket_sort_key(key: SyncBucketKey) -> (u8, i64) {
match key {
SyncBucketKey::User => (0, 0),
SyncBucketKey::Space { space_id } => (1, space_id.get()),
SyncBucketKey::Chat {
peer: SyncBucketPeer::User { user_id },
} => (2, user_id.get()),
SyncBucketKey::Chat {
peer: SyncBucketPeer::Chat { chat_id },
} => (3, chat_id.get()),
}
}
fn upsert_sync_bucket(
connection: &Connection,
kind: &str,
entity_id: i64,
state: SyncBucketState,
) -> StoreResult<()> {
connection
.execute(
"INSERT INTO sync_buckets (bucket_kind, entity_id, seq, date, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(bucket_kind, entity_id) DO UPDATE SET
seq = excluded.seq,
date = excluded.date,
updated_at = excluded.updated_at",
params![kind, entity_id, state.seq, state.date, now_seconds()],
)
.map_err(sqlite_error)?;
Ok(())
}
fn upsert_sqlite_user(connection: &Connection, user: UserRecord) -> StoreResult<()> {
connection
.execute(
"INSERT INTO users (
user_id, display_name, username, first_name, last_name,
avatar_url, is_bot, updated_at
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(user_id) DO UPDATE SET
display_name = excluded.display_name,
username = excluded.username,
first_name = excluded.first_name,
last_name = excluded.last_name,
avatar_url = excluded.avatar_url,
is_bot = excluded.is_bot,
updated_at = excluded.updated_at",
params![
user.user_id.get(),
user.display_name,
user.username,
user.first_name,
user.last_name,
user.avatar_url,
user.is_bot.map(|is_bot| if is_bot { 1_i64 } else { 0_i64 }),
now_seconds(),
],
)
.map_err(sqlite_error)?;
Ok(())
}
fn upsert_sqlite_participant(
connection: &Connection,
chat_id: InlineId,
participant: ChatParticipantRecord,
) -> StoreResult<()> {
connection
.execute(
"INSERT INTO chat_participants (chat_id, user_id, date, updated_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(chat_id, user_id) DO UPDATE SET
date = excluded.date,
updated_at = excluded.updated_at",
params![
chat_id.get(),
participant.user_id.get(),
participant.date,
now_seconds(),
],
)
.map_err(sqlite_error)?;
Ok(())
}
fn sqlite_dialog_from_row(row: &Row<'_>) -> rusqlite::Result<DialogRecord> {
let pinned_json = row.get::<_, String>(17)?;
let pinned_message_ids = serde_json::from_str(&pinned_json).map_err(|error| {
rusqlite::Error::FromSqlConversionFailure(17, Type::Text, Box::new(error))
})?;
Ok(DialogRecord {
chat_id: InlineId::new(row.get(0)?),
peer_user_id: row.get::<_, Option<i64>>(1)?.map(InlineId::new),
title: row.get(2)?,
emoji: row.get(3)?,
last_message_id: row.get::<_, Option<i64>>(4)?.map(InlineId::new),
synced_through_message_id: row.get::<_, Option<i64>>(5)?.map(InlineId::new),
unread_count: row
.get::<_, Option<i64>>(6)?
.and_then(|value| u32::try_from(value).ok()),
space_id: row.get::<_, Option<i64>>(7)?.map(InlineId::new),
is_public: row.get::<_, Option<bool>>(8)?,
archived: row.get::<_, Option<bool>>(9)?,
pinned: row.get::<_, Option<bool>>(10)?,
open: row.get::<_, Option<bool>>(11)?,
chat_list_hidden: row.get::<_, Option<bool>>(12)?,
order: row.get(13)?,
pinned_order: row.get(14)?,
notification_mode: row
.get::<_, Option<String>>(15)?
.as_deref()
.and_then(dialog_notification_mode_from_store),
follow_mode: row
.get::<_, Option<String>>(16)?
.as_deref()
.and_then(dialog_follow_mode_from_store),
pinned_message_ids,
})
}
fn upsert_sqlite_dialog(connection: &Connection, dialog: DialogRecord) -> StoreResult<()> {
let chat_id = dialog.chat_id;
let pinned_message_ids_json = serde_json::to_string(&dialog.pinned_message_ids)
.map_err(|error| StoreError::internal(format!("encode pinned message IDs: {error}")))?;
connection
.execute(
"INSERT INTO dialogs (
chat_id, peer_user_id, title, emoji, last_message_id, unread_count,
space_id, is_public, archived, pinned, open, chat_list_hidden,
list_order, pinned_order, notification_mode, follow_mode,
pinned_message_ids_json, updated_at
) VALUES (
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13,
?14, ?15, ?16, ?17, ?18
)
ON CONFLICT(chat_id) DO UPDATE SET
peer_user_id = excluded.peer_user_id,
title = excluded.title,
emoji = excluded.emoji,
last_message_id = excluded.last_message_id,
unread_count = excluded.unread_count,
space_id = excluded.space_id,
is_public = excluded.is_public,
archived = excluded.archived,
pinned = excluded.pinned,
open = excluded.open,
chat_list_hidden = excluded.chat_list_hidden,
list_order = excluded.list_order,
pinned_order = excluded.pinned_order,
notification_mode = excluded.notification_mode,
follow_mode = excluded.follow_mode,
pinned_message_ids_json = excluded.pinned_message_ids_json,
updated_at = excluded.updated_at",
params![
dialog.chat_id.get(),
dialog.peer_user_id.map(InlineId::get),
dialog.title,
dialog.emoji,
dialog.last_message_id.map(InlineId::get),
dialog.unread_count.map(i64::from),
dialog.space_id.map(InlineId::get),
dialog.is_public.map(i64::from),
dialog.archived.map(i64::from),
dialog.pinned.map(i64::from),
dialog.open.map(i64::from),
dialog.chat_list_hidden.map(i64::from),
dialog.order,
dialog.pinned_order,
dialog
.notification_mode
.map(dialog_notification_mode_for_store),
dialog.follow_mode.map(dialog_follow_mode_for_store),
pinned_message_ids_json,
now_seconds(),
],
)
.map_err(sqlite_error)?;
connection
.execute(
"DELETE FROM chat_tombstones WHERE chat_id = ?1",
params![chat_id.get()],
)
.map_err(sqlite_error)?;
Ok(())
}
const fn dialog_notification_mode_for_store(mode: DialogNotificationMode) -> &'static str {
match mode {
DialogNotificationMode::All => "all",
DialogNotificationMode::Mentions => "mentions",
DialogNotificationMode::None => "none",
}
}
fn dialog_notification_mode_from_store(value: &str) -> Option<DialogNotificationMode> {
match value {
"all" => Some(DialogNotificationMode::All),
"mentions" => Some(DialogNotificationMode::Mentions),
"none" => Some(DialogNotificationMode::None),
_ => None,
}
}
const fn dialog_follow_mode_for_store(mode: DialogFollowMode) -> &'static str {
match mode {
DialogFollowMode::Following => "following",
DialogFollowMode::Unfollowed => "unfollowed",
}
}
fn dialog_follow_mode_from_store(value: &str) -> Option<DialogFollowMode> {
match value {
"following" => Some(DialogFollowMode::Following),
"unfollowed" => Some(DialogFollowMode::Unfollowed),
_ => None,
}
}
fn upsert_sqlite_dialog_last_message(
connection: &Connection,
chat_id: InlineId,
message_id: InlineId,
) -> StoreResult<()> {
let updated = connection
.execute(
"UPDATE dialogs
SET last_message_id = ?1, updated_at = ?2
WHERE chat_id = ?3",
params![message_id.get(), now_seconds(), chat_id.get()],
)
.map_err(sqlite_error)?;
if updated == 0 {
connection
.execute(
"INSERT INTO dialogs (chat_id, title, last_message_id, unread_count, updated_at)
VALUES (?1, NULL, ?2, 0, ?3)",
params![chat_id.get(), message_id.get(), now_seconds()],
)
.map_err(sqlite_error)?;
}
connection
.execute(
"DELETE FROM chat_tombstones WHERE chat_id = ?1",
params![chat_id.get()],
)
.map_err(sqlite_error)?;
Ok(())
}
fn all_sqlite_users(connection: &Connection) -> StoreResult<Vec<UserRecord>> {
let mut stmt = connection
.prepare(
"SELECT user_id, display_name, username, first_name, last_name, avatar_url, is_bot
FROM users
ORDER BY user_id ASC",
)
.map_err(sqlite_error)?;
query_user_rows(&mut stmt, [])
}
fn sqlite_users_for_messages(
connection: &Connection,
messages: &[MessageRecord],
) -> StoreResult<Vec<UserRecord>> {
let mut user_ids = messages
.iter()
.map(|message| message.sender_id.get())
.collect::<Vec<_>>();
user_ids.sort_unstable();
user_ids.dedup();
let mut users = Vec::new();
let mut stmt = connection
.prepare(
"SELECT user_id, display_name, username, first_name, last_name, avatar_url, is_bot
FROM users
WHERE user_id = ?1",
)
.map_err(sqlite_error)?;
for user_id in user_ids {
if let Some(user) = stmt
.query_row(params![user_id], sqlite_user_from_row)
.optional()
.map_err(sqlite_error)?
{
users.push(user);
}
}
Ok(users)
}
fn query_user_rows<P>(stmt: &mut rusqlite::Statement<'_>, params: P) -> StoreResult<Vec<UserRecord>>
where
P: rusqlite::Params,
{
stmt.query_map(params, sqlite_user_from_row)
.map_err(sqlite_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(sqlite_error)
}
fn sqlite_user_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<UserRecord> {
Ok(UserRecord {
user_id: InlineId::new(row.get::<_, i64>(0)?),
display_name: row.get::<_, Option<String>>(1)?,
username: row.get::<_, Option<String>>(2)?,
first_name: row.get::<_, Option<String>>(3)?,
last_name: row.get::<_, Option<String>>(4)?,
avatar_url: row.get::<_, Option<String>>(5)?,
is_bot: row.get::<_, Option<i64>>(6)?.map(|value| value != 0),
})
}
#[derive(Debug)]
struct RawSqliteMessage {
chat_id: i64,
message_id: i64,
sender_id: i64,
timestamp: i64,
is_outgoing: bool,
content_json: String,
reply_to_message_id: Option<i64>,
transaction_json: Option<String>,
}
fn query_message_rows<P>(
stmt: &mut rusqlite::Statement<'_>,
params: P,
) -> StoreResult<Vec<RawSqliteMessage>>
where
P: rusqlite::Params,
{
stmt.query_map(params, |row| {
Ok(RawSqliteMessage {
chat_id: row.get(0)?,
message_id: row.get(1)?,
sender_id: row.get(2)?,
timestamp: row.get(3)?,
is_outgoing: row.get::<_, i64>(4)? != 0,
content_json: row.get(5)?,
reply_to_message_id: row.get(6)?,
transaction_json: row.get(7)?,
})
})
.map_err(sqlite_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(sqlite_error)
}
fn raw_sqlite_message_to_record(raw: RawSqliteMessage) -> StoreResult<MessageRecord> {
let content = serde_json::from_str::<MessageContent>(&raw.content_json)
.map_err(|error| StoreError::internal(format!("decode message content: {error}")))?;
let transaction = raw
.transaction_json
.as_deref()
.map(serde_json::from_str::<TransactionIdentity>)
.transpose()
.map_err(|error| StoreError::internal(format!("decode transaction identity: {error}")))?;
Ok(MessageRecord {
chat_id: InlineId::new(raw.chat_id),
message_id: InlineId::new(raw.message_id),
sender_id: InlineId::new(raw.sender_id),
timestamp: raw.timestamp,
is_outgoing: raw.is_outgoing,
content,
reply_to_message_id: raw.reply_to_message_id.map(InlineId::new),
transaction,
})
}
#[derive(Debug)]
struct RawSqliteTransaction {
identity_json: String,
state_json: String,
chat_id: Option<i64>,
message_id: Option<i64>,
failure_json: Option<String>,
created_at: i64,
updated_at: i64,
}
fn raw_sqlite_transaction_to_record(raw: RawSqliteTransaction) -> StoreResult<StoredTransaction> {
let identity = serde_json::from_str::<TransactionIdentity>(&raw.identity_json)
.map_err(|error| StoreError::internal(format!("decode transaction identity: {error}")))?;
let state = serde_json::from_str::<TransactionState>(&raw.state_json)
.map_err(|error| StoreError::internal(format!("decode transaction state: {error}")))?;
let failure = raw
.failure_json
.as_deref()
.map(serde_json::from_str::<ClientFailure>)
.transpose()
.map_err(|error| StoreError::internal(format!("decode transaction failure: {error}")))?;
Ok(StoredTransaction {
identity,
state,
chat_id: raw.chat_id.map(InlineId::new),
message_id: raw.message_id.map(InlineId::new),
failure,
created_at: raw.created_at,
updated_at: raw.updated_at,
})
}
fn sqlite_error(error: rusqlite::Error) -> StoreError {
StoreError::internal(format!("sqlite store error: {error}"))
}
fn now_seconds() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
.unwrap_or_default()
}
fn all_users_from_memory(users: &HashMap<i64, UserRecord>) -> Vec<UserRecord> {
let mut users = users.values().cloned().collect::<Vec<_>>();
users.sort_by_key(|user| user.user_id.get());
users
}
fn max_message_id_from_memory(
messages: &HashMap<i64, Vec<MessageRecord>>,
chat_id: InlineId,
) -> Option<InlineId> {
messages
.get(&chat_id.get())?
.iter()
.map(|message| message.message_id.get())
.max()
.map(InlineId::new)
}
fn dialog_with_synced_through(
mut dialog: DialogRecord,
synced_through_message_id: Option<InlineId>,
) -> DialogRecord {
dialog.synced_through_message_id = synced_through_message_id;
dialog
}
fn users_for_messages_from_memory(
users: &HashMap<i64, UserRecord>,
messages: &[MessageRecord],
) -> Vec<UserRecord> {
let mut user_ids = messages
.iter()
.map(|message| message.sender_id.get())
.collect::<Vec<_>>();
user_ids.sort_unstable();
user_ids.dedup();
user_ids
.into_iter()
.filter_map(|user_id| users.get(&user_id).cloned())
.collect()
}
fn upsert_dialog(dialogs: &mut Vec<DialogRecord>, dialog: DialogRecord) {
if let Some(existing) = dialogs
.iter_mut()
.find(|existing| existing.chat_id == dialog.chat_id)
{
*existing = dialog;
return;
}
dialogs.push(dialog);
}
fn insert_message(messages: &mut HashMap<i64, Vec<MessageRecord>>, message: MessageRecord) {
let chat_id = message.chat_id.get();
messages.entry(chat_id).or_default().push(message);
if let Some(messages) = messages.get_mut(&chat_id) {
messages.sort_by_key(|message| (message.timestamp, message.message_id.get()));
}
}
fn reaction_key(reaction: &StoredReaction) -> (i64, i64, i64, String) {
(
reaction.chat_id.get(),
reaction.message_id.get(),
reaction.user_id.get(),
reaction.reaction.clone(),
)
}
fn sort_reactions(reactions: &mut [StoredReaction]) {
reactions.sort_by(|left, right| {
(left.user_id.get(), left.reaction.as_str())
.cmp(&(right.user_id.get(), right.reaction.as_str()))
});
}
fn sort_participants(participants: &mut [ChatParticipantRecord]) {
participants.sort_by_key(|participant| participant.user_id.get());
}
pub(crate) fn upsert_dialog_last_message(
dialogs: &mut Vec<DialogRecord>,
chat_id: InlineId,
message_id: InlineId,
) {
if let Some(dialog) = dialogs.iter_mut().find(|dialog| dialog.chat_id == chat_id) {
dialog.last_message_id = Some(message_id);
return;
}
dialogs.push(DialogRecord {
chat_id,
peer_user_id: None,
title: None,
last_message_id: Some(message_id),
synced_through_message_id: None,
unread_count: Some(0),
..DialogRecord::new(chat_id)
});
}
#[cfg(test)]
mod tests {
use crate::{AuthToken, ExternalId, RandomId};
use super::*;
fn session() -> StoredSession {
StoredSession {
auth: AuthCredential::AccessToken {
token: AuthToken::try_new("secret-token").unwrap(),
},
account_namespace: Some("team".to_owned()),
}
}
fn test_message(message_id: i64) -> MessageRecord {
MessageRecord {
chat_id: InlineId::new(7),
message_id: InlineId::new(message_id),
sender_id: InlineId::new(2),
timestamp: message_id,
is_outgoing: false,
content: MessageContent::Text {
text: format!("message {message_id}"),
},
reply_to_message_id: None,
transaction: None,
}
}
#[tokio::test]
async fn stored_session_debug_redacts_token() {
let rendered = format!("{:?}", session());
assert!(rendered.contains("[redacted]"));
assert!(rendered.contains("<redacted>"));
assert!(!rendered.contains("secret-token"));
assert!(!rendered.contains("team"));
}
#[tokio::test]
async fn in_memory_store_saves_and_clears_session() {
let store = InMemoryStore::new();
store.save_session(session()).await.unwrap();
assert!(store.load_session().await.unwrap().is_some());
store.clear_session().await.unwrap();
assert!(store.load_session().await.unwrap().is_none());
}
#[tokio::test]
async fn in_memory_store_round_trips_sync_cursors() {
let store = InMemoryStore::new();
let user = SyncBucketKey::User;
let direct = SyncBucketKey::Chat {
peer: SyncBucketPeer::User {
user_id: InlineId::new(2),
},
};
let chat = SyncBucketKey::Chat {
peer: SyncBucketPeer::Chat {
chat_id: InlineId::new(7),
},
};
let space = SyncBucketKey::Space {
space_id: InlineId::new(9),
};
assert_eq!(store.sync_state().await.unwrap(), SyncState::default());
assert_eq!(
store.sync_bucket_state(user).await.unwrap(),
SyncBucketState::default()
);
store
.save_sync_state(SyncState { last_sync_date: 40 })
.await
.unwrap();
store
.save_sync_bucket_state(user, SyncBucketState { seq: 3, date: 30 })
.await
.unwrap();
store
.save_sync_bucket_states(vec![
(direct, SyncBucketState { seq: 4, date: 31 }),
(chat, SyncBucketState { seq: 5, date: 32 }),
(space, SyncBucketState { seq: 6, date: 33 }),
])
.await
.unwrap();
store
.save_sync_bucket_state(user, SyncBucketState { seq: 7, date: 41 })
.await
.unwrap();
assert_eq!(
store.sync_state().await.unwrap(),
SyncState { last_sync_date: 40 }
);
assert_eq!(
store.sync_bucket_state(user).await.unwrap(),
SyncBucketState { seq: 7, date: 41 }
);
assert_eq!(
store.sync_bucket_state(direct).await.unwrap(),
SyncBucketState { seq: 4, date: 31 }
);
assert_eq!(
store.sync_bucket_state(chat).await.unwrap(),
SyncBucketState { seq: 5, date: 32 }
);
assert_eq!(
store.sync_bucket_state(space).await.unwrap(),
SyncBucketState { seq: 6, date: 33 }
);
store.remove_sync_bucket_state(direct).await.unwrap();
assert_eq!(
store.sync_bucket_state(direct).await.unwrap(),
SyncBucketState::default()
);
store.clear_sync_state().await.unwrap();
assert_eq!(store.sync_state().await.unwrap(), SyncState::default());
assert_eq!(
store.sync_bucket_state(user).await.unwrap(),
SyncBucketState::default()
);
assert_eq!(
store.sync_bucket_state(space).await.unwrap(),
SyncBucketState::default()
);
}
#[tokio::test]
async fn in_memory_store_lists_dialogs_and_history() {
let store = InMemoryStore::new();
store.upsert_dialog(DialogRecord {
chat_id: InlineId::new(7),
peer_user_id: None,
title: Some("general".to_owned()),
last_message_id: None,
synced_through_message_id: None,
unread_count: Some(0),
..DialogRecord::new(InlineId::new(7))
});
store
.record_users(vec![UserRecord {
user_id: InlineId::new(2),
display_name: Some("Ada Lovelace".to_owned()),
username: Some("ada".to_owned()),
first_name: Some("Ada".to_owned()),
last_name: Some("Lovelace".to_owned()),
avatar_url: None,
is_bot: Some(false),
}])
.await
.unwrap();
store
.record_message(MessageRecord {
chat_id: InlineId::new(7),
message_id: InlineId::new(1),
sender_id: InlineId::new(2),
timestamp: 1,
is_outgoing: false,
content: MessageContent::Text {
text: "hello".to_owned(),
},
reply_to_message_id: None,
transaction: None,
})
.await
.unwrap();
let dialogs = store.dialogs(DialogsRequest::default()).await.unwrap();
assert_eq!(dialogs.dialogs.len(), 1);
assert_eq!(
dialogs.dialogs[0].synced_through_message_id,
Some(InlineId::new(1))
);
assert_eq!(dialogs.users.len(), 1);
assert_eq!(
dialogs.users[0].display_name.as_deref(),
Some("Ada Lovelace")
);
let history = store
.history(HistoryRequest {
chat_id: InlineId::new(7),
limit: Some(10),
before_message_id: None,
after_message_id: None,
})
.await
.unwrap();
assert_eq!(history.messages.len(), 1);
assert_eq!(history.users.len(), 1);
assert_eq!(history.users[0].user_id, InlineId::new(2));
}
#[tokio::test]
async fn stable_dialog_cursor_does_not_duplicate_when_activity_order_changes() {
let store = InMemoryStore::new();
for (chat_id, last_message_id) in [(10, 100), (30, 300), (50, 500)] {
store.upsert_dialog(DialogRecord {
chat_id: InlineId::new(chat_id),
last_message_id: Some(InlineId::new(last_message_id)),
..DialogRecord::new(InlineId::new(chat_id))
});
}
let first = store
.dialogs(DialogsRequest {
limit: Some(2),
cursor: None,
order: DialogsOrder::StableChatId,
})
.await
.unwrap();
assert_eq!(
first
.dialogs
.iter()
.map(|dialog| dialog.chat_id.get())
.collect::<Vec<_>>(),
vec![10, 30]
);
assert_eq!(first.next_cursor.as_deref(), Some("30"));
store.upsert_dialog(DialogRecord {
chat_id: InlineId::new(50),
last_message_id: Some(InlineId::new(1_000)),
..DialogRecord::new(InlineId::new(50))
});
store.upsert_dialog(DialogRecord::new(InlineId::new(20)));
store.upsert_dialog(DialogRecord::new(InlineId::new(40)));
let second = store
.dialogs(DialogsRequest {
limit: Some(2),
cursor: first.next_cursor,
order: DialogsOrder::StableChatId,
})
.await
.unwrap();
assert_eq!(
second
.dialogs
.iter()
.map(|dialog| dialog.chat_id.get())
.collect::<Vec<_>>(),
vec![40, 50]
);
assert_eq!(second.next_cursor, None);
}
#[tokio::test]
async fn in_memory_store_records_transactions() {
let store = InMemoryStore::new();
let transaction = stored_transaction("txn-mem", TransactionState::Queued);
store.record_transaction(transaction.clone()).await.unwrap();
let loaded = store
.transaction(transaction.identity.transaction_id.clone())
.await
.unwrap()
.unwrap();
assert_eq!(
loaded.identity.transaction_id,
transaction.identity.transaction_id
);
assert_eq!(loaded.state, TransactionState::Queued);
}
#[tokio::test]
async fn in_memory_store_persists_message_reaction_and_read_mutations() {
let store = InMemoryStore::new();
let message = test_message(10);
let reaction = StoredReaction {
chat_id: message.chat_id,
message_id: message.message_id,
user_id: InlineId::new(2),
reaction: "👍".to_owned(),
};
let read_state = StoredReadState {
chat_id: message.chat_id,
read_max_id: Some(message.message_id),
unread_count: Some(3),
marked_unread: false,
};
store.record_message(message.clone()).await.unwrap();
store.record_reaction(reaction.clone()).await.unwrap();
store.record_read_state(read_state).await.unwrap();
assert_eq!(
store
.reactions(message.chat_id, message.message_id)
.await
.unwrap(),
vec![reaction.clone()]
);
assert_eq!(
store.read_state(message.chat_id).await.unwrap(),
Some(read_state)
);
store
.record_message_deleted(message.chat_id, message.message_id)
.await
.unwrap();
assert!(
store
.message_deleted(message.chat_id, message.message_id)
.await
.unwrap()
);
assert!(
store
.reactions(message.chat_id, message.message_id)
.await
.unwrap()
.is_empty()
);
store.record_message(message.clone()).await.unwrap();
assert!(
!store
.message_deleted(message.chat_id, message.message_id)
.await
.unwrap()
);
}
#[tokio::test]
async fn in_memory_store_fetches_history_after_checkpoint() {
let store = InMemoryStore::new();
for message_id in 1..=3 {
store
.record_message(test_message(message_id))
.await
.unwrap();
}
let history = store
.history(HistoryRequest {
chat_id: InlineId::new(7),
limit: Some(1),
before_message_id: None,
after_message_id: Some(InlineId::new(1)),
})
.await
.unwrap();
assert_eq!(history.messages.len(), 1);
assert_eq!(history.messages[0].message_id, InlineId::new(2));
assert!(history.has_more);
}
#[tokio::test]
async fn in_memory_store_latest_history_keeps_newest_window() {
let store = InMemoryStore::new();
for message_id in 1..=4 {
store
.record_message(test_message(message_id))
.await
.unwrap();
}
let history = store
.history(HistoryRequest {
chat_id: InlineId::new(7),
limit: Some(2),
before_message_id: None,
after_message_id: None,
})
.await
.unwrap();
assert!(history.has_more);
assert_eq!(
history
.messages
.iter()
.map(|message| message.message_id)
.collect::<Vec<_>>(),
vec![InlineId::new(3), InlineId::new(4)]
);
}
#[tokio::test]
async fn sqlite_store_persists_session_dialogs_and_history() {
let path = sqlite_temp_path("persist");
let store = SqliteStore::open(&path).unwrap();
store.save_session(session()).await.unwrap();
store
.upsert_dialog(DialogRecord {
chat_id: InlineId::new(7),
peer_user_id: Some(InlineId::new(2)),
title: Some("general".to_owned()),
emoji: Some("🚀".to_owned()),
last_message_id: None,
synced_through_message_id: None,
unread_count: Some(3),
space_id: Some(InlineId::new(5)),
is_public: Some(true),
archived: Some(false),
pinned: Some(true),
open: Some(true),
chat_list_hidden: Some(false),
order: Some("a0".to_owned()),
pinned_order: Some("p0".to_owned()),
notification_mode: Some(DialogNotificationMode::Mentions),
follow_mode: Some(DialogFollowMode::Following),
pinned_message_ids: vec![InlineId::new(99), InlineId::new(98)],
})
.unwrap();
store
.record_users(vec![UserRecord {
user_id: InlineId::new(2),
display_name: Some("Grace Hopper".to_owned()),
username: Some("grace".to_owned()),
first_name: Some("Grace".to_owned()),
last_name: Some("Hopper".to_owned()),
avatar_url: Some("https://cdn.inline.test/grace.jpg".to_owned()),
is_bot: Some(false),
}])
.await
.unwrap();
store
.record_message(MessageRecord {
chat_id: InlineId::new(7),
message_id: InlineId::new(10),
sender_id: InlineId::new(2),
timestamp: 100,
is_outgoing: false,
content: MessageContent::Text {
text: "persisted".to_owned(),
},
reply_to_message_id: None,
transaction: None,
})
.await
.unwrap();
drop(store);
let reopened = SqliteStore::open(&path).unwrap();
let session = reopened.load_session().await.unwrap().unwrap();
assert_eq!(session.account_namespace.as_deref(), Some("team"));
assert!(!format!("{reopened:?}").contains(path.to_string_lossy().as_ref()));
let dialogs = reopened.dialogs(DialogsRequest::default()).await.unwrap();
assert_eq!(dialogs.dialogs.len(), 1);
assert_eq!(dialogs.dialogs[0].chat_id, InlineId::new(7));
assert_eq!(dialogs.dialogs[0].peer_user_id, Some(InlineId::new(2)));
assert_eq!(dialogs.dialogs[0].title.as_deref(), Some("general"));
assert_eq!(dialogs.dialogs[0].emoji.as_deref(), Some("🚀"));
assert_eq!(dialogs.dialogs[0].last_message_id, Some(InlineId::new(10)));
assert_eq!(dialogs.dialogs[0].space_id, Some(InlineId::new(5)));
assert_eq!(dialogs.dialogs[0].is_public, Some(true));
assert_eq!(dialogs.dialogs[0].archived, Some(false));
assert_eq!(dialogs.dialogs[0].pinned, Some(true));
assert_eq!(dialogs.dialogs[0].open, Some(true));
assert_eq!(dialogs.dialogs[0].chat_list_hidden, Some(false));
assert_eq!(dialogs.dialogs[0].order.as_deref(), Some("a0"));
assert_eq!(dialogs.dialogs[0].pinned_order.as_deref(), Some("p0"));
assert_eq!(
dialogs.dialogs[0].notification_mode,
Some(DialogNotificationMode::Mentions)
);
assert_eq!(
dialogs.dialogs[0].follow_mode,
Some(DialogFollowMode::Following)
);
assert_eq!(
dialogs.dialogs[0].pinned_message_ids,
vec![InlineId::new(99), InlineId::new(98)]
);
assert_eq!(
dialogs.dialogs[0].synced_through_message_id,
Some(InlineId::new(10))
);
assert_eq!(dialogs.users.len(), 1);
assert_eq!(
dialogs.users[0].display_name.as_deref(),
Some("Grace Hopper")
);
let history = reopened
.history(HistoryRequest {
chat_id: InlineId::new(7),
limit: Some(10),
before_message_id: None,
after_message_id: None,
})
.await
.unwrap();
assert_eq!(history.messages.len(), 1);
assert_eq!(history.messages[0].message_id, InlineId::new(10));
assert!(matches!(
history.messages[0].content,
MessageContent::Text { ref text } if text == "persisted"
));
assert_eq!(history.users.len(), 1);
assert_eq!(
history.users[0].avatar_url.as_deref(),
Some("https://cdn.inline.test/grace.jpg")
);
let _ = std::fs::remove_file(path);
}
#[cfg(unix)]
#[tokio::test]
async fn sqlite_store_file_permissions_are_private() {
use std::os::unix::fs::PermissionsExt;
let path = sqlite_temp_path("permissions");
let store = SqliteStore::open(&path).unwrap();
store.save_session(session()).await.unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
drop(store);
let _ = std::fs::remove_file(path);
}
async fn assert_clear_history_store(store: &dyn ClientStore) {
let mut dialog = DialogRecord::new(InlineId::new(7));
dialog.space_id = Some(InlineId::new(5));
store.record_dialog(dialog).await.unwrap();
for (message_id, timestamp) in [(10, 100), (11, 200)] {
store
.record_message(MessageRecord {
chat_id: InlineId::new(7),
message_id: InlineId::new(message_id),
sender_id: InlineId::new(2),
timestamp,
is_outgoing: false,
content: MessageContent::Text {
text: format!("message {message_id}"),
},
reply_to_message_id: None,
transaction: None,
})
.await
.unwrap();
}
store
.record_reaction(StoredReaction {
chat_id: InlineId::new(7),
message_id: InlineId::new(10),
user_id: InlineId::new(2),
reaction: "ok".to_owned(),
})
.await
.unwrap();
assert_eq!(
store.chat_ids_in_space(InlineId::new(5)).await.unwrap(),
vec![InlineId::new(7)]
);
assert_eq!(
store
.clear_chat_messages(InlineId::new(7), Some(150))
.await
.unwrap(),
vec![InlineId::new(10)]
);
assert!(
store
.message_deleted(InlineId::new(7), InlineId::new(10))
.await
.unwrap()
);
assert!(
store
.reactions(InlineId::new(7), InlineId::new(10))
.await
.unwrap()
.is_empty()
);
let history = store
.history(HistoryRequest {
chat_id: InlineId::new(7),
limit: Some(10),
before_message_id: None,
after_message_id: None,
})
.await
.unwrap();
assert_eq!(
history
.messages
.iter()
.map(|message| message.message_id)
.collect::<Vec<_>>(),
vec![InlineId::new(11)]
);
assert_eq!(
store
.dialog(InlineId::new(7))
.await
.unwrap()
.unwrap()
.last_message_id,
Some(InlineId::new(11))
);
}
#[tokio::test]
async fn stores_clear_history_with_tombstones_and_space_lookup() {
let memory = InMemoryStore::new();
assert_clear_history_store(&memory).await;
let sqlite = SqliteStore::open_in_memory().unwrap();
assert_clear_history_store(&sqlite).await;
}
async fn assert_space_and_settings_store(store: &dyn ClientStore) {
let space = SpaceRecord {
space_id: InlineId::new(5),
name: "Engineering".to_owned(),
creator: true,
date: 100,
is_public: Some(false),
grid_enabled: Some(true),
};
let member = SpaceMemberRecord {
space_id: InlineId::new(5),
user_id: InlineId::new(2),
role: Some(crate::SpaceMemberRole::Admin),
date: 101,
can_access_public_chats: true,
};
let settings = UserSettingsRecord {
notification_mode: Some(crate::NotificationMode::Mentions),
silent: Some(true),
zen_mode_requires_mention: Some(true),
zen_mode_uses_default_rules: Some(false),
zen_mode_custom_rules: Some("important".to_owned()),
disable_dm_notifications: Some(false),
};
store.record_space(space.clone()).await.unwrap();
store.record_space_member(member.clone()).await.unwrap();
store.record_user_settings(settings.clone()).await.unwrap();
assert_eq!(store.space(InlineId::new(5)).await.unwrap(), Some(space));
assert_eq!(
store.space_members(InlineId::new(5)).await.unwrap(),
vec![member]
);
assert_eq!(store.user_settings().await.unwrap(), Some(settings));
store
.remove_space_member(InlineId::new(5), InlineId::new(2))
.await
.unwrap();
assert!(
store
.space_members(InlineId::new(5))
.await
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn stores_persist_spaces_members_and_user_settings() {
let memory = InMemoryStore::new();
assert_space_and_settings_store(&memory).await;
let sqlite = SqliteStore::open_in_memory().unwrap();
assert_space_and_settings_store(&sqlite).await;
}
#[tokio::test]
async fn sqlite_store_persists_sync_cursors_across_reopen() {
let path = sqlite_temp_path("sync-cursors");
let store = SqliteStore::open(&path).unwrap();
let user = SyncBucketKey::User;
let direct = SyncBucketKey::Chat {
peer: SyncBucketPeer::User {
user_id: InlineId::new(2),
},
};
let chat = SyncBucketKey::Chat {
peer: SyncBucketPeer::Chat {
chat_id: InlineId::new(7),
},
};
let space = SyncBucketKey::Space {
space_id: InlineId::new(9),
};
store
.save_sync_state(SyncState { last_sync_date: 80 })
.await
.unwrap();
store
.save_sync_bucket_states(vec![
(user, SyncBucketState { seq: 10, date: 70 }),
(direct, SyncBucketState { seq: 11, date: 71 }),
(chat, SyncBucketState { seq: 12, date: 72 }),
(space, SyncBucketState { seq: 13, date: 73 }),
])
.await
.unwrap();
store
.save_sync_bucket_state(direct, SyncBucketState { seq: 14, date: 74 })
.await
.unwrap();
drop(store);
let reopened = SqliteStore::open(&path).unwrap();
assert_eq!(
reopened.sync_state().await.unwrap(),
SyncState { last_sync_date: 80 }
);
assert_eq!(
reopened.sync_bucket_state(user).await.unwrap(),
SyncBucketState { seq: 10, date: 70 }
);
assert_eq!(
reopened.sync_bucket_state(direct).await.unwrap(),
SyncBucketState { seq: 14, date: 74 }
);
assert_eq!(
reopened.sync_bucket_state(chat).await.unwrap(),
SyncBucketState { seq: 12, date: 72 }
);
assert_eq!(
reopened.sync_bucket_state(space).await.unwrap(),
SyncBucketState { seq: 13, date: 73 }
);
reopened.remove_sync_bucket_state(chat).await.unwrap();
assert_eq!(
reopened.sync_bucket_state(chat).await.unwrap(),
SyncBucketState::default()
);
reopened.clear_sync_state().await.unwrap();
assert_eq!(reopened.sync_state().await.unwrap(), SyncState::default());
assert_eq!(
reopened.sync_bucket_state(space).await.unwrap(),
SyncBucketState::default()
);
drop(reopened);
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn sqlite_store_upgrades_initial_client_schema_in_place() {
let path = sqlite_temp_path("initial-client-upgrade");
let connection = Connection::open(&path).unwrap();
connection
.execute_batch(
"
CREATE TABLE sessions (
id INTEGER PRIMARY KEY CHECK (id = 1),
auth_json TEXT NOT NULL,
account_namespace TEXT,
updated_at INTEGER NOT NULL
);
CREATE TABLE dialogs (
chat_id INTEGER PRIMARY KEY,
peer_user_id INTEGER,
title TEXT,
last_message_id INTEGER,
unread_count INTEGER,
updated_at INTEGER NOT NULL
);
CREATE TABLE users (
user_id INTEGER PRIMARY KEY,
display_name TEXT,
username TEXT,
first_name TEXT,
last_name TEXT,
avatar_url TEXT,
is_bot INTEGER,
updated_at INTEGER NOT NULL
);
CREATE TABLE messages (
chat_id INTEGER NOT NULL,
message_id INTEGER NOT NULL,
sender_id INTEGER NOT NULL,
timestamp INTEGER NOT NULL,
is_outgoing INTEGER NOT NULL,
content_json TEXT NOT NULL,
reply_to_message_id INTEGER,
transaction_json TEXT,
PRIMARY KEY (chat_id, message_id)
);
CREATE TABLE transactions (
transaction_id TEXT PRIMARY KEY,
identity_json TEXT NOT NULL,
state_json TEXT NOT NULL,
chat_id INTEGER,
message_id INTEGER,
random_id INTEGER NOT NULL,
external_source TEXT,
external_id TEXT,
failure_json TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
",
)
.unwrap();
let initial_session = session();
connection
.execute(
"INSERT INTO sessions (id, auth_json, account_namespace, updated_at)
VALUES (1, ?1, ?2, 1)",
params![
serde_json::to_string(&initial_session.auth).unwrap(),
initial_session.account_namespace
],
)
.unwrap();
connection
.execute(
"INSERT INTO dialogs (
chat_id, peer_user_id, title, last_message_id, unread_count, updated_at
) VALUES (7, 2, 'General', 10, 1, 1)",
[],
)
.unwrap();
connection
.execute(
"INSERT INTO messages (
chat_id, message_id, sender_id, timestamp, is_outgoing,
content_json, reply_to_message_id, transaction_json
) VALUES (7, 10, 2, 10, 0, ?1, NULL, NULL)",
params![
serde_json::to_string(&MessageContent::Text {
text: "from the initial client".to_owned(),
})
.unwrap()
],
)
.unwrap();
drop(connection);
let upgraded = SqliteStore::open(&path).unwrap();
assert_eq!(
upgraded
.load_session()
.await
.unwrap()
.unwrap()
.account_namespace
.as_deref(),
Some("team")
);
let dialogs = upgraded.dialogs(DialogsRequest::default()).await.unwrap();
assert_eq!(dialogs.dialogs.len(), 1);
assert_eq!(dialogs.dialogs[0].chat_id, InlineId::new(7));
assert_eq!(dialogs.dialogs[0].peer_user_id, Some(InlineId::new(2)));
assert_eq!(dialogs.dialogs[0].title.as_deref(), Some("General"));
let history = upgraded
.history(HistoryRequest {
chat_id: InlineId::new(7),
limit: Some(10),
before_message_id: None,
after_message_id: None,
})
.await
.unwrap();
assert_eq!(history.messages.len(), 1);
assert!(matches!(
&history.messages[0].content,
MessageContent::Text { text } if text == "from the initial client"
));
assert_eq!(upgraded.sync_state().await.unwrap(), SyncState::default());
upgraded
.save_sync_bucket_state(SyncBucketKey::User, SyncBucketState { seq: 3, date: 30 })
.await
.unwrap();
assert_eq!(
upgraded
.sync_bucket_state(SyncBucketKey::User)
.await
.unwrap(),
SyncBucketState { seq: 3, date: 30 }
);
drop(upgraded);
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn sqlite_store_persists_message_tombstones_reactions_and_read_state() {
let path = sqlite_temp_path("message-state");
let store = SqliteStore::open(&path).unwrap();
let message = test_message(10);
let reaction = StoredReaction {
chat_id: message.chat_id,
message_id: message.message_id,
user_id: InlineId::new(2),
reaction: "🔥".to_owned(),
};
let read_state = StoredReadState {
chat_id: message.chat_id,
read_max_id: Some(message.message_id),
unread_count: Some(1),
marked_unread: true,
};
store.record_message(message.clone()).await.unwrap();
store.record_reaction(reaction).await.unwrap();
store.record_read_state(read_state).await.unwrap();
store
.record_message_deleted(message.chat_id, message.message_id)
.await
.unwrap();
drop(store);
let reopened = SqliteStore::open(&path).unwrap();
assert!(
reopened
.message_deleted(message.chat_id, message.message_id)
.await
.unwrap()
);
assert!(
reopened
.reactions(message.chat_id, message.message_id)
.await
.unwrap()
.is_empty()
);
assert_eq!(
reopened.read_state(message.chat_id).await.unwrap(),
Some(read_state)
);
reopened.record_message(message.clone()).await.unwrap();
assert!(
!reopened
.message_deleted(message.chat_id, message.message_id)
.await
.unwrap()
);
drop(reopened);
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn sqlite_store_preserves_complete_reconciliation_markers_and_chat_tombstones() {
let path = sqlite_temp_path("reconciliation-state");
let store = SqliteStore::open(&path).unwrap();
let message = test_message(10);
store
.record_dialog(DialogRecord::new(message.chat_id))
.await
.unwrap();
store.record_message(message.clone()).await.unwrap();
store
.record_chat_participants(message.chat_id, Vec::new())
.await
.unwrap();
store
.replace_message_reactions(
message.chat_id,
message.message_id,
vec![StoredReaction {
chat_id: message.chat_id,
message_id: message.message_id,
user_id: InlineId::new(2),
reaction: "👍".to_owned(),
}],
)
.await
.unwrap();
assert!(
store
.chat_participants_complete(message.chat_id)
.await
.unwrap()
);
assert_eq!(
store
.reaction_snapshot_message_ids(message.chat_id)
.await
.unwrap(),
vec![message.message_id]
);
store.remove_dialog(message.chat_id).await.unwrap();
drop(store);
let reopened = SqliteStore::open(&path).unwrap();
assert_eq!(
reopened.deleted_chat_ids().await.unwrap(),
vec![message.chat_id]
);
assert_eq!(
reopened.deleted_message_ids(message.chat_id).await.unwrap(),
vec![message.message_id]
);
assert!(
!reopened
.chat_participants_complete(message.chat_id)
.await
.unwrap()
);
drop(reopened);
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn sqlite_store_commits_sync_cursor_and_journal_removal_atomically() {
let path = sqlite_temp_path("sync-journal");
let key = SyncBucketKey::Chat {
peer: SyncBucketPeer::Chat {
chat_id: InlineId::new(7),
},
};
let state = SyncBucketState { seq: 9, date: 90 };
let store = SqliteStore::open(&path).unwrap();
store
.save_pending_sync_batch(PendingSyncBatch {
key,
committed_state: state,
payload: vec![1, 2, 3],
})
.await
.unwrap();
drop(store);
let reopened = SqliteStore::open(&path).unwrap();
assert_eq!(
reopened.pending_sync_batches().await.unwrap(),
vec![PendingSyncBatch {
key,
committed_state: state,
payload: vec![1, 2, 3],
}]
);
let lossless = ClientEvent::MessageDeleted {
chat_id: InlineId::new(7),
message_id: InlineId::new(11),
};
let best_effort = ClientEvent::Typing {
chat_id: InlineId::new(7),
user_id: InlineId::new(2),
is_typing: true,
};
let deliveries = reopened
.commit_pending_sync_batch_with_events(
key,
state,
vec![lossless.clone(), best_effort.clone()],
)
.await
.unwrap();
assert_eq!(deliveries.len(), 2);
assert!(deliveries[0].delivery_id.is_some());
assert_eq!(deliveries[0].event, lossless);
assert_eq!(deliveries[1], ClientEventDelivery::ephemeral(best_effort));
assert_eq!(reopened.sync_bucket_state(key).await.unwrap(), state);
assert!(reopened.pending_sync_batches().await.unwrap().is_empty());
drop(reopened);
let reopened = SqliteStore::open(&path).unwrap();
let pending = reopened.pending_client_events().await.unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].event, lossless);
reopened
.acknowledge_client_event(pending[0].delivery_id.unwrap())
.await
.unwrap();
assert!(reopened.pending_client_events().await.unwrap().is_empty());
drop(reopened);
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn sqlite_store_persists_transactions() {
let path = sqlite_temp_path("transactions");
let store = SqliteStore::open(&path).unwrap();
let transaction = stored_transaction("txn-sqlite", TransactionState::Sent)
.with_chat_id(InlineId::new(7))
.with_message_id(InlineId::new(11));
store.record_transaction(transaction.clone()).await.unwrap();
drop(store);
let reopened = SqliteStore::open(&path).unwrap();
let loaded = reopened
.transaction(transaction.identity.transaction_id.clone())
.await
.unwrap()
.unwrap();
assert_eq!(
loaded.identity.transaction_id,
transaction.identity.transaction_id
);
assert_eq!(
loaded.identity.external_id,
transaction.identity.external_id
);
assert_eq!(loaded.state, TransactionState::Sent);
assert_eq!(loaded.chat_id, Some(InlineId::new(7)));
assert_eq!(loaded.message_id, Some(InlineId::new(11)));
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn sqlite_store_replaces_messages_by_chat_and_message_id() {
let store = SqliteStore::open_in_memory().unwrap();
store
.record_message(MessageRecord {
chat_id: InlineId::new(7),
message_id: InlineId::new(10),
sender_id: InlineId::new(2),
timestamp: 100,
is_outgoing: false,
content: MessageContent::Text {
text: "first".to_owned(),
},
reply_to_message_id: None,
transaction: None,
})
.await
.unwrap();
store
.record_message(MessageRecord {
chat_id: InlineId::new(7),
message_id: InlineId::new(10),
sender_id: InlineId::new(2),
timestamp: 101,
is_outgoing: false,
content: MessageContent::Text {
text: "updated".to_owned(),
},
reply_to_message_id: None,
transaction: None,
})
.await
.unwrap();
let history = store
.history(HistoryRequest {
chat_id: InlineId::new(7),
limit: Some(10),
before_message_id: None,
after_message_id: None,
})
.await
.unwrap();
assert_eq!(history.messages.len(), 1);
assert!(matches!(
history.messages[0].content,
MessageContent::Text { ref text } if text == "updated"
));
}
#[tokio::test]
async fn sqlite_store_history_windows_match_client_cursors() {
let store = SqliteStore::open_in_memory().unwrap();
for message_id in 1..=4 {
store
.record_message(test_message(message_id))
.await
.unwrap();
}
let latest = store
.history(HistoryRequest {
chat_id: InlineId::new(7),
limit: Some(2),
before_message_id: None,
after_message_id: None,
})
.await
.unwrap();
assert!(latest.has_more);
assert_eq!(
latest
.messages
.iter()
.map(|message| message.message_id)
.collect::<Vec<_>>(),
vec![InlineId::new(3), InlineId::new(4)]
);
let newer = store
.history(HistoryRequest {
chat_id: InlineId::new(7),
limit: Some(2),
before_message_id: None,
after_message_id: Some(InlineId::new(1)),
})
.await
.unwrap();
assert!(newer.has_more);
assert_eq!(
newer
.messages
.iter()
.map(|message| message.message_id)
.collect::<Vec<_>>(),
vec![InlineId::new(2), InlineId::new(3)]
);
let older = store
.history(HistoryRequest {
chat_id: InlineId::new(7),
limit: Some(2),
before_message_id: Some(InlineId::new(4)),
after_message_id: None,
})
.await
.unwrap();
assert!(older.has_more);
assert_eq!(
older
.messages
.iter()
.map(|message| message.message_id)
.collect::<Vec<_>>(),
vec![InlineId::new(2), InlineId::new(3)]
);
}
fn sqlite_temp_path(label: &str) -> std::path::PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!(
"inline-client-{label}-{}-{unique}.db",
std::process::id()
))
}
fn stored_transaction(id: &str, state: TransactionState) -> StoredTransaction {
StoredTransaction::new(
TransactionIdentity::new(
TransactionId::try_new(id).unwrap(),
Some(ExternalId::try_new("host-event", "event-1").unwrap()),
RandomId::new(42),
),
state,
)
}
}