Skip to main content

inline_client/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3#![forbid(unsafe_code)]
4
5use std::{fmt, str::FromStr};
6
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10/// Backend/store boundary for client runtime operations.
11pub mod backend;
12
13/// Async client facade and runner skeleton.
14pub mod runtime;
15
16/// Realtime connector boundary.
17pub mod realtime;
18
19/// SDK-backed backend implementation.
20pub mod sdk_backend;
21
22/// Durable store boundary.
23pub mod store;
24
25/// Native Inline client request and record types.
26pub mod types;
27
28pub use backend::{
29    BackendError, BackendResult, ClientBackend, InMemoryBackend, OperationOutcome, SendTextOutcome,
30};
31pub use inline_sdk::ClientIdentity;
32pub use realtime::{
33    FakeRealtimeAttempt, FakeRealtimeConnector, RealtimeConnectRequest, RealtimeConnectionInfo,
34    RealtimeConnector, SdkRealtimeConnector,
35};
36pub use runtime::{
37    ClientCommandError, ClientRequestError, ClientRunner, DEFAULT_COMMAND_QUEUE_CAPACITY,
38    DEFAULT_EVENT_QUEUE_CAPACITY, InlineClient, InlineClientBuilder, InlineClientRuntime,
39};
40pub use sdk_backend::{SdkBackend, SdkBackendBuildError, SdkBackendBuilder};
41pub use store::{
42    ClientStore, InMemoryStore, SqliteStore, StoreError, StoreResult, StoredSession,
43    StoredTransaction,
44};
45pub use types::{
46    AuthContactKind, AuthCredential, AuthStartRequest, AuthStartResult, AuthToken,
47    AuthVerifyRequest, AuthVerifyResult, ChatCreateParticipant, ChatParticipantRecord,
48    ChatParticipantsPage, ChatParticipantsRequest, ClientStatusSnapshot, ConnectRequest,
49    CreateDmRequest, CreateReplyThreadRequest, CreateThreadRequest, CreatedChat,
50    DeleteMessageRequest, DialogRecord, DialogsPage, DialogsRequest, EditMessageRequest,
51    HistoryPage, HistoryRequest, MediaKind, MessageContent, MessageMutation, MessageRecord,
52    PeerRef, ReactRequest, ReadRequest, SendTextRequest, TypingRequest, UploadHandle,
53    UploadRequest, UserRecord,
54};
55
56/// Published package version.
57pub const VERSION: &str = env!("CARGO_PKG_VERSION");
58
59/// Errors returned by public `inline-client` helper types.
60#[derive(Clone, Debug, Error, PartialEq, Eq)]
61#[non_exhaustive]
62pub enum ClientError {
63    /// A required string field was empty or whitespace-only.
64    #[error("{field} must not be empty")]
65    EmptyField {
66        /// Field name.
67        field: &'static str,
68    },
69
70    /// A string field exceeded its public API limit.
71    #[error("{field} is too long ({len} > {max})")]
72    FieldTooLong {
73        /// Field name.
74        field: &'static str,
75        /// Actual length in bytes.
76        len: usize,
77        /// Maximum allowed length in bytes.
78        max: usize,
79    },
80
81    /// A string field contained ASCII control characters.
82    #[error("{field} must not contain ASCII control characters")]
83    ControlCharacter {
84        /// Field name.
85        field: &'static str,
86    },
87}
88
89fn validate_token<'a>(
90    field: &'static str,
91    value: &'a str,
92    max_len: usize,
93) -> Result<&'a str, ClientError> {
94    let trimmed = value.trim();
95    if trimmed.is_empty() {
96        return Err(ClientError::EmptyField { field });
97    }
98    if trimmed.len() > max_len {
99        return Err(ClientError::FieldTooLong {
100            field,
101            len: trimmed.len(),
102            max: max_len,
103        });
104    }
105    if trimmed.chars().any(|ch| ch.is_ascii_control()) {
106        return Err(ClientError::ControlCharacter { field });
107    }
108    Ok(trimmed)
109}
110
111/// Opaque Inline ID used by high-level client events.
112///
113/// Inline server IDs are represented as signed 64-bit integers across existing
114/// clients. This wrapper avoids mixing user, chat, and message IDs with other
115/// integers in public event structures while keeping serialization simple.
116#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
117#[serde(transparent)]
118pub struct InlineId(i64);
119
120impl InlineId {
121    /// Creates an Inline ID from its raw wire value.
122    pub const fn new(value: i64) -> Self {
123        Self(value)
124    }
125
126    /// Returns the raw wire value.
127    pub const fn get(self) -> i64 {
128        self.0
129    }
130}
131
132impl From<i64> for InlineId {
133    fn from(value: i64) -> Self {
134        Self::new(value)
135    }
136}
137
138impl fmt::Display for InlineId {
139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140        self.0.fmt(f)
141    }
142}
143
144/// Deterministic random ID attached to an Inline mutation.
145///
146/// Hosts should derive this from their own durable event or operation ID plus
147/// message content so retries after restart remain idempotent.
148#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
149#[serde(transparent)]
150pub struct RandomId(i64);
151
152impl RandomId {
153    /// Creates a random ID wrapper from a caller-provided deterministic value.
154    pub const fn new(value: i64) -> Self {
155        Self(value)
156    }
157
158    /// Returns the raw random ID.
159    pub const fn get(self) -> i64 {
160        self.0
161    }
162}
163
164impl From<i64> for RandomId {
165    fn from(value: i64) -> Self {
166        Self::new(value)
167    }
168}
169
170impl fmt::Display for RandomId {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        self.0.fmt(f)
173    }
174}
175
176/// Opaque host-provided ID used for idempotency and echo reconciliation.
177///
178/// Hosts can use their own source namespaces, such as `host-event` or
179/// `agent-task`. The core client treats this as metadata and does not depend on
180/// host-specific semantics.
181#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
182pub struct ExternalId {
183    source: String,
184    id: String,
185}
186
187impl ExternalId {
188    /// Maximum source namespace length in bytes.
189    pub const MAX_SOURCE_LEN: usize = 64;
190
191    /// Maximum external ID length in bytes.
192    pub const MAX_ID_LEN: usize = 512;
193
194    /// Creates a validated external ID.
195    pub fn try_new(source: impl AsRef<str>, id: impl AsRef<str>) -> Result<Self, ClientError> {
196        let source = validate_token("source", source.as_ref(), Self::MAX_SOURCE_LEN)?;
197        let id = validate_token("id", id.as_ref(), Self::MAX_ID_LEN)?;
198        Ok(Self {
199            source: source.to_owned(),
200            id: id.to_owned(),
201        })
202    }
203
204    /// Returns the source namespace.
205    pub fn source(&self) -> &str {
206        &self.source
207    }
208
209    /// Returns the source-local external ID.
210    pub fn id(&self) -> &str {
211        &self.id
212    }
213}
214
215/// Stable client transaction ID.
216#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
217#[serde(transparent)]
218pub struct TransactionId(String);
219
220impl TransactionId {
221    /// Maximum transaction ID length in bytes.
222    pub const MAX_LEN: usize = 128;
223
224    /// Creates a validated transaction ID.
225    pub fn try_new(value: impl AsRef<str>) -> Result<Self, ClientError> {
226        Ok(Self(
227            validate_token("transaction_id", value.as_ref(), Self::MAX_LEN)?.to_owned(),
228        ))
229    }
230
231    /// Returns the transaction ID string.
232    pub fn as_str(&self) -> &str {
233        &self.0
234    }
235}
236
237impl fmt::Display for TransactionId {
238    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239        self.0.fmt(f)
240    }
241}
242
243impl FromStr for TransactionId {
244    type Err = ClientError;
245
246    fn from_str(value: &str) -> Result<Self, Self::Err> {
247        Self::try_new(value)
248    }
249}
250
251/// Identity data used to reconcile a mutation across host, client, and server.
252#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
253pub struct TransactionIdentity {
254    /// Stable client transaction ID.
255    pub transaction_id: TransactionId,
256    /// Optional host-provided idempotency key.
257    pub external_id: Option<ExternalId>,
258    /// Inline mutation random ID.
259    pub random_id: RandomId,
260    /// Temporary local message ID, when the mutation created one.
261    pub temporary_message_id: Option<InlineId>,
262    /// Final server message ID, when known.
263    pub final_message_id: Option<InlineId>,
264}
265
266impl TransactionIdentity {
267    /// Creates a transaction identity before a temporary or final message ID is known.
268    pub fn new(
269        transaction_id: TransactionId,
270        external_id: Option<ExternalId>,
271        random_id: RandomId,
272    ) -> Self {
273        Self {
274            transaction_id,
275            external_id,
276            random_id,
277            temporary_message_id: None,
278            final_message_id: None,
279        }
280    }
281
282    /// Records the temporary message ID assigned by optimistic local state.
283    pub fn with_temporary_message_id(mut self, message_id: impl Into<InlineId>) -> Self {
284        self.temporary_message_id = Some(message_id.into());
285        self
286    }
287
288    /// Records the final server message ID.
289    pub fn with_final_message_id(mut self, message_id: impl Into<InlineId>) -> Self {
290        self.final_message_id = Some(message_id.into());
291        self
292    }
293}
294
295/// High-level connection/auth state for apps, bridges, and agents.
296#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
297#[non_exhaustive]
298pub enum ClientStatus {
299    /// Client is not connected.
300    Disconnected,
301    /// Client is establishing a session.
302    Connecting,
303    /// Client is connected and able to send/receive realtime updates.
304    Connected,
305    /// Client lost realtime connectivity and is retrying.
306    Reconnecting,
307    /// Client needs interactive auth before it can connect.
308    AuthRequired,
309    /// Client credentials expired and a relogin or refresh is required.
310    AuthExpired,
311    /// The account was logged out or invalidated elsewhere.
312    LoggedOut,
313    /// The client is shutting down.
314    ShuttingDown,
315}
316
317/// Stable error categories exposed to client hosts.
318#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
319#[non_exhaustive]
320pub enum ClientErrorCategory {
321    /// Caller supplied invalid input.
322    InvalidInput,
323    /// Authentication is missing.
324    AuthRequired,
325    /// Authentication expired and must be refreshed.
326    AuthExpired,
327    /// The user must relogin interactively.
328    ReloginRequired,
329    /// Network connectivity failed.
330    Network,
331    /// Operation timed out.
332    Timeout,
333    /// Remote server rate limited the operation.
334    RateLimited,
335    /// Local and remote protocol versions are incompatible.
336    ProtocolMismatch,
337    /// The target entity was not found.
338    NotFound,
339    /// The account does not have permission for the operation.
340    PermissionDenied,
341    /// The operation is unsupported by the current client or server.
342    Unsupported,
343    /// The operation conflicted with newer state.
344    Conflict,
345    /// An internal client error occurred.
346    Internal,
347}
348
349/// Redacted failure value suitable for status endpoints and bridge errors.
350#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
351pub struct ClientFailure {
352    /// Machine-readable category.
353    pub category: ClientErrorCategory,
354    /// Human-readable message with secrets and message bodies redacted.
355    pub message: String,
356}
357
358impl ClientFailure {
359    /// Creates a redacted failure.
360    pub fn new(category: ClientErrorCategory, message: impl Into<String>) -> Self {
361        Self {
362            category,
363            message: message.into(),
364        }
365    }
366}
367
368/// State of a durable client transaction.
369#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
370#[non_exhaustive]
371pub enum TransactionState {
372    /// Transaction is queued locally.
373    Queued,
374    /// Transaction was sent to the realtime or API transport.
375    Sent,
376    /// Transport acknowledged receipt, but final server result is not known.
377    Acked,
378    /// Server result completed and final state was applied.
379    Completed,
380    /// Transaction failed permanently or exhausted retries.
381    Failed,
382    /// Transaction was cancelled locally.
383    Cancelled,
384}
385
386/// Transaction event emitted after client state changes.
387#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
388pub struct TransactionEvent {
389    /// Mutation identity and reconciliation IDs.
390    pub identity: TransactionIdentity,
391    /// Current transaction state.
392    pub state: TransactionState,
393    /// Redacted failure, present for failed transactions.
394    pub failure: Option<ClientFailure>,
395}
396
397/// Reliability class for events delivered to hosts.
398#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
399pub enum EventReliability {
400    /// Event must be delivered or recovered from durable state.
401    Lossless,
402    /// Event may be dropped under backpressure.
403    BestEffort,
404}
405
406/// Committed client event for apps, bridges, agents, and hosts.
407#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
408#[non_exhaustive]
409pub enum ClientEvent {
410    /// Connection/auth status changed.
411    StatusChanged {
412        /// New client status.
413        status: ClientStatus,
414        /// Optional redacted failure details.
415        failure: Option<ClientFailure>,
416    },
417    /// A durable transaction changed state.
418    TransactionChanged(TransactionEvent),
419    /// A chat was inserted or updated in the local store.
420    ChatUpserted {
421        /// Inline chat ID.
422        chat_id: InlineId,
423    },
424    /// A user was inserted or updated in the local store.
425    UserUpserted {
426        /// Inline user ID.
427        user_id: InlineId,
428    },
429    /// A message was inserted or updated in the local store.
430    MessageUpserted {
431        /// Inline chat ID.
432        chat_id: InlineId,
433        /// Inline message ID.
434        message_id: InlineId,
435    },
436    /// A message was inserted or updated and the committed record is available.
437    MessageStored {
438        /// Committed message record.
439        message: MessageRecord,
440    },
441    /// A message was deleted or unsent.
442    MessageDeleted {
443        /// Inline chat ID.
444        chat_id: InlineId,
445        /// Inline message ID.
446        message_id: InlineId,
447    },
448    /// Reactions for a message changed.
449    ReactionChanged {
450        /// Inline chat ID.
451        chat_id: InlineId,
452        /// Inline message ID.
453        message_id: InlineId,
454        /// Inline user ID that changed the reaction.
455        user_id: InlineId,
456        /// Reaction emoji.
457        reaction: String,
458        /// Whether the reaction was removed.
459        removed: bool,
460    },
461    /// Read state changed for a chat.
462    ReadStateChanged {
463        /// Inline chat ID.
464        chat_id: InlineId,
465    },
466    /// Typing state changed.
467    Typing {
468        /// Inline chat ID.
469        chat_id: InlineId,
470        /// Inline user ID.
471        user_id: InlineId,
472        /// Whether the user is typing.
473        is_typing: bool,
474    },
475}
476
477impl ClientEvent {
478    /// Returns the event delivery reliability class.
479    pub const fn reliability(&self) -> EventReliability {
480        match self {
481            Self::Typing { .. } => EventReliability::BestEffort,
482            Self::StatusChanged { .. }
483            | Self::TransactionChanged(_)
484            | Self::ChatUpserted { .. }
485            | Self::UserUpserted { .. }
486            | Self::MessageUpserted { .. }
487            | Self::MessageStored { .. }
488            | Self::MessageDeleted { .. }
489            | Self::ReactionChanged { .. }
490            | Self::ReadStateChanged { .. } => EventReliability::Lossless,
491        }
492    }
493}
494
495/// Convenient imports for common client consumers.
496pub mod prelude {
497    pub use crate::{
498        AuthCredential, AuthToken, BackendError, BackendResult, ClientBackend, ClientCommandError,
499        ClientError, ClientErrorCategory, ClientEvent, ClientFailure, ClientIdentity,
500        ClientRequestError, ClientRunner, ClientStatus, ClientStore, ConnectRequest,
501        DEFAULT_COMMAND_QUEUE_CAPACITY, DEFAULT_EVENT_QUEUE_CAPACITY, DeleteMessageRequest,
502        DialogRecord, DialogsPage, DialogsRequest, EditMessageRequest, EventReliability,
503        ExternalId, FakeRealtimeAttempt, FakeRealtimeConnector, HistoryPage, HistoryRequest,
504        InMemoryBackend, InMemoryStore, InlineClient, InlineClientBuilder, InlineClientRuntime,
505        InlineId, MessageContent, MessageMutation, MessageRecord, PeerRef, RandomId, ReactRequest,
506        ReadRequest, RealtimeConnectRequest, RealtimeConnectionInfo, RealtimeConnector, SdkBackend,
507        SdkBackendBuildError, SdkBackendBuilder, SdkRealtimeConnector, SendTextOutcome,
508        SendTextRequest, SqliteStore, StoreError, StoreResult, StoredSession, StoredTransaction,
509        TransactionEvent, TransactionId, TransactionIdentity, TransactionState, TypingRequest,
510        UploadHandle, UploadRequest, VERSION,
511    };
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    #[test]
519    fn external_id_rejects_empty_values() {
520        assert_eq!(
521            ExternalId::try_new("host-event", " "),
522            Err(ClientError::EmptyField { field: "id" })
523        );
524    }
525
526    #[test]
527    fn transaction_identity_records_message_ids() {
528        let identity = TransactionIdentity::new(
529            TransactionId::try_new("txn-1").unwrap(),
530            Some(ExternalId::try_new("host-event", "event-1").unwrap()),
531            RandomId::new(42),
532        )
533        .with_temporary_message_id(-1)
534        .with_final_message_id(100);
535
536        assert_eq!(identity.random_id.get(), 42);
537        assert_eq!(identity.temporary_message_id.unwrap().get(), -1);
538        assert_eq!(identity.final_message_id.unwrap().get(), 100);
539    }
540
541    #[test]
542    fn typing_is_best_effort() {
543        let event = ClientEvent::Typing {
544            chat_id: InlineId::new(1),
545            user_id: InlineId::new(2),
546            is_typing: true,
547        };
548
549        assert_eq!(event.reliability(), EventReliability::BestEffort);
550    }
551}