Skip to main content

softchat/
runtime_facade.rs

1//! Generated-binding handles for the account, delivery, relay, and sync plans.
2
3#![allow(
4    unreachable_pub,
5    reason = "UniFFI exports public handles from this private adapter module"
6)]
7
8use std::collections::{BTreeMap, BTreeSet};
9use std::fmt;
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
13
14use bech32::{Bech32, Hrp};
15use sha2::{Digest, Sha256};
16use url::Url;
17
18use crate::account_diagnostics::{
19    AccountLogBatch, AccountLogBuffer, AccountLogCategory, AccountLogLevel, AccountLogSource,
20    AccountLogSubject, Diagnostic,
21};
22#[cfg(test)]
23use crate::account_diagnostics::{AccountLogMetadata, MAX_ACCOUNT_LOG_BATCH};
24use crate::account_product::{
25    command_hash, product_draft_match, profile_update_from_patch, validate_command_id,
26    validate_conversation_id,
27};
28use crate::account_transport::{AccountTransportRun, StoredAccountTransportOutcome, result_hash};
29use crate::event::{RumorEvent, SignedEvent, SignedNostrEvent};
30use crate::media::FfiHttpAuthorizationPlan;
31use crate::message::LocalIdentityHandle;
32use crate::storage::ProductAccountOperationResult;
33use crate::{
34    AccountDatabase, AccountDatabaseInfo, AccountDiagnostics, AccountDraft, AccountEngine,
35    AccountEventNode, AccountMediaItem, AccountMediaLease, AccountMediaOperation,
36    AccountMediaOperationPage, AccountMessage, AccountMessageView, AccountMutationResult,
37    AccountOperationResult, AccountProfile, AccountProjection, AccountSettings,
38    AccountSettingsMutation, AccountTransportBatch, AccountTransportResult, AppDataContext,
39    AssetReplacementTarget, AttachmentMetadata, ChatMessageDraft, ChatRelation, ClaimedDelivery,
40    Contact, ConversationCursor, ConversationPage, ConversationRecord, ConversationSummary,
41    ConversationView, DeliveryClaim, DeliveryIntentSnapshot, DeliveryReducer,
42    DeliveryStateMutation, DnsRelayRecord, IngestionReceipt, LinkPreviewRecord, LocalContactRecord,
43    MessageContentInput, MessageCursor, MessagePage, MessageReactionInput, NegentropyItem,
44    Nip59EnvelopeKind, NostrEventId, NostrEventKind, NostrPublicKey, NostrTag, OperationSnapshot,
45    PendingAssetReplacement, PreparedIncomingBatch, PreparedOutgoingOperation,
46    ProductOperationResult, PushPlatform, ReactionDraft, RelayCatalogEntry, RelayCatalogMutation,
47    RelayCatalogPlan, RelayCatalogReducer, RelayConnectionState, RelayDeliveryResult,
48    RelayEndpointPlan, RelayFailoverMutation, RelayFailureKind, RelayFilter, RelayRetryPlan,
49    SoftchatError, StickerRecord, StoredEventJson, StoredIngestion, StoredRelayCatalogPlan,
50    SubjectDraft, SyncAction, SyncActionKind, SyncEngine, SyncEngineSnapshot, conversation_id,
51    parse_relay_endpoint, plan_relay_retry, route_nip59_envelope,
52};
53
54/// Android-facing owner of one identity and one Rust-managed account database.
55#[cfg_attr(feature = "native-bindings", derive(uniffi::Object))]
56pub struct AccountRuntimeHandle {
57    account_id: String,
58    identity: Arc<LocalIdentityHandle>,
59    database: Mutex<AccountDatabase>,
60    database_path: PathBuf,
61    closed: AtomicBool,
62    sync_engines: Mutex<BTreeMap<String, SyncEngine>>,
63    transport_run: Mutex<Option<AccountTransportRun>>,
64    typing_authored: Mutex<BTreeMap<String, (i64, String)>>,
65    pub(crate) logs: Arc<AccountLogBuffer>,
66    logging_configured: AtomicBool,
67}
68
69/// Non-secret identity-derived account storage descriptor.
70#[derive(Clone, Debug)]
71#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
72pub struct AccountDescriptor {
73    /// Canonical local account public key.
74    pub public_key: String,
75    /// Deterministic database filename without a directory.
76    pub database_filename: String,
77}
78
79/// Verified account-routing hint from one signed NIP-59 outer wrapper.
80#[derive(Clone, Debug)]
81#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
82pub struct IncomingAccountRoute {
83    /// Verified outer wrapper event ID.
84    pub event_id: String,
85    /// Canonical recipient public key from the one `p` tag.
86    pub recipient_public_key: String,
87    /// Durable or ephemeral wrapper profile.
88    pub envelope_kind: Nip59EnvelopeKind,
89}
90
91/// Verify one outer wrapper and extract its account-routing hint.
92///
93/// The selected account must still ingest and authenticate every encrypted
94/// layer. No plaintext is exposed by this operation.
95///
96/// # Errors
97///
98/// Returns a stable event or envelope failure for malformed, forged,
99/// unsupported, or ambiguously routed input.
100#[cfg_attr(feature = "native-bindings", uniffi::export)]
101pub fn route_incoming_account_event(
102    event_json: String,
103) -> Result<IncomingAccountRoute, SoftchatError> {
104    let route = route_nip59_envelope(&event_json)?;
105    Ok(IncomingAccountRoute {
106        event_id: route.outer_event_id().to_hex(),
107        recipient_public_key: route.recipient().to_hex(),
108        envelope_kind: route.kind(),
109    })
110}
111
112/// Generate one cryptographically secure local account secret.
113///
114/// The returned 32-byte mutable buffer is the only exported secret copy.
115/// Platforms must protect it immediately and clear it after opening an
116/// account. Validation and public-key derivation occur again at account open.
117#[cfg_attr(feature = "native-bindings", uniffi::export)]
118#[must_use]
119pub fn generate_account_secret_key() -> Vec<u8> {
120    nostr::SecretKey::generate().to_secret_bytes().to_vec()
121}
122
123/// Parse one hexadecimal or `nsec` account authority into a mutable buffer.
124///
125/// # Errors
126///
127/// Returns [`SoftchatError::InvalidSecretKey`] for malformed input.
128#[cfg_attr(feature = "native-bindings", uniffi::export)]
129pub fn parse_account_secret_key(encoded: String) -> Result<Vec<u8>, SoftchatError> {
130    nostr::SecretKey::parse(&encoded)
131        .map(|secret| secret.to_secret_bytes().to_vec())
132        .map_err(|_| SoftchatError::InvalidSecretKey)
133}
134
135/// Encode one temporary 32-byte local authority as a canonical `nsec`.
136///
137/// The mutable binding buffer is cleared before return. Platforms must expose
138/// the returned string only inside an authenticated, explicitly user-requested
139/// export flow.
140///
141/// # Errors
142///
143/// Returns [`SoftchatError::InvalidSecretKey`] for a malformed secret.
144#[cfg_attr(feature = "native-bindings", uniffi::export)]
145pub fn encode_account_secret_key(mut secret_key_bytes: Vec<u8>) -> Result<String, SoftchatError> {
146    let result = (|| {
147        let secret = nostr::SecretKey::from_slice(&secret_key_bytes)
148            .map_err(|_| SoftchatError::InvalidSecretKey)?;
149        bech32::encode::<Bech32>(
150            Hrp::parse("nsec").map_err(|_| SoftchatError::InternalFailure)?,
151            &secret.to_secret_bytes(),
152        )
153        .map_err(|_| SoftchatError::InternalFailure)
154    })();
155    secret_key_bytes.fill(0);
156    result
157}
158
159/// Parse one stable product conversation identifier.
160///
161/// # Errors
162///
163/// Returns a stable account-operation error for malformed input.
164#[cfg_attr(feature = "native-bindings", uniffi::export)]
165pub fn parse_account_conversation_id(value: String) -> Result<String, SoftchatError> {
166    if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
167        return Err(SoftchatError::InvalidAccountOperation);
168    }
169    Ok(value.to_ascii_lowercase())
170}
171
172/// Parse one stable product message identifier.
173///
174/// # Errors
175///
176/// Returns a stable event-ID error for malformed input.
177#[cfg_attr(feature = "native-bindings", uniffi::export)]
178pub fn parse_account_message_id(value: String) -> Result<String, SoftchatError> {
179    Ok(NostrEventId::from_hex(&value)?.to_hex())
180}
181
182/// Validate a secret and derive its non-secret account storage descriptor.
183///
184/// The supplied mutable binding buffer is cleared inside Rust before return.
185///
186/// # Errors
187///
188/// Returns a stable invalid-secret failure.
189#[cfg_attr(feature = "native-bindings", uniffi::export)]
190pub fn describe_account_secret(
191    secret_key_bytes: Vec<u8>,
192) -> Result<AccountDescriptor, SoftchatError> {
193    let identity = LocalIdentityHandle::new(secret_key_bytes)?;
194    let public_key = identity.public_key()?;
195    identity.erase();
196    Ok(AccountDescriptor {
197        database_filename: account_database_filename(&public_key)?,
198        public_key,
199    })
200}
201
202impl fmt::Debug for AccountRuntimeHandle {
203    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
204        formatter
205            .debug_struct("AccountRuntimeHandle")
206            .field("account_id", &self.account_id)
207            .field("database", &"<account-private sqlite>")
208            .field("closed", &self.closed.load(Ordering::Acquire))
209            .field("active_syncs", &lock(&self.sync_engines).len())
210            .finish_non_exhaustive()
211    }
212}
213
214#[cfg_attr(feature = "native-bindings", uniffi::export)]
215impl AccountRuntimeHandle {
216    /// Open one Rust-owned SQLite account runtime.
217    ///
218    /// Android supplies a private per-account file path and retains lifecycle,
219    /// HTTP, files, background scheduling, and UI ownership. Relay transport
220    /// can be host-driven or delegated to the optional managed resource.
221    ///
222    /// # Errors
223    ///
224    /// Returns a stable identity, account, schema, path, or persistence error.
225    #[cfg_attr(feature = "native-bindings", uniffi::constructor)]
226    pub fn open(
227        secret_key_bytes: Vec<u8>,
228        database_directory: String,
229    ) -> Result<Self, SoftchatError> {
230        let identity = Arc::new(LocalIdentityHandle::new(secret_key_bytes)?);
231        let account_id = identity.public_key()?;
232        let database_path = derived_account_path(&database_directory, &account_id)?;
233        {
234            let mut registry = lock(open_account_paths());
235            if !registry.insert(database_path.clone()) {
236                identity.erase();
237                return Err(SoftchatError::AccountAlreadyOpen);
238            }
239        }
240        let mut database = match AccountDatabase::open(&database_path, account_id.clone()) {
241            Ok(database) => database,
242            Err(error) => {
243                lock(open_account_paths()).remove(&database_path);
244                identity.erase();
245                return Err(error);
246            }
247        };
248        let logs = Arc::new(AccountLogBuffer::new(AccountLogLevel::Off));
249        database.diagnostics.buffer = Some(logs.clone());
250        Ok(Self {
251            account_id,
252            identity,
253            database: Mutex::new(database),
254            database_path,
255            closed: AtomicBool::new(false),
256            sync_engines: Mutex::new(BTreeMap::new()),
257            transport_run: Mutex::new(None),
258            typing_authored: Mutex::new(BTreeMap::new()),
259            logs,
260            logging_configured: AtomicBool::new(false),
261        })
262    }
263
264    /// Return the non-secret account partition.
265    #[must_use]
266    pub fn account_id(&self) -> String {
267        self.account_id.clone()
268    }
269
270    /// Return the canonical public key that owns this account.
271    ///
272    /// # Errors
273    ///
274    /// Returns [`SoftchatError::AccountClosed`] after explicit close.
275    pub fn public_key(&self) -> Result<String, SoftchatError> {
276        self.ensure_open()?;
277        Ok(self.account_id.clone())
278    }
279
280    /// Return the derived account database filename without exposing a path.
281    #[must_use]
282    pub fn database_filename(&self) -> String {
283        self.database_path
284            .file_name()
285            .and_then(|value| value.to_str())
286            .unwrap_or_default()
287            .to_owned()
288    }
289
290    /// Return the validated non-secret account identity and storage filename.
291    ///
292    /// This compact snapshot lets authored facades initialize without
293    /// repeating native calls or querying SQLite metadata.
294    ///
295    /// # Errors
296    ///
297    /// Returns [`SoftchatError::AccountClosed`] after explicit close.
298    pub fn descriptor(&self) -> Result<AccountDescriptor, SoftchatError> {
299        self.ensure_open()?;
300        Ok(AccountDescriptor {
301            public_key: self.account_id.clone(),
302            database_filename: self.database_filename(),
303        })
304    }
305
306    /// Irreversibly close this account authority.
307    ///
308    /// The authored facade immediately releases the generated object after
309    /// this call, which closes SQLite and removes the same-path registry entry.
310    /// Repeated close calls are harmless.
311    pub fn close_account(&self) {
312        if !self.closed.swap(true, Ordering::AcqRel) {
313            lock(&self.sync_engines).clear();
314            lock(&self.transport_run).take();
315            lock(&self.typing_authored).clear();
316            self.identity.erase();
317            self.logs.flush();
318            self.logs.emit(Diagnostic::new(
319                AccountLogCategory::Lifecycle,
320                "account.close",
321                "Account closed",
322            ));
323        }
324    }
325
326    /// Change the bounded account diagnostic threshold.
327    ///
328    /// `Off` keeps the disabled hot path to one relaxed atomic load. Enabling
329    /// diagnostics records one lifecycle event; no secret, event identifier,
330    /// URL, frame, or caller content is retained.
331    ///
332    /// # Errors
333    ///
334    /// Returns [`SoftchatError::AccountClosed`] after explicit close.
335    pub fn set_log_level(&self, level: AccountLogLevel) -> Result<(), SoftchatError> {
336        self.ensure_open()?;
337        let previous = self.logs.level();
338        self.logs.set_level(level);
339        if level != AccountLogLevel::Off && previous != level {
340            if self.logging_configured.swap(true, Ordering::Relaxed) {
341                self.logs.emit(
342                    Diagnostic::new(
343                        AccountLogCategory::Lifecycle,
344                        "account.logging.configure",
345                        "Logging configured",
346                    )
347                    .debug(),
348                );
349            } else {
350                self.logs.emit(Diagnostic::new(
351                    AccountLogCategory::Lifecycle,
352                    "account.opened",
353                    "Account opened",
354                ));
355            }
356        }
357        Ok(())
358    }
359
360    /// Drain at most 64 structured account diagnostics in sequence order.
361    ///
362    /// The ring is process-memory-only and retains at most 256 events. The
363    /// returned drop count reports overwritten events since the previous drain.
364    ///
365    /// # Errors
366    ///
367    /// Returns a stable bound error. A final drain remains valid immediately
368    /// after `close_account` so the authored facade can deliver `account.close`
369    /// before releasing the generated handle.
370    pub fn drain_log_events(&self, limit: u32) -> Result<AccountLogBatch, SoftchatError> {
371        self.logs.drain(limit)
372    }
373
374    /// Create or return one conversation from remote member keys.
375    ///
376    /// The local account key is derived internally and included exactly once.
377    ///
378    /// # Errors
379    ///
380    /// Returns a stable account, key, bound, or persistence error.
381    pub fn get_or_create_conversation(
382        &self,
383        member_public_keys: Vec<String>,
384        now: i64,
385    ) -> Result<ConversationRecord, SoftchatError> {
386        let own_public_key = self.public_key()?;
387        self.database()?.get_or_create_product_conversation(
388            &own_public_key,
389            member_public_keys,
390            now,
391        )
392    }
393
394    /// Return one complete conversation by stable ID.
395    ///
396    /// # Errors
397    ///
398    /// Returns a stable account, key, or persistence error.
399    pub fn conversation(
400        &self,
401        conversation_id: String,
402    ) -> Result<Option<ConversationRecord>, SoftchatError> {
403        let own_public_key = self.public_key()?;
404        self.database()?
405            .product_conversation(&conversation_id, &own_public_key)
406    }
407
408    /// Return one bounded stable page of complete conversations.
409    ///
410    /// # Errors
411    ///
412    /// Returns a stable cursor, bound, account, or persistence error.
413    pub fn conversation_page(
414        &self,
415        archived: bool,
416        cursor: Option<ConversationCursor>,
417        limit: u32,
418    ) -> Result<ConversationPage, SoftchatError> {
419        let own_public_key = self.public_key()?;
420        self.database()?
421            .product_conversations(&own_public_key, archived, cursor, limit)
422    }
423
424    /// Return one complete effective message by ID.
425    ///
426    /// # Errors
427    ///
428    /// Returns a stable ID, account, or persistence error.
429    pub fn message(&self, message_id: String) -> Result<Option<AccountMessageView>, SoftchatError> {
430        self.database()?.product_message(&message_id)
431    }
432
433    /// Return up to 512 unique exact messages in caller order, omitting unknown IDs.
434    ///
435    /// Includes effective edits, reactions, and deletion tombstones from one database snapshot.
436    /// Empty input returns an empty result.
437    ///
438    /// # Errors
439    ///
440    /// Returns a stable ID, duplicate, bound, account, or persistence error.
441    pub fn messages_by_ids(
442        &self,
443        message_ids: Vec<String>,
444    ) -> Result<Vec<AccountMessageView>, SoftchatError> {
445        self.database()?.product_messages_by_ids(&message_ids)
446    }
447
448    /// Return one bounded lossless tuple-cursor message page.
449    ///
450    /// # Errors
451    ///
452    /// Returns a stable ID, cursor, bound, account, or persistence error.
453    pub fn message_page(
454        &self,
455        conversation_id: String,
456        cursor: Option<MessageCursor>,
457        limit: u32,
458    ) -> Result<MessagePage, SoftchatError> {
459        self.database()?
460            .product_messages(&conversation_id, cursor, limit)
461    }
462
463    /// Return a newest-first tuple-cursor page of direct replies in the root conversation.
464    ///
465    /// Unrelated messages and forwards do not consume the page. Includes deletion tombstones;
466    /// an unknown root returns an empty page. Limits and cursors match `message_page`.
467    ///
468    /// # Errors
469    ///
470    /// Returns a stable ID, cursor, bound, account, or persistence error.
471    pub fn message_replies(
472        &self,
473        message_id: String,
474        cursor: Option<MessageCursor>,
475        limit: u32,
476    ) -> Result<MessagePage, SoftchatError> {
477        self.database()?
478            .product_message_replies(&message_id, cursor, limit)
479    }
480
481    /// Return a bounded context centered on one stored message.
482    ///
483    /// # Errors
484    ///
485    /// Returns a stable ID, bound, account, or persistence error.
486    pub fn message_context(
487        &self,
488        message_id: String,
489        before: u32,
490        after: u32,
491    ) -> Result<Vec<AccountMessageView>, SoftchatError> {
492        self.database()?
493            .product_message_context(&message_id, before, after)
494    }
495
496    /// Search effective live message text through the Rust-owned FTS index.
497    ///
498    /// Empty [conversation_id] searches the whole account.
499    ///
500    /// # Errors
501    ///
502    /// Returns a stable query, bound, account, or persistence error.
503    pub fn search_messages(
504        &self,
505        conversation_id: String,
506        query: String,
507        limit: u32,
508    ) -> Result<Vec<AccountMessageView>, SoftchatError> {
509        self.database()?.search_product_messages(
510            (!conversation_id.is_empty()).then_some(conversation_id),
511            &query,
512            limit,
513        )
514    }
515
516    /// Return sparse account-local transcript and waveform data for one message.
517    ///
518    /// # Errors
519    ///
520    /// Returns a stable message, account, or persistence error.
521    pub fn message_local_extras(
522        &self,
523        message_id: String,
524    ) -> Result<Option<crate::MessageLocalExtras>, SoftchatError> {
525        self.database()?.product_message_local_extras(&message_id)
526    }
527
528    /// Replace sparse account-local transcript and normalized waveform data.
529    ///
530    /// # Errors
531    ///
532    /// Returns a stable message, bound, account, or persistence error.
533    pub fn set_message_local_extras(
534        &self,
535        message_id: String,
536        transcript: Option<String>,
537        waveform: Vec<u8>,
538        now: i64,
539    ) -> Result<AccountMutationResult, SoftchatError> {
540        self.database()?
541            .set_product_message_local_extras(&message_id, transcript, waveform, now)
542    }
543
544    /// Return the exact number of archived or active conversations.
545    ///
546    /// # Errors
547    ///
548    /// Returns a stable persistence error.
549    pub fn conversation_count(&self, archived: bool) -> Result<u32, SoftchatError> {
550        self.database()?.product_conversation_count(archived)
551    }
552
553    /// Return whether another participant has an unread live message.
554    ///
555    /// # Errors
556    ///
557    /// Returns a stable account or persistence error.
558    pub fn has_unread(&self, include_archived: bool) -> Result<bool, SoftchatError> {
559        let own_public_key = self.public_key()?;
560        self.database()?
561            .product_has_unread(&own_public_key, include_archived)
562    }
563
564    /// Search complete conversation views by subject, member key, or known name.
565    /// Matching ignores ASCII case; other Unicode characters remain literal.
566    ///
567    /// An empty archived filter searches both active and archived conversations.
568    ///
569    /// # Errors
570    ///
571    /// Returns a stable search, bound, account, or persistence error.
572    pub fn search_conversations(
573        &self,
574        query: String,
575        archived: Option<bool>,
576        limit: u32,
577    ) -> Result<Vec<ConversationRecord>, SoftchatError> {
578        let own_public_key = self.public_key()?;
579        self.database()?
580            .search_product_conversations(&own_public_key, &query, archived, limit)
581    }
582
583    /// Send one atomic idempotent chat command.
584    ///
585    /// Rust resolves participants, relay selection, notification recipients,
586    /// local echo, wrappers, and delivery intents from durable account state.
587    /// The host persists its exact portable composer snapshot before passing it
588    /// in `draft_to_consume`. When present,
589    /// the same SQLite transaction clears it only if the stored semantic draft
590    /// still matches. A newer draft is preserved, including when its autosave
591    /// wins the race before this command acquires SQLite. An idempotent replay
592    /// returns before draft matching.
593    ///
594    /// # Errors
595    ///
596    /// Returns a stable command, conversation, content, relay, or persistence error.
597    pub fn send_message(
598        &self,
599        command_id: String,
600        conversation_id: String,
601        content: MessageContentInput,
602        reply_to_message_id: String,
603        draft_to_consume: Option<AccountDraft>,
604        now: i64,
605    ) -> Result<ProductOperationResult, SoftchatError> {
606        let result = (|| {
607            validate_command_id(&command_id)?;
608            let draft_match = draft_to_consume.map(product_draft_match).transpose()?;
609            let payload_hash = send_message_command_hash(
610                &conversation_id,
611                &content,
612                &reply_to_message_id,
613                draft_match.as_ref(),
614            )?;
615            let (rumor, recipients) = author_product_chat_message(
616                self,
617                &conversation_id,
618                content,
619                &reply_to_message_id,
620                now,
621            )?;
622            let message_id = rumor.id.clone();
623            let operation = {
624                let mut database = self.database()?;
625                self.identity.with_identity(|identity| {
626                    database.enqueue_product_rumor(
627                        identity,
628                        command_id.clone(),
629                        payload_hash,
630                        message_id.clone(),
631                        rumor,
632                        recipients.clone(),
633                        recipients,
634                        Nip59EnvelopeKind::Durable,
635                        now,
636                        draft_match
637                            .as_ref()
638                            .map(|value| (conversation_id.as_str(), value)),
639                    )
640                })?
641            };
642            stored_product_result(self, operation)
643        })();
644        let mut diagnostic = Diagnostic::new(
645            AccountLogCategory::Messaging,
646            "message.command.failed",
647            "Account command failed",
648        );
649        diagnostic.subject = Some(AccountLogSubject::ChatMessage);
650        diagnostic.source = Some(AccountLogSource::LocalCommand);
651        self.logs.failed(diagnostic, &result);
652        result
653    }
654
655    /// Forward one stored authenticated message to another conversation.
656    ///
657    /// Replaying a committed command returns its original operation even if
658    /// the source message was subsequently deleted.
659    ///
660    /// # Errors
661    ///
662    /// Returns a stable command, message, conversation, relay, or persistence error.
663    pub fn forward_message(
664        &self,
665        command_id: String,
666        message_id: String,
667        destination_conversation_id: String,
668        now: i64,
669    ) -> Result<ProductOperationResult, SoftchatError> {
670        let result = (|| {
671            validate_command_id(&command_id)?;
672            NostrEventId::from_hex(&message_id)?;
673            let payload_hash =
674                command_hash(&("forward", &message_id, &destination_conversation_id))?;
675            let existing = self
676                .database()?
677                .product_operation_if_committed(&command_id, &payload_hash)?;
678            if let Some(operation) = existing {
679                return stored_product_result(self, operation);
680            }
681            let original = self
682                .database()?
683                .product_message(&message_id)?
684                .filter(|message| !message.deleted)
685                .ok_or(SoftchatError::InvalidAccountOperation)?;
686            let own_public_key = self.public_key()?;
687            let destination = self
688                .database()?
689                .product_conversation(&destination_conversation_id, &own_public_key)?
690                .ok_or(SoftchatError::InvalidAccountOperation)?;
691            let recipients = remote_members(&destination, &own_public_key);
692            let created_at =
693                u64::try_from(now).map_err(|_| SoftchatError::InvalidAccountOperation)?;
694            let relay_hint = self.database()?.active_relay_url()?;
695            let rumor = self.identity.with_identity(|identity| {
696                identity
697                    .create_chat_message(ChatMessageDraft {
698                        created_at,
699                        participants: public_keys(&recipients)?,
700                        content: original.text,
701                        relation: ChatRelation::Forward {
702                            event_id: NostrEventId::from_hex(&original.id)?,
703                            relay_hint: Some(relay_hint),
704                            original_author: NostrPublicKey::from_hex(&original.author_public_key)?,
705                        },
706                        attachments: original.attachments,
707                        emoji_tags: nostr_tags(original.emoji_tags)?,
708                        extension_tags: Vec::new(),
709                    })
710                    .map(|view| view.rumor)
711            })?;
712            let forwarded_message_id = rumor.id.clone();
713            let operation = {
714                let mut database = self.database()?;
715                self.identity.with_identity(|identity| {
716                    database.enqueue_product_rumor(
717                        identity,
718                        command_id,
719                        payload_hash,
720                        forwarded_message_id.clone(),
721                        rumor,
722                        recipients.clone(),
723                        recipients,
724                        Nip59EnvelopeKind::Durable,
725                        now,
726                        None,
727                    )
728                })?
729            };
730            stored_product_result(self, operation)
731        })();
732        let mut diagnostic = Diagnostic::new(
733            AccountLogCategory::Messaging,
734            "message.command.failed",
735            "Account command failed",
736        );
737        diagnostic.subject = Some(AccountLogSubject::ChatMessage);
738        diagnostic.source = Some(AccountLogSource::LocalCommand);
739        self.logs.failed(diagnostic, &result);
740        result
741    }
742
743    /// Publish one conversation subject update.
744    ///
745    /// # Errors
746    ///
747    /// Returns a stable command, conversation, subject, relay, or persistence error.
748    pub fn set_conversation_subject(
749        &self,
750        command_id: String,
751        conversation_id: String,
752        subject: String,
753        icon: Option<AttachmentMetadata>,
754        emoji_tags: Vec<Vec<String>>,
755        now: i64,
756    ) -> Result<ProductOperationResult, SoftchatError> {
757        let result = (|| {
758            validate_command_id(&command_id)?;
759            if subject.len() > crate::MAX_PRODUCT_MESSAGE_BYTES {
760                return Err(SoftchatError::InvalidAccountOperation);
761            }
762            let own_public_key = self.public_key()?;
763            let conversation = self
764                .database()?
765                .product_conversation(&conversation_id, &own_public_key)?
766                .ok_or(SoftchatError::InvalidAccountOperation)?;
767            let recipients = remote_members(&conversation, &own_public_key);
768            let payload_hash =
769                command_hash(&("subject", &conversation_id, &subject, &icon, &emoji_tags))?;
770            let created_at =
771                u64::try_from(now).map_err(|_| SoftchatError::InvalidAccountOperation)?;
772            let rumor = self.identity.with_identity(|identity| {
773                identity
774                    .create_subject(SubjectDraft {
775                        created_at,
776                        participants: public_keys(&recipients)?,
777                        subject,
778                        emoji_tags: nostr_tags(emoji_tags)?,
779                        icon,
780                        extension_tags: Vec::new(),
781                    })
782                    .map(|view| view.rumor)
783            })?;
784            let operation = {
785                let mut database = self.database()?;
786                self.identity.with_identity(|identity| {
787                    database.enqueue_product_rumor(
788                        identity,
789                        command_id,
790                        payload_hash,
791                        String::new(),
792                        rumor,
793                        recipients.clone(),
794                        recipients,
795                        Nip59EnvelopeKind::Durable,
796                        now,
797                        None,
798                    )
799                })?
800            };
801            stored_product_result(self, operation)
802        })();
803        let mut diagnostic = Diagnostic::new(
804            AccountLogCategory::Messaging,
805            "message.command.failed",
806            "Account command failed",
807        );
808        diagnostic.subject = Some(AccountLogSubject::Subject);
809        diagnostic.source = Some(AccountLogSource::LocalCommand);
810        self.logs.failed(diagnostic, &result);
811        result
812    }
813
814    /// Toggle one reaction by the local account on a stored message.
815    ///
816    /// A committed command replays its original result without toggling again,
817    /// including after later reactions or deletion of the parent message.
818    /// Removal addresses all active local copies of the value, independently
819    /// of the bounded message view. More than 256 matching copies returns
820    /// `InvalidAccountOperation` without a partial deletion or revision change.
821    ///
822    /// # Errors
823    ///
824    /// Returns a stable command, message, reaction, authorization, or persistence error.
825    pub fn toggle_message_reaction(
826        &self,
827        command_id: String,
828        message_id: String,
829        reaction: MessageReactionInput,
830        now: i64,
831    ) -> Result<ProductOperationResult, SoftchatError> {
832        let result = (|| {
833            validate_command_id(&command_id)?;
834            NostrEventId::from_hex(&message_id)?;
835            // Released commands hash the state before the toggle. Either historic
836            // hash identifies the same caller intent, regardless of current state.
837            let adding_hash = command_hash(&("reaction", &message_id, &reaction, false))?;
838            let removing_hash = command_hash(&("reaction", &message_id, &reaction, true))?;
839            let mut database = self.database()?;
840            let committed = match database.product_operation_if_committed(&command_id, &adding_hash)
841            {
842                Err(SoftchatError::CommandConflict) => {
843                    database.product_operation_if_committed(&command_id, &removing_hash)?
844                }
845                result => result?,
846            };
847            if let Some(operation) = committed {
848                drop(database);
849                return stored_product_result(self, operation);
850            }
851            let own_public_key = self.public_key()?;
852            let parent = database
853                .product_message(&message_id)?
854                .filter(|message| !message.deleted)
855                .ok_or(SoftchatError::InvalidAccountOperation)?;
856            let conversation = database
857                .product_conversation(&parent.conversation_id, &own_public_key)?
858                .ok_or(SoftchatError::InvalidAccountOperation)?;
859            let recipients = remote_members(&conversation, &own_public_key);
860            let existing_ids = database.active_product_reaction_ids(
861                &message_id,
862                &own_public_key,
863                &reaction.value,
864            )?;
865            let payload_hash = if !existing_ids.is_empty() {
866                removing_hash
867            } else {
868                adding_hash
869            };
870            let created_at =
871                u64::try_from(now).map_err(|_| SoftchatError::InvalidAccountOperation)?;
872            let rumor = if !existing_ids.is_empty() {
873                self.identity.with_identity(|identity| {
874                    identity
875                        .create_deletion(
876                            public_keys(&recipients)?,
877                            existing_ids
878                                .iter()
879                                .map(|id| NostrEventId::from_hex(id))
880                                .collect::<Result<Vec<_>, _>>()?,
881                            String::new(),
882                            created_at,
883                        )
884                        .map(|view| view.rumor)
885                })?
886            } else {
887                self.identity.with_identity(|identity| {
888                    identity
889                        .create_reaction(ReactionDraft {
890                            participants: public_keys(&recipients)?,
891                            parent_id: NostrEventId::from_hex(&parent.id)?,
892                            parent_author: NostrPublicKey::from_hex(&parent.author_public_key)?,
893                            parent_kind: NostrEventKind::PRIVATE_DIRECT_MESSAGE,
894                            reaction: reaction.value,
895                            custom_emoji_url: reaction.custom_emoji_url,
896                            created_at,
897                        })
898                        .map(|view| view.rumor)
899                })?
900            };
901            let operation = self.identity.with_identity(|identity| {
902                database.enqueue_product_rumor(
903                    identity,
904                    command_id,
905                    payload_hash,
906                    message_id.clone(),
907                    rumor,
908                    recipients.clone(),
909                    Vec::new(),
910                    Nip59EnvelopeKind::Durable,
911                    now,
912                    None,
913                )
914            })?;
915            drop(database);
916            stored_product_result(self, operation)
917        })();
918        let mut diagnostic = Diagnostic::new(
919            AccountLogCategory::Messaging,
920            "message.command.failed",
921            "Account command failed",
922        );
923        diagnostic.subject = Some(AccountLogSubject::Reaction);
924        diagnostic.source = Some(AccountLogSource::LocalCommand);
925        self.logs.failed(diagnostic, &result);
926        result
927    }
928
929    /// Replace text and emoji on one locally authored stored message.
930    ///
931    /// Replaying a committed command with the same payload returns its existing
932    /// operation even if the original message has subsequently been deleted.
933    /// A different payload for that command remains a command conflict.
934    ///
935    /// # Errors
936    ///
937    /// Returns a stable command, authorization, content, or persistence error.
938    pub fn edit_message(
939        &self,
940        command_id: String,
941        message_id: String,
942        replacement: MessageContentInput,
943        now: i64,
944    ) -> Result<ProductOperationResult, SoftchatError> {
945        let result = (|| {
946            validate_command_id(&command_id)?;
947            replacement.validate_edit()?;
948            let target = NostrEventId::from_hex(&message_id)?;
949            let payload_hash = command_hash(&("edit", &message_id, &replacement))?;
950            let existing = {
951                let database = self.database()?;
952                database.product_operation_if_committed(&command_id, &payload_hash)?
953            };
954            if let Some(operation) = existing {
955                return stored_product_result(self, operation);
956            }
957            let own_public_key = self.public_key()?;
958            let original = self
959                .database()?
960                .product_message(&message_id)?
961                .filter(|message| message.author_public_key == own_public_key && !message.deleted)
962                .ok_or(SoftchatError::InvalidAccountOperation)?;
963            if replacement.text.is_empty() && original.attachments.is_empty() {
964                return Err(SoftchatError::InvalidAccountOperation);
965            }
966            let conversation = self
967                .database()?
968                .product_conversation(&original.conversation_id, &own_public_key)?
969                .ok_or(SoftchatError::InvalidAccountOperation)?;
970            let recipients = remote_members(&conversation, &own_public_key);
971            let created_at =
972                u64::try_from(now).map_err(|_| SoftchatError::InvalidAccountOperation)?;
973            let rumor = self.identity.with_identity(|identity| {
974                identity
975                    .create_edit(
976                        public_keys(&recipients)?,
977                        target,
978                        replacement.text,
979                        nostr_tags(replacement.emoji_tags)?,
980                        created_at,
981                    )
982                    .map(|view| view.rumor)
983            })?;
984            let operation = {
985                let mut database = self.database()?;
986                self.identity.with_identity(|identity| {
987                    database.enqueue_product_rumor(
988                        identity,
989                        command_id,
990                        payload_hash,
991                        message_id.clone(),
992                        rumor,
993                        recipients,
994                        Vec::new(),
995                        Nip59EnvelopeKind::Durable,
996                        now,
997                        None,
998                    )
999                })?
1000            };
1001            stored_product_result(self, operation)
1002        })();
1003        let mut diagnostic = Diagnostic::new(
1004            AccountLogCategory::Messaging,
1005            "message.command.failed",
1006            "Account command failed",
1007        );
1008        diagnostic.subject = Some(AccountLogSubject::Edit);
1009        diagnostic.source = Some(AccountLogSource::LocalCommand);
1010        self.logs.failed(diagnostic, &result);
1011        result
1012    }
1013
1014    /// Delete one locally authored stored message.
1015    ///
1016    /// # Errors
1017    ///
1018    /// Returns a stable command, authorization, message, or persistence error.
1019    pub fn delete_message(
1020        &self,
1021        command_id: String,
1022        message_id: String,
1023        now: i64,
1024    ) -> Result<ProductOperationResult, SoftchatError> {
1025        let result = (|| {
1026            validate_command_id(&command_id)?;
1027            if message_id.len() != 64 {
1028                return Err(SoftchatError::InvalidAccountOperation);
1029            }
1030            let target = NostrEventId::from_hex(&message_id)?;
1031            let payload_hash = command_hash(&("delete", &message_id))?;
1032            let existing = {
1033                let database = self.database()?;
1034                database.product_operation_if_committed(&command_id, &payload_hash)?
1035            };
1036            if let Some(operation) = existing {
1037                return stored_product_result(self, operation);
1038            }
1039            let own_public_key = self.public_key()?;
1040            let original = self
1041                .database()?
1042                .product_message(&message_id)?
1043                .filter(|message| message.author_public_key == own_public_key && !message.deleted)
1044                .ok_or(SoftchatError::InvalidAccountOperation)?;
1045            let conversation = self
1046                .database()?
1047                .product_conversation(&original.conversation_id, &own_public_key)?
1048                .ok_or(SoftchatError::InvalidAccountOperation)?;
1049            let recipients = remote_members(&conversation, &own_public_key);
1050            let created_at =
1051                u64::try_from(now).map_err(|_| SoftchatError::InvalidAccountOperation)?;
1052            let rumor = self.identity.with_identity(|identity| {
1053                identity
1054                    .create_deletion(
1055                        public_keys(&recipients)?,
1056                        vec![target],
1057                        String::new(),
1058                        created_at,
1059                    )
1060                    .map(|view| view.rumor)
1061            })?;
1062            let operation = {
1063                let mut database = self.database()?;
1064                self.identity.with_identity(|identity| {
1065                    database.enqueue_product_rumor(
1066                        identity,
1067                        command_id,
1068                        payload_hash,
1069                        message_id.clone(),
1070                        rumor,
1071                        recipients,
1072                        Vec::new(),
1073                        Nip59EnvelopeKind::Durable,
1074                        now,
1075                        None,
1076                    )
1077                })?
1078            };
1079            stored_product_result(self, operation)
1080        })();
1081        let mut diagnostic = Diagnostic::new(
1082            AccountLogCategory::Messaging,
1083            "message.command.failed",
1084            "Account command failed",
1085        );
1086        diagnostic.subject = Some(AccountLogSubject::Deletion);
1087        diagnostic.source = Some(AccountLogSource::LocalCommand);
1088        self.logs.failed(diagnostic, &result);
1089        result
1090    }
1091
1092    /// Delete a bounded set of locally authored messages from one conversation atomically.
1093    ///
1094    /// One idempotent command authors one deletion rumor containing every target. Either the
1095    /// complete command is committed and projected or no target is changed.
1096    ///
1097    /// # Errors
1098    ///
1099    /// Returns a stable command, bound, authorization, message, conversation, or persistence
1100    /// error.
1101    pub fn delete_messages(
1102        &self,
1103        command_id: String,
1104        message_ids: Vec<String>,
1105        now: i64,
1106    ) -> Result<ProductOperationResult, SoftchatError> {
1107        let result = (|| {
1108            validate_command_id(&command_id)?;
1109            if message_ids.is_empty() || message_ids.len() > crate::MAX_PRODUCT_QUERY_PAGE as usize
1110            {
1111                return Err(SoftchatError::InvalidAccountOperation);
1112            }
1113            if message_ids.iter().any(|message_id| message_id.len() != 64) {
1114                return Err(SoftchatError::InvalidAccountOperation);
1115            }
1116            let targets = message_ids
1117                .iter()
1118                .map(|message_id| NostrEventId::from_hex(message_id))
1119                .collect::<Result<Vec<_>, _>>()?;
1120            let mut distinct_ids = BTreeSet::new();
1121            if message_ids
1122                .iter()
1123                .any(|message_id| !distinct_ids.insert(message_id.as_str()))
1124            {
1125                return Err(SoftchatError::InvalidAccountOperation);
1126            }
1127            let payload_hash = command_hash(&("delete-many", &message_ids))?;
1128            let existing = {
1129                let database = self.database()?;
1130                database.product_operation_if_committed(&command_id, &payload_hash)?
1131            };
1132            if let Some(operation) = existing {
1133                return stored_product_result(self, operation);
1134            }
1135
1136            let own_public_key = self.public_key()?;
1137            let messages = message_ids
1138                .iter()
1139                .map(|message_id| {
1140                    self.database()?
1141                        .product_message(message_id)?
1142                        .filter(|message| {
1143                            message.author_public_key == own_public_key && !message.deleted
1144                        })
1145                        .ok_or(SoftchatError::InvalidAccountOperation)
1146                })
1147                .collect::<Result<Vec<_>, _>>()?;
1148            let conversation_id = messages
1149                .first()
1150                .map(|message| message.conversation_id.clone())
1151                .ok_or(SoftchatError::InvalidAccountOperation)?;
1152            if messages
1153                .iter()
1154                .any(|message| message.conversation_id != conversation_id)
1155            {
1156                return Err(SoftchatError::InvalidAccountOperation);
1157            }
1158            let conversation = self
1159                .database()?
1160                .product_conversation(&conversation_id, &own_public_key)?
1161                .ok_or(SoftchatError::InvalidAccountOperation)?;
1162            let recipients = remote_members(&conversation, &own_public_key);
1163            let created_at =
1164                u64::try_from(now).map_err(|_| SoftchatError::InvalidAccountOperation)?;
1165            let rumor = self.identity.with_identity(|identity| {
1166                identity
1167                    .create_deletion(
1168                        public_keys(&recipients)?,
1169                        targets,
1170                        String::new(),
1171                        created_at,
1172                    )
1173                    .map(|view| view.rumor)
1174            })?;
1175            let result_message_id = message_ids
1176                .first()
1177                .cloned()
1178                .ok_or(SoftchatError::InvalidAccountOperation)?;
1179            let operation = {
1180                let mut database = self.database()?;
1181                self.identity.with_identity(|identity| {
1182                    database.enqueue_product_rumor(
1183                        identity,
1184                        command_id,
1185                        payload_hash,
1186                        result_message_id,
1187                        rumor,
1188                        recipients,
1189                        Vec::new(),
1190                        Nip59EnvelopeKind::Durable,
1191                        now,
1192                        None,
1193                    )
1194                })?
1195            };
1196            stored_product_result(self, operation)
1197        })();
1198        let mut diagnostic = Diagnostic::new(
1199            AccountLogCategory::Messaging,
1200            "message.command.failed",
1201            "Account command failed",
1202        );
1203        diagnostic.subject = Some(AccountLogSubject::Deletion);
1204        diagnostic.source = Some(AccountLogSource::LocalCommand);
1205        self.logs.failed(diagnostic, &result);
1206        result
1207    }
1208
1209    /// Publish an ephemeral typing event when enabled by account settings.
1210    ///
1211    /// Returning `None` means no wire event was required.
1212    ///
1213    /// # Errors
1214    ///
1215    /// Returns a stable command, conversation, settings, or persistence error.
1216    pub fn typing_changed(
1217        &self,
1218        command_id: String,
1219        conversation_id: String,
1220        is_typing: bool,
1221        now: i64,
1222    ) -> Result<Option<ProductOperationResult>, SoftchatError> {
1223        let result = (|| {
1224            validate_command_id(&command_id)?;
1225            if !is_typing {
1226                lock(&self.typing_authored).remove(&conversation_id);
1227                return Ok(None);
1228            }
1229            if !self
1230                .database()?
1231                .product_settings()?
1232                .typing_indicators_enabled
1233            {
1234                return Ok(None);
1235            }
1236            {
1237                let authored = lock(&self.typing_authored);
1238                if authored
1239                    .get(&conversation_id)
1240                    .is_some_and(|(last, prior_id)| {
1241                        prior_id != &command_id && now >= *last && now.saturating_sub(*last) < 3
1242                    })
1243                {
1244                    return Ok(None);
1245                }
1246            }
1247            let own_public_key = self.public_key()?;
1248            let conversation = self
1249                .database()?
1250                .product_conversation(&conversation_id, &own_public_key)?
1251                .ok_or(SoftchatError::InvalidAccountOperation)?;
1252            let recipients = remote_members(&conversation, &own_public_key);
1253            let payload_hash = command_hash(&("typing", &conversation_id, is_typing))?;
1254            let created_at =
1255                u64::try_from(now).map_err(|_| SoftchatError::InvalidAccountOperation)?;
1256            let rumor = self.identity.with_identity(|identity| {
1257                identity
1258                    .create_typing(public_keys(&recipients)?, created_at)
1259                    .map(|view| view.rumor)
1260            })?;
1261            let operation = {
1262                let mut database = self.database()?;
1263                self.identity.with_identity(|identity| {
1264                    database.enqueue_product_rumor(
1265                        identity,
1266                        command_id.clone(),
1267                        payload_hash,
1268                        String::new(),
1269                        rumor,
1270                        recipients,
1271                        Vec::new(),
1272                        Nip59EnvelopeKind::Ephemeral,
1273                        now,
1274                        None,
1275                    )
1276                })?
1277            };
1278            let result = stored_product_result(self, operation)?;
1279            lock(&self.typing_authored).insert(conversation_id, (now, command_id));
1280            Ok(Some(result))
1281        })();
1282        let mut diagnostic = Diagnostic::new(
1283            AccountLogCategory::Messaging,
1284            "message.command.failed",
1285            "Account command failed",
1286        );
1287        diagnostic.subject = Some(AccountLogSubject::Typing);
1288        diagnostic.source = Some(AccountLogSource::LocalCommand);
1289        self.logs.failed(diagnostic, &result);
1290        result
1291    }
1292
1293    /// Set the local archived state for one conversation.
1294    ///
1295    /// # Errors
1296    ///
1297    /// Returns a stable conversation or persistence error.
1298    pub fn set_conversation_archived(
1299        &self,
1300        conversation_id: String,
1301        archived: bool,
1302        now: i64,
1303    ) -> Result<AccountMutationResult, SoftchatError> {
1304        self.database()?
1305            .set_product_conversation_flag(&conversation_id, "archived", archived, now)
1306    }
1307
1308    /// Set the local pinned state for one conversation.
1309    ///
1310    /// # Errors
1311    ///
1312    /// Returns a stable conversation or persistence error.
1313    pub fn set_conversation_pinned(
1314        &self,
1315        conversation_id: String,
1316        pinned: bool,
1317        now: i64,
1318    ) -> Result<AccountMutationResult, SoftchatError> {
1319        self.database()?
1320            .set_product_conversation_flag(&conversation_id, "pinned", pinned, now)
1321    }
1322
1323    /// Mark one conversation read through an exact stable message cursor.
1324    ///
1325    /// The cursor timestamp must match its stored message. State preview,
1326    /// signing and commit share one account lock so concurrent changes retain
1327    /// a complete cross-device snapshot.
1328    /// Committed commands replay before preparing another snapshot, preserving
1329    /// later read-state updates and the current account revision.
1330    ///
1331    /// # Errors
1332    ///
1333    /// Returns a stable conversation, timestamp, or persistence error.
1334    pub fn mark_conversation_read(
1335        &self,
1336        command_id: String,
1337        conversation_id: String,
1338        boundary: MessageCursor,
1339        now: i64,
1340    ) -> Result<ProductOperationResult, SoftchatError> {
1341        let result = (|| {
1342            validate_command_id(&command_id)?;
1343            validate_conversation_id(&conversation_id)?;
1344            NostrEventId::from_hex(&boundary.message_id)?;
1345            let boundary_created_at = u64::try_from(boundary.created_at)
1346                .map_err(|_| SoftchatError::InvalidAccountOperation)?;
1347            if boundary_created_at > crate::MAX_PORTABLE_TIMESTAMP_SECONDS {
1348                return Err(SoftchatError::InvalidAccountOperation);
1349            }
1350            let payload_hash = command_hash(&("mark-read", &conversation_id, &boundary))?;
1351            let mut database = self.database()?;
1352            if let Some(operation) =
1353                database.product_operation_if_committed(&command_id, &payload_hash)?
1354            {
1355                drop(database);
1356                return stored_product_result(self, operation);
1357            }
1358            let preview = database.preview_product_read_state_update(
1359                &conversation_id,
1360                Some(boundary.clone()),
1361                false,
1362                now,
1363                &command_id,
1364            )?;
1365            let authored_at = database.next_app_data_event_timestamp("read-state", now)?;
1366            let created_at =
1367                u64::try_from(authored_at).map_err(|_| SoftchatError::InvalidAccountOperation)?;
1368            let event = self.identity.with_identity(|identity| {
1369                identity
1370                    .create_app_data_sync(
1371                        created_at,
1372                        AppDataContext::ReadState,
1373                        &preview.canonical_json,
1374                    )
1375                    .map(|view| view.event)
1376            })?;
1377            let operation = database.enqueue_product_signed_with_read_state(
1378                command_id,
1379                payload_hash,
1380                vec![event],
1381                preview.state,
1382                preview.canonical_json,
1383                now,
1384            )?;
1385            drop(database);
1386            stored_product_result(self, operation)
1387        })();
1388        let mut diagnostic = Diagnostic::new(
1389            AccountLogCategory::Synchronization,
1390            "message.command.failed",
1391            "Account command failed",
1392        );
1393        diagnostic.subject = Some(AccountLogSubject::ReadState);
1394        diagnostic.source = Some(AccountLogSource::LocalCommand);
1395        self.logs.failed(diagnostic, &result);
1396        result
1397    }
1398
1399    /// Mark one conversation unread without destroying its prior read boundary.
1400    ///
1401    /// State preview, signing and commit share one account lock.
1402    /// Committed commands replay without preparing or changing current state.
1403    ///
1404    /// # Errors
1405    ///
1406    /// Returns a stable conversation or persistence error.
1407    pub fn mark_conversation_unread(
1408        &self,
1409        command_id: String,
1410        conversation_id: String,
1411        now: i64,
1412    ) -> Result<ProductOperationResult, SoftchatError> {
1413        let result = (|| {
1414            validate_command_id(&command_id)?;
1415            validate_conversation_id(&conversation_id)?;
1416            let payload_hash = command_hash(&("mark-unread", &conversation_id))?;
1417            let mut database = self.database()?;
1418            if let Some(operation) =
1419                database.product_operation_if_committed(&command_id, &payload_hash)?
1420            {
1421                drop(database);
1422                return stored_product_result(self, operation);
1423            }
1424            let preview = database.preview_product_read_state_update(
1425                &conversation_id,
1426                None,
1427                true,
1428                now,
1429                &command_id,
1430            )?;
1431            let authored_at = database.next_app_data_event_timestamp("read-state", now)?;
1432            let created_at =
1433                u64::try_from(authored_at).map_err(|_| SoftchatError::InvalidAccountOperation)?;
1434            let event = self.identity.with_identity(|identity| {
1435                identity
1436                    .create_app_data_sync(
1437                        created_at,
1438                        AppDataContext::ReadState,
1439                        &preview.canonical_json,
1440                    )
1441                    .map(|view| view.event)
1442            })?;
1443            let operation = database.enqueue_product_signed_with_read_state(
1444                command_id,
1445                payload_hash,
1446                vec![event],
1447                preview.state,
1448                preview.canonical_json,
1449                now,
1450            )?;
1451            drop(database);
1452            stored_product_result(self, operation)
1453        })();
1454        let mut diagnostic = Diagnostic::new(
1455            AccountLogCategory::Synchronization,
1456            "message.command.failed",
1457            "Account command failed",
1458        );
1459        diagnostic.subject = Some(AccountLogSubject::ReadState);
1460        diagnostic.source = Some(AccountLogSource::LocalCommand);
1461        self.logs.failed(diagnostic, &result);
1462        result
1463    }
1464
1465    /// Return the complete effective cross-device read-state snapshot.
1466    ///
1467    /// # Errors
1468    ///
1469    /// Returns a stable account or persistence error.
1470    pub fn account_read_state(&self) -> Result<Vec<crate::AccountReadState>, SoftchatError> {
1471        self.database()?.product_read_states()
1472    }
1473
1474    /// Save or clear one portable conversation draft.
1475    ///
1476    /// # Errors
1477    ///
1478    /// Returns a stable draft, conversation, or persistence error.
1479    pub fn save_conversation_draft(
1480        &self,
1481        conversation_id: String,
1482        draft: Option<AccountDraft>,
1483        now: i64,
1484    ) -> Result<AccountMutationResult, SoftchatError> {
1485        self.database()?
1486            .save_product_draft(&conversation_id, draft, now)
1487    }
1488
1489    /// Return the complete versioned account settings.
1490    ///
1491    /// # Errors
1492    ///
1493    /// Returns a stable settings or persistence error.
1494    pub fn account_settings(&self) -> Result<AccountSettings, SoftchatError> {
1495        self.database()?.product_settings()
1496    }
1497
1498    /// Apply one JSON merge patch while retaining unknown settings fields.
1499    ///
1500    /// The account lock spans preview, signing and commit so concurrent patches
1501    /// preserve each other's independent fields.
1502    ///
1503    /// Replaying a committed bounded patch returns current settings without
1504    /// merging it again, creating another operation or advancing the revision.
1505    ///
1506    /// The authored Android facade exposes typed patch values; JSON remains a
1507    /// private FFI representation.
1508    ///
1509    /// # Errors
1510    ///
1511    /// Returns a stable settings, timestamp, or persistence error.
1512    pub fn apply_account_settings_patch(
1513        &self,
1514        command_id: String,
1515        patch_json: String,
1516        now: i64,
1517    ) -> Result<AccountSettingsMutation, SoftchatError> {
1518        let result = (|| {
1519            validate_command_id(&command_id)?;
1520            if patch_json.len() > crate::MAX_ACCOUNT_SETTINGS_BYTES {
1521                return Err(SoftchatError::InvalidAccountSettings);
1522            }
1523            let canonical_patch: serde_json::Value = serde_json::from_str(&patch_json)
1524                .map_err(|_| SoftchatError::InvalidAccountSettings)?;
1525            if !canonical_patch.is_object() {
1526                return Err(SoftchatError::InvalidAccountSettings);
1527            }
1528            let payload_hash = command_hash(&("settings", canonical_patch))?;
1529            let mut database = self.database()?;
1530            let operation = if let Some(committed) =
1531                database.product_operation_if_committed(&command_id, &payload_hash)?
1532            {
1533                committed
1534            } else {
1535                let mutation = database.preview_product_settings_patch(&patch_json)?;
1536                let authored_at = database.next_app_data_event_timestamp("app-settings", now)?;
1537                let wire_json =
1538                    crate::account_product::settings_wire_json(&mutation.canonical_json)?;
1539                let created_at = u64::try_from(authored_at)
1540                    .map_err(|_| SoftchatError::InvalidAccountOperation)?;
1541                let event = self.identity.with_identity(|identity| {
1542                    identity
1543                        .create_app_data_sync(created_at, AppDataContext::AppSettings, &wire_json)
1544                        .map(|view| view.event)
1545                })?;
1546                database.enqueue_product_signed_with_settings(
1547                    command_id,
1548                    payload_hash,
1549                    vec![event],
1550                    mutation.settings.schema_version,
1551                    mutation.canonical_json.clone(),
1552                    wire_json,
1553                    now,
1554                )?
1555            };
1556            let settings = database.product_settings()?;
1557            let canonical_json = database.product_settings_canonical_json()?;
1558            Ok(AccountSettingsMutation {
1559                settings,
1560                canonical_json,
1561                revision: operation.operation.revision,
1562            })
1563        })();
1564        let mut diagnostic = Diagnostic::new(
1565            AccountLogCategory::Synchronization,
1566            "message.command.failed",
1567            "Account command failed",
1568        );
1569        diagnostic.subject = Some(AccountLogSubject::AppSettings);
1570        diagnostic.source = Some(AccountLogSource::LocalCommand);
1571        self.logs.failed(diagnostic, &result);
1572        result
1573    }
1574
1575    /// Return one effective authenticated/local profile.
1576    ///
1577    /// # Errors
1578    ///
1579    /// Returns a stable key, account, or persistence error.
1580    pub fn profile(&self, public_key: String) -> Result<AccountProfile, SoftchatError> {
1581        self.database()?.product_profile(&public_key)
1582    }
1583
1584    /// Resolve effective authenticated/local profiles for exact keys in input order.
1585    ///
1586    /// At most 512 keys are accepted. Unknown keys return empty profile values
1587    /// carrying their canonical public key, so consumers never need per-row
1588    /// fallback queries or a second profile cache.
1589    ///
1590    /// # Errors
1591    ///
1592    /// Returns a stable key, bound, account, or persistence error.
1593    pub fn profiles(&self, public_keys: Vec<String>) -> Result<Vec<AccountProfile>, SoftchatError> {
1594        self.database()?.product_profiles_for_keys(&public_keys)
1595    }
1596
1597    /// Return one bounded stable profile page.
1598    ///
1599    /// # Errors
1600    ///
1601    /// Returns a stable bound, account, or persistence error.
1602    pub fn profile_page(
1603        &self,
1604        limit: u32,
1605        offset: u32,
1606    ) -> Result<Vec<AccountProfile>, SoftchatError> {
1607        self.database()?.product_profiles(limit, offset)
1608    }
1609
1610    /// Return the effective private follow list authored by this account.
1611    ///
1612    /// # Errors
1613    ///
1614    /// Returns a stable account, contact-list, or persistence error.
1615    pub fn follows(&self) -> Result<Vec<Contact>, SoftchatError> {
1616        let own_public_key = self.public_key()?;
1617        self.database()?.product_follows(&own_public_key)
1618    }
1619
1620    /// Search effective profiles by public key, authenticated metadata, or local label.
1621    /// Matching ignores ASCII case; other Unicode characters remain literal.
1622    ///
1623    /// # Errors
1624    ///
1625    /// Returns a stable search, bound, account, or persistence error.
1626    pub fn search_profiles(
1627        &self,
1628        query: String,
1629        limit: u32,
1630    ) -> Result<Vec<AccountProfile>, SoftchatError> {
1631        self.database()?.search_product_profiles(&query, limit)
1632    }
1633
1634    /// Merge and publish a private profile patch.
1635    ///
1636    /// Missing fields remain unchanged, JSON `null` clears a field, and a
1637    /// concrete value replaces it. Authenticated fields unknown to this SDK
1638    /// version are retained automatically.
1639    ///
1640    /// # Errors
1641    ///
1642    /// Returns a stable command, profile, recipient, relay, or persistence error.
1643    pub fn update_profile(
1644        &self,
1645        command_id: String,
1646        patch_json: String,
1647        now: i64,
1648    ) -> Result<ProductOperationResult, SoftchatError> {
1649        let result = (|| {
1650            validate_command_id(&command_id)?;
1651            let own_public_key = self.public_key()?;
1652            let mut database = self.database()?;
1653            let current = database.authenticated_product_profile(&own_public_key)?;
1654            let authored_at = if current.updated_at > 0 && current.updated_at >= now {
1655                current
1656                    .updated_at
1657                    .checked_add(1)
1658                    .ok_or(SoftchatError::InvalidAccountOperation)?
1659            } else {
1660                now
1661            };
1662            let created_at =
1663                u64::try_from(authored_at).map_err(|_| SoftchatError::InvalidAccountOperation)?;
1664            let (profile, canonical_patch) = profile_update_from_patch(&current, &patch_json)?;
1665            let payload_hash = command_hash(&("profile_patch", canonical_patch))?;
1666            let rumor = self.identity.with_identity(|identity| {
1667                identity
1668                    .create_user_metadata(
1669                        created_at,
1670                        profile.name,
1671                        profile.display_name,
1672                        profile.about,
1673                        profile.picture,
1674                        profile.website,
1675                        profile.banner,
1676                        profile.bot,
1677                        &profile.unknown_json,
1678                    )
1679                    .map(|view| view.rumor)
1680            })?;
1681            let recipients = all_known_public_keys(&database)?
1682                .into_iter()
1683                .filter(|key| key != &own_public_key)
1684                .collect::<Vec<_>>();
1685            let operation = self.identity.with_identity(|identity| {
1686                database.enqueue_product_rumor(
1687                    identity,
1688                    command_id,
1689                    payload_hash,
1690                    String::new(),
1691                    rumor,
1692                    recipients,
1693                    Vec::new(),
1694                    Nip59EnvelopeKind::Durable,
1695                    now,
1696                    None,
1697                )
1698            })?;
1699            stored_product_result(self, operation)
1700        })();
1701        let mut diagnostic = Diagnostic::new(
1702            AccountLogCategory::Profile,
1703            "message.command.failed",
1704            "Account command failed",
1705        );
1706        diagnostic.subject = Some(AccountLogSubject::UserMetadata);
1707        diagnostic.source = Some(AccountLogSource::LocalCommand);
1708        self.logs.failed(diagnostic, &result);
1709        result
1710    }
1711
1712    /// Publish a complete private follow-list replacement.
1713    ///
1714    /// # Errors
1715    ///
1716    /// Returns a stable command, contact, relay, or persistence error.
1717    pub fn replace_follows(
1718        &self,
1719        command_id: String,
1720        contacts: Vec<Contact>,
1721        now: i64,
1722    ) -> Result<ProductOperationResult, SoftchatError> {
1723        let result = (|| {
1724            let mut database = self.database()?;
1725            let operation =
1726                self.enqueue_follow_replacement(&mut database, command_id, contacts, now)?;
1727            drop(database);
1728            stored_product_result(self, operation)
1729        })();
1730        self.logs.failed(
1731            Diagnostic::new(
1732                AccountLogCategory::Profile,
1733                "profile.follows.replace",
1734                "Follow list command failed",
1735            ),
1736            &result,
1737        );
1738        result
1739    }
1740
1741    /// Add or replace one entry in the private follow list atomically.
1742    ///
1743    /// Rust reads the current authenticated list, applies the keyed update,
1744    /// authors its self-copy, and commits the outgoing operation while holding
1745    /// one account-database lock. Concurrent callers cannot lose updates.
1746    ///
1747    /// # Errors
1748    ///
1749    /// Returns a stable command, contact, relay, or persistence error.
1750    pub fn follow(
1751        &self,
1752        command_id: String,
1753        contact: Contact,
1754        now: i64,
1755    ) -> Result<ProductOperationResult, SoftchatError> {
1756        let result = (|| {
1757            let own_public_key = self.public_key()?;
1758            let mut database = self.database()?;
1759            let mut contacts = database.product_follows(&own_public_key)?;
1760            contacts.retain(|value| value.public_key != contact.public_key);
1761            contacts.push(contact);
1762            contacts.sort_by(|left, right| left.public_key.cmp(&right.public_key));
1763            let operation =
1764                self.enqueue_follow_replacement(&mut database, command_id, contacts, now)?;
1765            drop(database);
1766            stored_product_result(self, operation)
1767        })();
1768        self.logs.failed(
1769            Diagnostic::new(
1770                AccountLogCategory::Profile,
1771                "profile.follow",
1772                "Follow list command failed",
1773            ),
1774            &result,
1775        );
1776        result
1777    }
1778
1779    /// Remove one public key from the private follow list atomically.
1780    ///
1781    /// Removing an absent key is a valid idempotent replacement. Reusing the
1782    /// same command ID with a different resulting list is still rejected.
1783    ///
1784    /// # Errors
1785    ///
1786    /// Returns a stable command, key, relay, or persistence error.
1787    pub fn unfollow(
1788        &self,
1789        command_id: String,
1790        public_key: String,
1791        now: i64,
1792    ) -> Result<ProductOperationResult, SoftchatError> {
1793        let result = (|| {
1794            NostrPublicKey::from_hex(&public_key)?;
1795            let own_public_key = self.public_key()?;
1796            let mut database = self.database()?;
1797            let mut contacts = database.product_follows(&own_public_key)?;
1798            contacts.retain(|value| value.public_key != public_key);
1799            let operation =
1800                self.enqueue_follow_replacement(&mut database, command_id, contacts, now)?;
1801            drop(database);
1802            stored_product_result(self, operation)
1803        })();
1804        self.logs.failed(
1805            Diagnostic::new(
1806                AccountLogCategory::Profile,
1807                "profile.unfollow",
1808                "Follow list command failed",
1809            ),
1810            &result,
1811        );
1812        result
1813    }
1814
1815    /// Register an FCM or APNs token through its released NIP-78 wire profile.
1816    ///
1817    /// # Errors
1818    ///
1819    /// Returns a stable command, token, relay, signing, or persistence error.
1820    pub fn register_push_token(
1821        &self,
1822        command_id: String,
1823        platform: PushPlatform,
1824        token: String,
1825        installation_id: String,
1826        now: i64,
1827    ) -> Result<ProductOperationResult, SoftchatError> {
1828        let result = (|| {
1829            validate_command_id(&command_id)?;
1830            if token.is_empty()
1831                || token.len() > 16 * 1024
1832                || installation_id.is_empty()
1833                || installation_id.len() > 512
1834            {
1835                return Err(SoftchatError::InvalidAccountOperation);
1836            }
1837            let (identifier, token_key) = match platform {
1838                PushPlatform::Fcm => ("fcmToken-v2", "fcmToken"),
1839                PushPlatform::Apns => ("apnsToken-v2", "apnsToken"),
1840            };
1841            let content = serde_json::to_string(&serde_json::json!({
1842                (token_key): token,
1843                "deviceIdentifier": installation_id,
1844            }))
1845            .map_err(|_| SoftchatError::InternalFailure)?;
1846            let payload_hash = command_hash(&("push", platform, &content))?;
1847            let created_at =
1848                u64::try_from(now).map_err(|_| SoftchatError::InvalidAccountOperation)?;
1849            let event = self.identity.with_identity(|identity| {
1850                identity
1851                    .create_application_data(created_at, identifier.to_owned(), content)
1852                    .map(|view| view.event)
1853            })?;
1854            let operation = self.database()?.enqueue_product_signed(
1855                command_id,
1856                payload_hash,
1857                String::new(),
1858                vec![event],
1859                now,
1860                AccountLogSubject::PushRegistration,
1861            )?;
1862            stored_product_result(self, operation)
1863        })();
1864        let mut diagnostic = Diagnostic::new(
1865            AccountLogCategory::Lifecycle,
1866            "message.command.failed",
1867            "Account command failed",
1868        );
1869        diagnostic.subject = Some(AccountLogSubject::PushRegistration);
1870        diagnostic.source = Some(AccountLogSource::LocalCommand);
1871        self.logs.failed(diagnostic, &result);
1872        result
1873    }
1874
1875    /// Return a bounded account or conversation media gallery.
1876    ///
1877    /// `limit` is a soft attachment count from 1 through 100. The final
1878    /// message's attachment group is returned in full so a message cursor
1879    /// cannot skip its remainder. At most `limit + 31` items are returned.
1880    /// Deleted messages are excluded.
1881    ///
1882    /// # Errors
1883    ///
1884    /// Returns a stable cursor, bound, account, or persistence error.
1885    pub fn media_gallery(
1886        &self,
1887        conversation_id: String,
1888        before: Option<MessageCursor>,
1889        limit: u32,
1890    ) -> Result<Vec<AccountMediaItem>, SoftchatError> {
1891        self.database()?.product_media_gallery(
1892            (!conversation_id.is_empty()).then_some(conversation_id),
1893            before,
1894            limit,
1895        )
1896    }
1897
1898    /// Persist one attachment-dependent message before Android starts file or HTTP work.
1899    ///
1900    /// The host persists its exact portable composer snapshot before passing it
1901    /// in `draft_to_consume`. When present,
1902    /// preparation clears it in the same transaction only while the stored
1903    /// semantic draft still matches. A newer autosave and every idempotent
1904    /// replay are preserved.
1905    ///
1906    /// # Errors
1907    ///
1908    /// Returns a stable command, conversation, content, media, or persistence error.
1909    #[allow(clippy::too_many_arguments)]
1910    pub fn prepare_media_message(
1911        &self,
1912        command_id: String,
1913        conversation_id: String,
1914        content: MessageContentInput,
1915        reply_to_message_id: String,
1916        sources: Vec<crate::MediaPreparationInput>,
1917        draft_to_consume: Option<AccountDraft>,
1918        now: i64,
1919    ) -> Result<crate::PendingMediaMessage, SoftchatError> {
1920        let result = (|| {
1921            validate_command_id(&command_id)?;
1922            if sources.is_empty() || sources.len() > crate::MAX_PRODUCT_DRAFT_ATTACHMENTS {
1923                return Err(SoftchatError::InvalidMediaOperation);
1924            }
1925            content
1926                .validate_bounds()
1927                .map_err(|_| SoftchatError::InvalidMediaOperation)?;
1928            if !content.attachments.is_empty() {
1929                return Err(SoftchatError::InvalidMediaOperation);
1930            }
1931            let probe_content = MessageContentInput {
1932                text: content.text.clone(),
1933                attachments: crate::account_product::reserved_product_attachments(sources.len())?,
1934                emoji_tags: content.emoji_tags.clone(),
1935            };
1936            author_product_chat_message(
1937                self,
1938                &conversation_id,
1939                probe_content,
1940                &reply_to_message_id,
1941                now,
1942            )
1943            .map_err(|_| SoftchatError::InvalidMediaOperation)?;
1944            let draft_match = draft_to_consume
1945                .map(product_draft_match)
1946                .transpose()
1947                .map_err(|_| SoftchatError::InvalidMediaOperation)?;
1948            let payload_hash = media_message_command_hash(
1949                &conversation_id,
1950                &content,
1951                &reply_to_message_id,
1952                &sources,
1953                draft_match.as_ref(),
1954            )?;
1955            self.database()?.prepare_product_media_message(
1956                crate::account_product::ProductMediaMessagePreparation {
1957                    command_id: &command_id,
1958                    conversation_id: &conversation_id,
1959                    content: &content,
1960                    reply_to_message_id: &reply_to_message_id,
1961                    sources,
1962                    command_hash: &payload_hash,
1963                    draft_match,
1964                    now,
1965                },
1966            )
1967        })();
1968        let mut diagnostic = Diagnostic::new(
1969            AccountLogCategory::Media,
1970            "media.command.failed",
1971            "Media command failed",
1972        );
1973        diagnostic.source = Some(AccountLogSource::LocalCommand);
1974        self.logs.failed(diagnostic, &result);
1975        result
1976    }
1977
1978    /// Return one durable attachment-dependent message intent.
1979    ///
1980    /// # Errors
1981    ///
1982    /// Returns a stable command, media, or persistence error.
1983    pub fn pending_media_message(
1984        &self,
1985        command_id: String,
1986    ) -> Result<Option<crate::PendingMediaMessage>, SoftchatError> {
1987        self.database()?.product_pending_media_message(&command_id)
1988    }
1989
1990    /// Cancel one unpublished attachment-dependent message and its unfinished transfers.
1991    ///
1992    /// Cancellation is idempotent and may retire a permanently failed message.
1993    /// Completed attachment operations remain immutable, while every unfinished
1994    /// dependency loses its lease and becomes cancelled in the same transaction.
1995    ///
1996    /// # Errors
1997    ///
1998    /// Returns a stable command, media, timestamp, account, or persistence error.
1999    pub fn cancel_pending_media_message(
2000        &self,
2001        command_id: String,
2002        now: i64,
2003    ) -> Result<crate::PendingMediaMessage, SoftchatError> {
2004        self.database()?
2005            .cancel_product_pending_media_message(&command_id, now)
2006    }
2007
2008    /// Return a bounded newest-update-first page of attachment-dependent messages.
2009    ///
2010    /// # Errors
2011    ///
2012    /// Returns a stable bound, media, or persistence error.
2013    pub fn pending_media_messages(
2014        &self,
2015        limit: u32,
2016    ) -> Result<Vec<crate::PendingMediaMessage>, SoftchatError> {
2017        self.database()?.product_pending_media_messages(limit)
2018    }
2019
2020    /// Persist one public asset replacement before Android starts file or HTTP work.
2021    ///
2022    /// The upload is intentionally public because profile and subject icon
2023    /// readers receive only a URL. Rust retains the dependent profile or
2024    /// subject command and completes it idempotently after the upload.
2025    ///
2026    /// [Guide](https://softchat-sdk.jackl.dev/android-account-runtime/#durable-public-asset-replacement)
2027    ///
2028    /// # Errors
2029    ///
2030    /// Returns a stable command, target, conversation, media, or persistence error.
2031    pub fn prepare_asset_replacement(
2032        &self,
2033        command_id: String,
2034        target: AssetReplacementTarget,
2035        conversation_id: String,
2036        source_fingerprint: String,
2037        now: i64,
2038    ) -> Result<PendingAssetReplacement, SoftchatError> {
2039        let result = (|| {
2040            validate_command_id(&command_id)?;
2041            let payload_hash = command_hash(&(
2042                "asset-replacement",
2043                target,
2044                &conversation_id,
2045                &source_fingerprint,
2046            ))?;
2047            self.database()?.prepare_product_asset_replacement(
2048                crate::account_product::ProductAssetReplacementPreparation {
2049                    command_id: &command_id,
2050                    target,
2051                    conversation_id: &conversation_id,
2052                    source_fingerprint: &source_fingerprint,
2053                    command_hash: &payload_hash,
2054                    now,
2055                },
2056            )
2057        })();
2058        let mut diagnostic = Diagnostic::new(
2059            AccountLogCategory::Media,
2060            "media.command.failed",
2061            "Media command failed",
2062        );
2063        diagnostic.source = Some(AccountLogSource::LocalCommand);
2064        self.logs.failed(diagnostic, &result);
2065        result
2066    }
2067
2068    /// Return one durable upload-dependent asset replacement.
2069    ///
2070    /// [Guide](https://softchat-sdk.jackl.dev/android-account-runtime/#durable-public-asset-replacement)
2071    ///
2072    /// # Errors
2073    ///
2074    /// Returns a stable command, media, or persistence error.
2075    pub fn pending_asset_replacement(
2076        &self,
2077        command_id: String,
2078    ) -> Result<Option<PendingAssetReplacement>, SoftchatError> {
2079        self.database()?
2080            .product_pending_asset_replacement(&command_id)
2081    }
2082
2083    /// Return a bounded newest-update-first page of asset replacements.
2084    ///
2085    /// [Guide](https://softchat-sdk.jackl.dev/android-account-runtime/#durable-public-asset-replacement)
2086    ///
2087    /// # Errors
2088    ///
2089    /// Returns a stable bound, media, or persistence error.
2090    pub fn pending_asset_replacements(
2091        &self,
2092        limit: u32,
2093    ) -> Result<Vec<PendingAssetReplacement>, SoftchatError> {
2094        self.database()?.product_pending_asset_replacements(limit)
2095    }
2096
2097    /// Prepare one idempotent logical media operation.
2098    ///
2099    /// # Errors
2100    ///
2101    /// Returns a stable command, media, conversation, or persistence error.
2102    pub fn prepare_media_operation(
2103        &self,
2104        command_id: String,
2105        conversation_id: String,
2106        direction: String,
2107        source_fingerprint: String,
2108        now: i64,
2109    ) -> Result<AccountMediaOperation, SoftchatError> {
2110        let result = (|| {
2111            self.database()?.prepare_product_media(
2112                &command_id,
2113                (!conversation_id.is_empty()).then_some(conversation_id),
2114                &direction,
2115                &source_fingerprint,
2116                now,
2117            )
2118        })();
2119        let mut diagnostic = Diagnostic::new(
2120            AccountLogCategory::Media,
2121            "media.command.failed",
2122            "Media command failed",
2123        );
2124        diagnostic.source = Some(AccountLogSource::LocalCommand);
2125        self.logs.failed(diagnostic, &result);
2126        result
2127    }
2128
2129    /// Prepare or return the stable download for one authenticated attachment.
2130    ///
2131    /// Rust resolves the message, validates the attachment URL, and derives
2132    /// the idempotency key and source fingerprint. Android receives only the
2133    /// logical operation and performs the file/HTTP effect.
2134    ///
2135    /// # Errors
2136    ///
2137    /// Returns a stable message, media, or persistence error.
2138    pub fn prepare_media_download(
2139        &self,
2140        message_id: String,
2141        attachment_url: String,
2142        now: i64,
2143    ) -> Result<AccountMediaOperation, SoftchatError> {
2144        let result = (|| {
2145            self.database()?
2146                .prepare_product_media_download(&message_id, &attachment_url, now)
2147        })();
2148        let mut diagnostic = Diagnostic::new(
2149            AccountLogCategory::Media,
2150            "media.command.failed",
2151            "Media command failed",
2152        );
2153        diagnostic.source = Some(AccountLogSource::LocalCommand);
2154        self.logs.failed(diagnostic, &result);
2155        result
2156    }
2157
2158    /// Prepare or return the stable download for the current authenticated conversation icon.
2159    ///
2160    /// Rust resolves the effective subject event and retains its complete `imeta` integrity
2161    /// metadata. Public icons require the downloaded-object SHA-256 (`x`); encrypted icons may
2162    /// rely on final AEAD authentication. The operation is bound to that exact subject version,
2163    /// so completion is rejected only when a later authenticated subject
2164    /// explicitly changes the icon. Text- or emoji-only subjects inherit it.
2165    ///
2166    /// # Errors
2167    ///
2168    /// Returns a stable conversation, media, or persistence error.
2169    pub fn prepare_conversation_icon_download(
2170        &self,
2171        conversation_id: String,
2172        now: i64,
2173    ) -> Result<AccountMediaOperation, SoftchatError> {
2174        let result = (|| {
2175            self.database()?
2176                .prepare_product_conversation_icon_download(&conversation_id, now)
2177        })();
2178        let mut diagnostic = Diagnostic::new(
2179            AccountLogCategory::Media,
2180            "media.command.failed",
2181            "Media command failed",
2182        );
2183        diagnostic.source = Some(AccountLogSource::LocalCommand);
2184        self.logs.failed(diagnostic, &result);
2185        result
2186    }
2187
2188    /// Restart an authenticated download after cache eviction or user retry.
2189    ///
2190    /// Completed uploads and non-download operations are rejected. Running,
2191    /// prepared, and retryable downloads are returned without modification.
2192    /// A terminal conversation-icon download whose authenticated source was
2193    /// superseded remains terminal; callers prepare the current icon instead.
2194    ///
2195    /// # Errors
2196    ///
2197    /// Returns a stable media or persistence error.
2198    pub fn restart_media_download(
2199        &self,
2200        operation_id: String,
2201        now: i64,
2202    ) -> Result<AccountMediaOperation, SoftchatError> {
2203        let result = (|| {
2204            self.database()?
2205                .restart_product_media_download(&operation_id, now)
2206        })();
2207        let mut diagnostic = Diagnostic::new(
2208            AccountLogCategory::Media,
2209            "media.command.failed",
2210            "Media command failed",
2211        );
2212        diagnostic.source = Some(AccountLogSource::LocalCommand);
2213        self.logs.failed(diagnostic, &result);
2214        result
2215    }
2216
2217    /// Commit final authenticated attachment metadata.
2218    ///
2219    /// An exact retry of an already committed completion returns the persisted
2220    /// result before freshness checks. Freshness gates only the first running
2221    /// conversation-icon transition, so a lost success response remains safe
2222    /// to retry after a later subject changes the icon.
2223    ///
2224    /// # Errors
2225    ///
2226    /// Returns [`SoftchatError::InvalidAttachmentMetadata`] when `attachment`
2227    /// is malformed, [`SoftchatError::InvalidMediaOperation`] when the
2228    /// operation or lease is stale, or a stable persistence error.
2229    pub fn complete_media_operation(
2230        &self,
2231        operation_id: String,
2232        lease_id: String,
2233        attachment: AttachmentMetadata,
2234        byte_count: u64,
2235        now: i64,
2236    ) -> Result<crate::MediaCompletionResult, SoftchatError> {
2237        let completion = self.database()?.complete_product_media(
2238            &operation_id,
2239            &lease_id,
2240            attachment,
2241            byte_count,
2242            now,
2243        );
2244        self.logs.failed(
2245            Diagnostic::new(
2246                AccountLogCategory::Media,
2247                "media.completion.failed",
2248                "Media completion rejected",
2249            ),
2250            &completion,
2251        );
2252        let operation = completion?;
2253        let publication = (|| {
2254            let pending_message_command = self
2255                .database()?
2256                .pending_media_command_for_operation(&operation_id)?;
2257            let pending_asset_command = self
2258                .database()?
2259                .pending_asset_command_for_operation(&operation_id)?;
2260            let completed_operation = pending_message_command
2261                .map(|command_id| self.finalize_pending_media_message(&command_id, now))
2262                .transpose()?
2263                .flatten()
2264                .or(pending_asset_command
2265                    .map(|command_id| self.finalize_pending_asset_replacement(&command_id, now))
2266                    .transpose()?
2267                    .flatten());
2268            Ok(completed_operation)
2269        })();
2270        self.logs.failed(
2271            Diagnostic::new(
2272                AccountLogCategory::Media,
2273                "media.publication.deferred",
2274                "Media committed; dependent publication deferred",
2275            ),
2276            &publication,
2277        );
2278        let completed_operation = publication?;
2279        Ok(crate::MediaCompletionResult {
2280            operation,
2281            completed_operation,
2282        })
2283    }
2284
2285    /// Recover every ready attachment-dependent message after process death.
2286    ///
2287    /// # Errors
2288    ///
2289    /// Returns a stable media, messaging, relay, or persistence error.
2290    pub fn recover_pending_media_messages(
2291        &self,
2292        limit: u32,
2293        now: i64,
2294    ) -> Result<Vec<ProductOperationResult>, SoftchatError> {
2295        let result = (|| {
2296            let commands = self.database()?.ready_pending_media_commands(limit)?;
2297            if !commands.is_empty() {
2298                let mut event = Diagnostic::new(
2299                    AccountLogCategory::Recovery,
2300                    "recovery.started",
2301                    "Pending publication recovery started",
2302                );
2303                event.source = Some(AccountLogSource::Recovery);
2304                event.summarize = true;
2305                self.logs.emit(event);
2306            }
2307            let mut published = Vec::new();
2308            for command_id in commands {
2309                if let Some(result) = self.finalize_pending_media_message(&command_id, now)? {
2310                    let mut diagnostic = Diagnostic::new(
2311                        AccountLogCategory::Recovery,
2312                        "recovery.completed",
2313                        "Pending publication recovered",
2314                    )
2315                    .count(
2316                        crate::account_diagnostics::AccountLogCounterKind::RecoveredItems,
2317                        1,
2318                    );
2319                    diagnostic.source = Some(AccountLogSource::Recovery);
2320                    diagnostic.summarize = true;
2321                    self.logs.emit(diagnostic);
2322                    published.push(result);
2323                }
2324            }
2325            Ok(published)
2326        })();
2327        let mut event = Diagnostic::new(
2328            AccountLogCategory::Recovery,
2329            "recovery.failed",
2330            "Pending publication recovery failed",
2331        );
2332        event.source = Some(AccountLogSource::Recovery);
2333        self.logs.failed(event, &result);
2334        result
2335    }
2336
2337    /// Recover every ready upload-dependent asset replacement after process death.
2338    ///
2339    /// [Guide](https://softchat-sdk.jackl.dev/android-account-runtime/#durable-public-asset-replacement)
2340    ///
2341    /// # Errors
2342    ///
2343    /// Returns a stable media, profile, subject, relay, or persistence error.
2344    pub fn recover_pending_asset_replacements(
2345        &self,
2346        limit: u32,
2347        now: i64,
2348    ) -> Result<Vec<ProductOperationResult>, SoftchatError> {
2349        let result = (|| {
2350            let commands = self.database()?.ready_pending_asset_commands(limit)?;
2351            if !commands.is_empty() {
2352                let mut event = Diagnostic::new(
2353                    AccountLogCategory::Recovery,
2354                    "recovery.started",
2355                    "Pending publication recovery started",
2356                );
2357                event.source = Some(AccountLogSource::Recovery);
2358                event.summarize = true;
2359                self.logs.emit(event);
2360            }
2361            let mut published = Vec::new();
2362            for command_id in commands {
2363                if let Some(result) = self.finalize_pending_asset_replacement(&command_id, now)? {
2364                    let mut diagnostic = Diagnostic::new(
2365                        AccountLogCategory::Recovery,
2366                        "recovery.completed",
2367                        "Pending publication recovered",
2368                    )
2369                    .count(
2370                        crate::account_diagnostics::AccountLogCounterKind::RecoveredItems,
2371                        1,
2372                    );
2373                    diagnostic.source = Some(AccountLogSource::Recovery);
2374                    diagnostic.summarize = true;
2375                    self.logs.emit(diagnostic);
2376                    published.push(result);
2377                }
2378            }
2379            Ok(published)
2380        })();
2381        let mut event = Diagnostic::new(
2382            AccountLogCategory::Recovery,
2383            "recovery.failed",
2384            "Pending publication recovery failed",
2385        );
2386        event.source = Some(AccountLogSource::Recovery);
2387        self.logs.failed(event, &result);
2388        result
2389    }
2390
2391    /// Claim one exact recoverable media operation with a bounded lease.
2392    ///
2393    /// Expired leases are recovered transactionally before selection.
2394    ///
2395    /// # Errors
2396    ///
2397    /// Returns a stable media-operation or persistence error.
2398    pub fn claim_media_operation(
2399        &self,
2400        operation_id: String,
2401        lease_id: String,
2402        now: i64,
2403        lease_duration_seconds: u32,
2404    ) -> Result<Option<AccountMediaLease>, SoftchatError> {
2405        let result = (|| {
2406            self.database()?.claim_product_media(
2407                &operation_id,
2408                &lease_id,
2409                now,
2410                lease_duration_seconds,
2411            )
2412        })();
2413        let mut diagnostic = Diagnostic::new(
2414            AccountLogCategory::Media,
2415            "media.command.failed",
2416            "Media command failed",
2417        );
2418        diagnostic.source = Some(AccountLogSource::LocalCommand);
2419        self.logs.failed(diagnostic, &result);
2420        result
2421    }
2422
2423    /// Finish one matching media lease with a redacted failure category.
2424    ///
2425    /// # Errors
2426    ///
2427    /// Returns a stable media-operation or persistence error.
2428    pub fn finish_media_operation_lease(
2429        &self,
2430        operation_id: String,
2431        lease_id: String,
2432        retryable: bool,
2433        error_category: String,
2434        now: i64,
2435    ) -> Result<AccountMediaOperation, SoftchatError> {
2436        let result = (|| {
2437            self.database()?.finish_product_media_lease(
2438                &operation_id,
2439                &lease_id,
2440                retryable,
2441                &error_category,
2442                now,
2443            )
2444        })();
2445        let mut diagnostic = Diagnostic::new(
2446            AccountLogCategory::Media,
2447            "media.command.failed",
2448            "Media command failed",
2449        );
2450        diagnostic.source = Some(AccountLogSource::LocalCommand);
2451        self.logs.failed(diagnostic, &result);
2452        result
2453    }
2454
2455    /// Create a signed NIP-98 upload plan from a streaming SHA-256.
2456    ///
2457    /// Android executes the exact HTTP request and must mint a new plan after
2458    /// any redirect.
2459    ///
2460    /// # Errors
2461    ///
2462    /// Returns a stable HTTP, hash, timestamp, or account lifecycle error.
2463    pub fn create_media_upload_authorization(
2464        &self,
2465        method: String,
2466        url: String,
2467        payload_sha256: String,
2468        now: i64,
2469    ) -> Result<FfiHttpAuthorizationPlan, SoftchatError> {
2470        let result = (|| {
2471            let created_at =
2472                u64::try_from(now).map_err(|_| SoftchatError::InvalidHttpAuthorization)?;
2473            self.identity.with_identity(|identity| {
2474                crate::create_nip98_authorization_for_payload_hash(
2475                    identity,
2476                    &method,
2477                    &url,
2478                    &payload_sha256,
2479                    created_at,
2480                )
2481                .map(FfiHttpAuthorizationPlan::from)
2482            })
2483        })();
2484        let mut diagnostic = Diagnostic::new(
2485            AccountLogCategory::Media,
2486            "media.authorization.failed",
2487            "Upload authorization failed",
2488        );
2489        diagnostic.source = Some(AccountLogSource::LocalCommand);
2490        if result.is_ok() {
2491            self.logs.emit(
2492                Diagnostic::new(
2493                    AccountLogCategory::Media,
2494                    "media.authorization.prepared",
2495                    "Upload authorization prepared",
2496                )
2497                .debug(),
2498            );
2499        }
2500        self.logs.failed(diagnostic, &result);
2501        result
2502    }
2503
2504    /// Apply one retry/failure/cancel transition to logical media state.
2505    ///
2506    /// # Errors
2507    ///
2508    /// Returns a stable media transition or persistence error.
2509    pub fn transition_media_operation(
2510        &self,
2511        operation_id: String,
2512        transition: String,
2513        error_category: String,
2514        now: i64,
2515    ) -> Result<AccountMediaOperation, SoftchatError> {
2516        let result = (|| {
2517            self.database()?.transition_product_media(
2518                &operation_id,
2519                &transition,
2520                &error_category,
2521                now,
2522            )
2523        })();
2524        let mut diagnostic = Diagnostic::new(
2525            AccountLogCategory::Media,
2526            "media.command.failed",
2527            "Media command failed",
2528        );
2529        diagnostic.source = Some(AccountLogSource::LocalCommand);
2530        self.logs.failed(diagnostic, &result);
2531        result
2532    }
2533
2534    /// Return existing downloads for the current attachments of one live message, in attachment order.
2535    ///
2536    /// This read creates no operation, changes no revision and schedules no work. Unknown or deleted
2537    /// messages return an empty result; unrelated recent transfers cannot hide a cached attachment.
2538    ///
2539    /// # Errors
2540    ///
2541    /// Returns a stable ID, account, or persistence error.
2542    pub fn message_downloads(
2543        &self,
2544        message_id: String,
2545    ) -> Result<Vec<AccountMediaOperation>, SoftchatError> {
2546        self.database()?.product_message_downloads(&message_id)
2547    }
2548
2549    /// Return a bounded newest-update-first media-operation page.
2550    ///
2551    /// # Errors
2552    ///
2553    /// Returns a stable bound or persistence error.
2554    pub fn media_operations(
2555        &self,
2556        limit: u32,
2557    ) -> Result<Vec<AccountMediaOperation>, SoftchatError> {
2558        self.database()?.product_media_operations(limit)
2559    }
2560
2561    /// Return existing logical media operations for one bounded exact ID set.
2562    ///
2563    /// Results preserve caller order. Missing IDs are omitted, while duplicate,
2564    /// malformed, or oversized input fails before any row is returned.
2565    ///
2566    /// # Errors
2567    ///
2568    /// Returns a stable bound, media-operation, or persistence error.
2569    pub fn media_operations_by_ids(
2570        &self,
2571        operation_ids: Vec<String>,
2572    ) -> Result<Vec<AccountMediaOperation>, SoftchatError> {
2573        self.database()?
2574            .product_media_operations_by_ids(&operation_ids)
2575    }
2576
2577    /// Return one stable ID-ordered page of non-terminal media operations.
2578    ///
2579    /// Platform executors must follow `next_cursor` to exhaustion during startup recovery instead
2580    /// of assuming that a newest-first UI page contains every durable operation.
2581    ///
2582    /// # Errors
2583    ///
2584    /// Returns a stable cursor, bound, media-operation, or persistence error.
2585    pub fn recoverable_media_operations(
2586        &self,
2587        cursor: Option<String>,
2588        limit: u32,
2589    ) -> Result<AccountMediaOperationPage, SoftchatError> {
2590        self.database()?
2591            .product_recoverable_media_operations(cursor.as_deref(), limit)
2592    }
2593
2594    /// Return one exact logical media operation.
2595    ///
2596    /// # Errors
2597    ///
2598    /// Returns a stable media-operation or persistence error.
2599    pub fn media_operation(
2600        &self,
2601        operation_id: String,
2602    ) -> Result<Option<AccountMediaOperation>, SoftchatError> {
2603        self.database()?.product_media_operation(&operation_id)
2604    }
2605
2606    fn finalize_pending_media_message(
2607        &self,
2608        command_id: &str,
2609        now: i64,
2610    ) -> Result<Option<ProductOperationResult>, SoftchatError> {
2611        if !self
2612            .database()?
2613            .claim_pending_media_publication(command_id)?
2614        {
2615            return Ok(None);
2616        }
2617        let pending = self
2618            .database()?
2619            .product_pending_media_message(command_id)?
2620            .ok_or(SoftchatError::InvalidMediaOperation)?;
2621        match pending.state {
2622            crate::PendingMediaMessageState::Published
2623            | crate::PendingMediaMessageState::WaitingForUploads
2624            | crate::PendingMediaMessageState::Failed
2625            | crate::PendingMediaMessageState::Cancelled => return Ok(None),
2626            crate::PendingMediaMessageState::ReadyToPublish => {}
2627        }
2628        let attachments = pending
2629            .attachments
2630            .iter()
2631            .map(|operation| {
2632                if operation.state != crate::AccountMediaOperationState::Completed {
2633                    return Err(SoftchatError::InvalidMediaOperation);
2634                }
2635                operation
2636                    .attachment
2637                    .clone()
2638                    .ok_or(SoftchatError::InvalidMediaOperation)
2639            })
2640            .collect::<Result<Vec<_>, _>>()?;
2641        let result = self.send_message(
2642            pending.command_id.clone(),
2643            pending.conversation_id,
2644            MessageContentInput {
2645                text: pending.text,
2646                attachments,
2647                emoji_tags: pending.emoji_tags,
2648            },
2649            pending.reply_to_message_id,
2650            None,
2651            now,
2652        )?;
2653        let message_id = result
2654            .message
2655            .as_ref()
2656            .map(|message| message.id.clone())
2657            .ok_or(SoftchatError::InvalidPersistenceResult)?;
2658        self.database()?
2659            .mark_pending_media_published(command_id, &message_id, now)?;
2660        Ok(Some(result))
2661    }
2662
2663    fn finalize_pending_asset_replacement(
2664        &self,
2665        command_id: &str,
2666        now: i64,
2667    ) -> Result<Option<ProductOperationResult>, SoftchatError> {
2668        let pending = self
2669            .database()?
2670            .product_pending_asset_replacement(command_id)?
2671            .ok_or(SoftchatError::InvalidMediaOperation)?;
2672        match pending.state {
2673            crate::PendingAssetReplacementState::Published
2674            | crate::PendingAssetReplacementState::WaitingForUpload
2675            | crate::PendingAssetReplacementState::Failed
2676            | crate::PendingAssetReplacementState::Cancelled => return Ok(None),
2677            crate::PendingAssetReplacementState::ReadyToPublish => {}
2678        }
2679        let attachment = pending
2680            .operation
2681            .attachment
2682            .clone()
2683            .filter(|_| {
2684                pending.operation.state == crate::AccountMediaOperationState::Completed
2685                    && pending.operation.protection == crate::AccountMediaProtection::Public
2686            })
2687            .ok_or(SoftchatError::InvalidMediaOperation)?;
2688        if attachment.encryption.is_some() {
2689            return Err(SoftchatError::InvalidMediaOperation);
2690        }
2691        let result = match pending.target {
2692            AssetReplacementTarget::ProfilePicture => self.update_profile(
2693                pending.command_id.clone(),
2694                serde_json::json!({ "picture": attachment.url }).to_string(),
2695                now,
2696            )?,
2697            AssetReplacementTarget::ProfileBanner => self.update_profile(
2698                pending.command_id.clone(),
2699                serde_json::json!({ "banner": attachment.url }).to_string(),
2700                now,
2701            )?,
2702            AssetReplacementTarget::ConversationIcon => {
2703                let (subject, emoji_tags) = self
2704                    .database()?
2705                    .pending_asset_subject(&pending.command_id)?;
2706                self.set_conversation_subject(
2707                    pending.command_id.clone(),
2708                    pending.conversation_id,
2709                    subject,
2710                    Some(attachment),
2711                    emoji_tags,
2712                    now,
2713                )?
2714            }
2715        };
2716        self.database()?.mark_pending_asset_published(
2717            command_id,
2718            &result.operation.operation_id,
2719            now,
2720        )?;
2721        Ok(Some(result))
2722    }
2723
2724    /// Return schema, revision, and WAL information.
2725    ///
2726    /// # Errors
2727    ///
2728    /// Returns a stable persistence error if metadata cannot be read.
2729    pub fn database_info(&self) -> Result<AccountDatabaseInfo, SoftchatError> {
2730        self.database()?.info()
2731    }
2732
2733    /// Return redacted bounded account health for support and recovery UI.
2734    ///
2735    /// # Errors
2736    ///
2737    /// Returns a stable persistence error.
2738    pub fn account_diagnostics(&self) -> Result<AccountDiagnostics, SoftchatError> {
2739        self.database()?.diagnostics()
2740    }
2741
2742    /// Forget durable synchronization checkpoints while retaining event truth.
2743    ///
2744    /// The active platform transport must be restarted after this call so its
2745    /// next Rust-owned synchronization begins from complete retained history.
2746    ///
2747    /// # Errors
2748    ///
2749    /// Returns a stable sync or persistence error.
2750    pub fn reset_account_sync(&self, now: i64) -> Result<AccountTransportBatch, SoftchatError> {
2751        let result = (|| {
2752            lock(&self.sync_engines).clear();
2753            let mut database = self.database()?;
2754            let reset = database.reset_sync(now)?;
2755            let mut diagnostic = Diagnostic::new(
2756                AccountLogCategory::Synchronization,
2757                "sync.reset",
2758                "Synchronization checkpoint reset",
2759            );
2760            diagnostic.revision = Some(reset.revision);
2761            self.logs.emit(diagnostic);
2762            let mut transport = lock(&self.transport_run);
2763            if let Some(run) = transport.as_mut() {
2764                run.restart_sync(&mut database, &self.identity, now)
2765            } else {
2766                Ok(AccountTransportBatch {
2767                    actions: Vec::new(),
2768                    connection_state: RelayConnectionState::Disconnected,
2769                    idle: true,
2770                    revision: database.info()?.revision,
2771                    typing_indicators: Vec::new(),
2772                    sync: None,
2773                })
2774            }
2775        })();
2776        self.logs.failed(
2777            Diagnostic::new(
2778                AccountLogCategory::Synchronization,
2779                "sync.reset",
2780                "Synchronization reset failed",
2781            ),
2782            &result,
2783        );
2784        result
2785    }
2786
2787    /// Start one correlated Rust-owned account transport run.
2788    ///
2789    /// A host using the low-level boundary executes only the returned WebSocket
2790    /// actions. A second active run must be explicitly cancelled first.
2791    ///
2792    /// # Errors
2793    ///
2794    /// Returns a stable account, relay, filter, or lifecycle error.
2795    pub fn start_account_transport(
2796        &self,
2797        run_id: String,
2798    ) -> Result<AccountTransportBatch, SoftchatError> {
2799        let result = (|| {
2800            let recipient = self.public_key()?;
2801            let filters = vec![inbox_filter_json(&recipient, None, None)?];
2802            let database = self.database()?;
2803            let mut slot = lock(&self.transport_run);
2804            if slot.is_some() {
2805                return Err(SoftchatError::InvalidRelaySession);
2806            }
2807            let (run, batch) = AccountTransportRun::start(run_id, filters, &database)?;
2808            *slot = Some(run);
2809            Ok(batch)
2810        })();
2811        self.logs.failed(
2812            Diagnostic::new(
2813                AccountLogCategory::Relay,
2814                "relay.operation.failed",
2815                "Relay operation failed",
2816            ),
2817            &result,
2818        );
2819        result
2820    }
2821
2822    /// Apply one exact correlated system result idempotently.
2823    ///
2824    /// Repeating the same result returns the originally committed action
2825    /// batch. A conflicting reuse or unknown action fails closed without
2826    /// consuming the active run. If an otherwise valid transition cannot
2827    /// commit, its database work is rolled back and the process-local run is
2828    /// retired; the host must start a fresh run.
2829    ///
2830    /// # Errors
2831    ///
2832    /// Returns a stable system-action, relay, delivery, ingestion, or
2833    /// persistence error.
2834    pub fn apply_account_transport_result(
2835        &self,
2836        transport_result: AccountTransportResult,
2837        now: i64,
2838    ) -> Result<AccountTransportBatch, SoftchatError> {
2839        let result = (|| {
2840            let hash = result_hash(&transport_result)?;
2841            let mut database = self.database()?;
2842            if let Some((stored_hash, outcome_json)) = database
2843                .stored_transport_result(&transport_result.run_id, &transport_result.action_id)?
2844            {
2845                if stored_hash != hash || outcome_json.is_empty() {
2846                    return Err(SoftchatError::InvalidRelaySession);
2847                }
2848                let outcome: StoredAccountTransportOutcome = serde_json::from_str(&outcome_json)
2849                    .map_err(|_| SoftchatError::InvalidPersistenceResult)?;
2850                return Ok(outcome.into_batch());
2851            }
2852            let mut slot = lock(&self.transport_run);
2853            slot.as_ref()
2854                .ok_or(SoftchatError::InvalidRelaySession)?
2855                .validate_result_preflight(&transport_result)?;
2856            let mut candidate = slot.take().ok_or(SoftchatError::InvalidRelaySession)?;
2857            let transition = database.apply_transport_result_atomically(|database| {
2858                let mut batch =
2859                    candidate.handle_result(&transport_result, database, &self.identity, now)?;
2860                let expected_revision = database
2861                    .info()?
2862                    .revision
2863                    .checked_add(1)
2864                    .ok_or(SoftchatError::InvalidPersistenceResult)?;
2865                batch.revision = expected_revision;
2866                let outcome_json =
2867                    serde_json::to_string(&StoredAccountTransportOutcome::from(&batch))
2868                        .map_err(|_| SoftchatError::InternalFailure)?;
2869                let committed_revision = database.record_transport_result(
2870                    &transport_result.run_id,
2871                    &transport_result.action_id,
2872                    transport_result.generation,
2873                    &hash,
2874                    &outcome_json,
2875                )?;
2876                if committed_revision != expected_revision {
2877                    return Err(SoftchatError::InvalidPersistenceResult);
2878                }
2879                Ok(batch)
2880            });
2881            if transition.is_ok() {
2882                *slot = Some(candidate);
2883            }
2884            transition
2885        })();
2886        self.logs.failed(
2887            Diagnostic::new(
2888                AccountLogCategory::Relay,
2889                "relay.operation.failed",
2890                "Relay operation failed",
2891            ),
2892            &result,
2893        );
2894        result
2895    }
2896
2897    /// Recheck durable outgoing work without waiting for the current receive deadline.
2898    ///
2899    /// Android calls this after observing a newer durable account revision.
2900    /// Existing receive actions remain correlated and active; only newly
2901    /// available ordered effects are returned.
2902    ///
2903    /// # Errors
2904    ///
2905    /// Returns a stable lifecycle, delivery, relay, or persistence error.
2906    pub fn wake_account_transport(&self, now: i64) -> Result<AccountTransportBatch, SoftchatError> {
2907        let result = (|| {
2908            let mut database = self.database()?;
2909            let mut slot = lock(&self.transport_run);
2910            let run = slot.as_mut().ok_or(SoftchatError::InvalidRelaySession)?;
2911            run.wake(&mut database, now)
2912        })();
2913        self.logs.failed(
2914            Diagnostic::new(
2915                AccountLogCategory::Relay,
2916                "relay.operation.failed",
2917                "Relay operation failed",
2918            ),
2919            &result,
2920        );
2921        result
2922    }
2923
2924    /// Cancel the active account transport and release unfinished leases.
2925    ///
2926    /// # Errors
2927    ///
2928    /// Returns a stable lifecycle, delivery, or persistence error.
2929    pub fn cancel_account_transport(&self) -> Result<AccountTransportBatch, SoftchatError> {
2930        let result = (|| {
2931            let mut database = self.database()?;
2932            let mut slot = lock(&self.transport_run);
2933            let run = slot.as_mut().ok_or(SoftchatError::InvalidRelaySession)?;
2934            let batch = run.cancel(&mut database)?;
2935            slot.take();
2936            Ok(batch)
2937        })();
2938        self.logs.failed(
2939            Diagnostic::new(
2940                AccountLogCategory::Relay,
2941                "relay.operation.failed",
2942                "Relay operation failed",
2943            ),
2944            &result,
2945        );
2946        result
2947    }
2948
2949    /// Authenticate and atomically persist one bounded incoming batch.
2950    ///
2951    /// # Errors
2952    ///
2953    /// Returns stable event, envelope, projection, account, or persistence
2954    /// errors. No partial transaction is committed.
2955    pub fn ingest(
2956        &self,
2957        events: Vec<SignedEvent>,
2958        received_at: i64,
2959    ) -> Result<StoredIngestion, SoftchatError> {
2960        let result = self.ingest_unlogged(events, received_at);
2961        self.logs.failed(
2962            Diagnostic::new(
2963                AccountLogCategory::Storage,
2964                "storage.ingest",
2965                "Incoming batch rejected",
2966            ),
2967            &result,
2968        );
2969        result
2970    }
2971
2972    /// Parse, authenticate, unwrap, and commit a bounded system event batch.
2973    ///
2974    /// This is the Android push/notification boundary. Product code receives
2975    /// only authored message and conversation values, never raw Nostr types.
2976    ///
2977    /// # Errors
2978    ///
2979    /// Returns a stable event, envelope, account, projection, or persistence
2980    /// error with no partial commit.
2981    pub fn ingest_event_jsons(
2982        &self,
2983        event_jsons: Vec<String>,
2984        received_at: i64,
2985    ) -> Result<StoredIngestion, SoftchatError> {
2986        let preflight = preflight_ingestion_jsons(&event_jsons);
2987        let result = (|| {
2988            preflight?;
2989            let events = event_jsons
2990                .into_iter()
2991                .map(|event_json| {
2992                    SignedNostrEvent::from_json(&event_json).map(|event| SignedEvent::from(&event))
2993                })
2994                .collect::<Result<Vec<_>, _>>()?;
2995            let mut database = self.database()?;
2996            self.identity.with_identity(|identity| {
2997                database.ingest_with_authenticated_replays(identity, events, received_at)
2998            })
2999        })();
3000        self.logs.failed(
3001            Diagnostic::new(
3002                AccountLogCategory::Storage,
3003                "storage.ingest_json",
3004                "Incoming batch rejected",
3005            ),
3006            &result,
3007        );
3008        result
3009    }
3010
3011    /// Create and atomically enqueue one private rumor operation.
3012    ///
3013    /// # Errors
3014    ///
3015    /// Returns stable identity, event, envelope, account, relay, delivery, or
3016    /// persistence errors.
3017    #[allow(clippy::too_many_arguments)]
3018    pub fn enqueue_rumor(
3019        &self,
3020        operation_id: String,
3021        rumor: RumorEvent,
3022        recipients: Vec<String>,
3023        notification_recipients: Vec<String>,
3024        relay_urls: Vec<String>,
3025        kind: Nip59EnvelopeKind,
3026        now: i64,
3027    ) -> Result<AccountOperationResult, SoftchatError> {
3028        let mut database = self.database()?;
3029        self.identity.with_identity(|identity| {
3030            database.enqueue_rumor(
3031                identity,
3032                operation_id,
3033                rumor,
3034                recipients,
3035                notification_recipients,
3036                relay_urls,
3037                kind,
3038                now,
3039            )
3040        })
3041    }
3042
3043    /// Return the effective HTTPS upload endpoint for this account.
3044    ///
3045    /// A validated explicit account setting wins. Otherwise the endpoint is
3046    /// derived from the selected relay host using the released
3047    /// `/api/v2/files` profile. Android does not duplicate this policy.
3048    ///
3049    /// # Errors
3050    ///
3051    /// Returns a stable settings, relay-catalog, or HTTP-authorization error.
3052    pub fn media_upload_url(&self) -> Result<String, SoftchatError> {
3053        let (configured, relay_url) = {
3054            let database = self.database()?;
3055            (
3056                database.product_settings()?.media_service,
3057                database.active_relay_url()?,
3058            )
3059        };
3060        if let Some(configured) = configured {
3061            return Ok(configured);
3062        }
3063        let endpoint = parse_relay_endpoint(&relay_url)?;
3064        let mut url = Url::parse(&endpoint.network_url)
3065            .map_err(|_| SoftchatError::InvalidHttpAuthorization)?;
3066        url.set_scheme("https")
3067            .map_err(|_| SoftchatError::InvalidHttpAuthorization)?;
3068        url.set_path("/api/v2/files");
3069        url.set_query(None);
3070        url.set_fragment(None);
3071        Ok(url.to_string())
3072    }
3073
3074    /// Atomically enqueue already signed direct protocol events.
3075    ///
3076    /// # Errors
3077    ///
3078    /// Returns stable identity, event, account, relay, or persistence errors.
3079    pub fn enqueue_signed(
3080        &self,
3081        operation_id: String,
3082        events: Vec<SignedEvent>,
3083        relay_urls: Vec<String>,
3084        now: i64,
3085    ) -> Result<AccountOperationResult, SoftchatError> {
3086        let mut database = self.database()?;
3087        self.identity.with_identity(|identity| {
3088            database.enqueue_signed(identity, operation_id, events, relay_urls, now)
3089        })
3090    }
3091
3092    /// Verify and recover one bounded JSON array of already signed events.
3093    ///
3094    /// This narrow migration boundary lets a platform extract an opaque batch
3095    /// from retired storage without decoding protocol fields. Every event ID
3096    /// and signature is verified before one idempotent outgoing transaction is
3097    /// committed. The caller retains and owns the retired storage.
3098    ///
3099    /// # Errors
3100    ///
3101    /// Returns stable event, operation, relay, timestamp, or persistence
3102    /// errors. Malformed, empty, oversized, duplicate, or forged batches commit
3103    /// nothing.
3104    pub fn recover_signed_outbox_batch(
3105        &self,
3106        operation_id: String,
3107        event_batch_json: String,
3108        now: i64,
3109    ) -> Result<AccountOperationResult, SoftchatError> {
3110        if event_batch_json.len() > crate::MAX_NOSTR_EVENT_JSON_BYTES {
3111            return Err(SoftchatError::InvalidAccountOperation);
3112        }
3113        let serde_json::Value::Array(raw_events) = crate::json::parse_value(&event_batch_json)
3114            .map_err(|_| SoftchatError::InvalidAccountOperation)?
3115        else {
3116            return Err(SoftchatError::InvalidAccountOperation);
3117        };
3118        if raw_events.is_empty() || raw_events.len() > crate::MAX_OPERATION_EVENT_COPIES {
3119            return Err(SoftchatError::InvalidAccountOperation);
3120        }
3121        let events = raw_events
3122            .into_iter()
3123            .map(|event| {
3124                SignedNostrEvent::from_json(&event.to_string())
3125                    .map(|verified| SignedEvent::from(&verified))
3126            })
3127            .collect::<Result<Vec<_>, _>>()?;
3128        let payload_hash = command_hash(&("signed_outbox_recovery", &events))?;
3129        Ok(self
3130            .database()?
3131            .enqueue_product_signed(
3132                operation_id,
3133                payload_hash,
3134                String::new(),
3135                events,
3136                now,
3137                AccountLogSubject::Unknown,
3138            )?
3139            .operation)
3140    }
3141
3142    /// Atomically claim bounded relay work and return exact payloads.
3143    ///
3144    /// # Errors
3145    ///
3146    /// Returns a stable delivery or persistence error.
3147    pub fn claim_deliveries(
3148        &self,
3149        lease_id: String,
3150        relay_url: String,
3151        now: i64,
3152        lease_duration_seconds: u32,
3153    ) -> Result<Option<ClaimedDelivery>, SoftchatError> {
3154        self.database()?
3155            .claim_deliveries(lease_id, relay_url, now, lease_duration_seconds)
3156    }
3157
3158    /// Persist that selected lease payloads were written to a socket.
3159    ///
3160    /// # Errors
3161    ///
3162    /// Returns a stable delivery or persistence error for stale state.
3163    pub fn mark_socket_written(
3164        &self,
3165        claim: DeliveryClaim,
3166        intent_ids: Vec<String>,
3167    ) -> Result<AccountMutationResult, SoftchatError> {
3168        self.database()?.mark_socket_written(&claim, intent_ids)
3169    }
3170
3171    /// Persist correlated relay outcomes.
3172    ///
3173    /// # Errors
3174    ///
3175    /// Returns a stable delivery or persistence error for stale state.
3176    pub fn apply_relay_results(
3177        &self,
3178        claim: DeliveryClaim,
3179        results: Vec<RelayDeliveryResult>,
3180    ) -> Result<AccountMutationResult, SoftchatError> {
3181        self.database()?.apply_relay_results(&claim, results)
3182    }
3183
3184    /// Release unfinished work after transport loss or cancellation.
3185    ///
3186    /// # Errors
3187    ///
3188    /// Returns a stable delivery or persistence error for stale state.
3189    pub fn release_claim(
3190        &self,
3191        claim: DeliveryClaim,
3192    ) -> Result<AccountMutationResult, SoftchatError> {
3193        self.database()?.release_claim(&claim)
3194    }
3195
3196    /// Return one durable operation's aggregate state.
3197    ///
3198    /// # Errors
3199    ///
3200    /// Returns a stable account or persistence error for unknown state.
3201    pub fn operation(&self, operation_id: String) -> Result<OperationSnapshot, SoftchatError> {
3202        self.database()?.operation(&operation_id)
3203    }
3204
3205    /// Return a bounded newest-first page of durable outgoing operations.
3206    ///
3207    /// # Errors
3208    ///
3209    /// Returns a stable bound, account, or persistence error.
3210    pub fn operation_page(&self, limit: u32) -> Result<Vec<OperationSnapshot>, SoftchatError> {
3211        self.database()?.operations(limit)
3212    }
3213
3214    /// Explicitly retry the rejected copies of one durable operation.
3215    ///
3216    /// # Errors
3217    ///
3218    /// Returns a stable account or delivery-state error.
3219    pub fn retry_operation(
3220        &self,
3221        operation_id: String,
3222    ) -> Result<AccountOperationResult, SoftchatError> {
3223        self.database()?.retry_operation(&operation_id)
3224    }
3225
3226    /// Cancel every unclaimed copy that is still safe to cancel.
3227    ///
3228    /// # Errors
3229    ///
3230    /// Returns a stable account or delivery-state error.
3231    pub fn cancel_operation(
3232        &self,
3233        operation_id: String,
3234    ) -> Result<AccountOperationResult, SoftchatError> {
3235        self.database()?.cancel_operation(&operation_id)
3236    }
3237
3238    /// Return the complete durable relay catalog.
3239    ///
3240    /// # Errors
3241    ///
3242    /// Returns a stable relay-catalog or persistence error.
3243    pub fn relay_catalog(&self) -> Result<Vec<RelayCatalogEntry>, SoftchatError> {
3244        self.database()?.relay_catalog()
3245    }
3246
3247    /// Return the canonical authenticated inbox filter for this account.
3248    ///
3249    /// Android passes the returned JSON directly to [`crate::RelaySession`].
3250    /// The filter accepts durable and ephemeral NIP-59 gift wraps addressed to
3251    /// the account. An optional lower bound supports live subscriptions without
3252    /// making Android construct or validate protocol JSON.
3253    ///
3254    /// # Errors
3255    ///
3256    /// Returns a stable filter or timestamp error.
3257    pub fn inbox_filters(
3258        &self,
3259        since_timestamp: Option<i64>,
3260    ) -> Result<Vec<String>, SoftchatError> {
3261        self.ensure_open()?;
3262        let recipient_public_key = self
3263            .identity
3264            .with_identity(|identity| Ok(identity.public_key().to_hex()))?;
3265        Ok(vec![inbox_filter_json(
3266            &recipient_public_key,
3267            since_timestamp,
3268            None,
3269        )?])
3270    }
3271
3272    /// Reconcile and atomically persist authenticated relay discovery.
3273    ///
3274    /// # Errors
3275    ///
3276    /// Returns a stable relay-catalog or persistence error.
3277    pub fn reconcile_account_relays(
3278        &self,
3279        discovered: Vec<DnsRelayRecord>,
3280        refreshed_at: i64,
3281        fallback_url: String,
3282        dns_authenticated: bool,
3283    ) -> Result<StoredRelayCatalogPlan, SoftchatError> {
3284        self.database()?.reconcile_relay_catalog(
3285            discovered,
3286            refreshed_at,
3287            fallback_url,
3288            dns_authenticated,
3289        )
3290    }
3291
3292    /// Add and atomically persist one custom relay.
3293    ///
3294    /// # Errors
3295    ///
3296    /// Returns a stable relay-catalog or persistence error.
3297    pub fn add_account_relay(
3298        &self,
3299        relay_url: String,
3300        created_at: i64,
3301    ) -> Result<RelayCatalogMutation, SoftchatError> {
3302        self.database()?.add_custom_relay(relay_url, created_at)
3303    }
3304
3305    /// Exclude and atomically persist one relay.
3306    ///
3307    /// # Errors
3308    ///
3309    /// Returns a stable relay-catalog or persistence error.
3310    pub fn exclude_account_relay(
3311        &self,
3312        relay_url: String,
3313        now: i64,
3314    ) -> Result<RelayCatalogMutation, SoftchatError> {
3315        self.database()?.exclude_relay(relay_url, now)
3316    }
3317
3318    /// Restore and atomically persist automatic relays.
3319    ///
3320    /// # Errors
3321    ///
3322    /// Returns a stable relay-catalog or persistence error.
3323    pub fn restore_account_relays(&self, now: i64) -> Result<RelayCatalogMutation, SoftchatError> {
3324        self.database()?.restore_automatic_relays(now)
3325    }
3326
3327    /// Select and atomically persist one active relay.
3328    ///
3329    /// # Errors
3330    ///
3331    /// Returns a stable relay-catalog or persistence error.
3332    pub fn set_account_active_relay(
3333        &self,
3334        relay_url: String,
3335        now: i64,
3336    ) -> Result<RelayCatalogMutation, SoftchatError> {
3337        self.database()?.set_active_relay(relay_url, now)
3338    }
3339
3340    /// Return failover candidates from durable relay state.
3341    ///
3342    /// # Errors
3343    ///
3344    /// Returns a stable relay-catalog or persistence error.
3345    pub fn account_relay_failover_candidates(
3346        &self,
3347        failed_url: String,
3348    ) -> Result<Vec<String>, SoftchatError> {
3349        self.database()?.relay_failover_candidates(failed_url)
3350    }
3351
3352    /// Atomically select the next relay and rebind unfinished work.
3353    ///
3354    /// # Errors
3355    ///
3356    /// Returns a stable catalog, delivery-state, or persistence error.
3357    pub fn rotate_failed_account_relay(
3358        &self,
3359        failed_url: String,
3360        now: i64,
3361    ) -> Result<RelayFailoverMutation, SoftchatError> {
3362        self.database()?.rotate_failed_relay(failed_url, now)
3363    }
3364
3365    /// Return a bounded newest-first conversation page.
3366    ///
3367    /// # Errors
3368    ///
3369    /// Returns a stable account or persistence error.
3370    pub fn list_conversations(
3371        &self,
3372        limit: u32,
3373        offset: u32,
3374    ) -> Result<Vec<ConversationSummary>, SoftchatError> {
3375        self.database()?.list_conversations(limit, offset)
3376    }
3377
3378    /// Return complete bounded conversation-list use cases in one native call.
3379    ///
3380    /// # Errors
3381    ///
3382    /// Returns a stable account or persistence error.
3383    pub fn list_conversation_views(
3384        &self,
3385        limit: u32,
3386        offset: u32,
3387    ) -> Result<Vec<ConversationView>, SoftchatError> {
3388        self.database()?.list_conversation_views(limit, offset)
3389    }
3390
3391    /// Return a bounded newest-first message page.
3392    ///
3393    /// # Errors
3394    ///
3395    /// Returns a stable account or persistence error.
3396    pub fn list_messages(
3397        &self,
3398        conversation_id: String,
3399        before_created_at: Option<i64>,
3400        limit: u32,
3401    ) -> Result<Vec<AccountMessage>, SoftchatError> {
3402        self.database()?
3403            .list_messages(&conversation_id, before_created_at, limit)
3404    }
3405
3406    /// Return a bounded newest-first authenticated projection page.
3407    ///
3408    /// Empty string filters are ignored.
3409    ///
3410    /// # Errors
3411    ///
3412    /// Returns a stable account, event, or persistence error.
3413    #[allow(clippy::too_many_arguments)]
3414    pub fn list_projections(
3415        &self,
3416        kind: Option<crate::ProjectionKind>,
3417        logical_event_id: String,
3418        conversation_id: String,
3419        target_event_id: String,
3420        author_public_key: String,
3421        before_created_at: Option<i64>,
3422        limit: u32,
3423        offset: u32,
3424    ) -> Result<Vec<AccountProjection>, SoftchatError> {
3425        self.database()?.list_projections(
3426            kind,
3427            logical_event_id,
3428            conversation_id,
3429            target_event_id,
3430            author_public_key,
3431            before_created_at,
3432            limit,
3433            offset,
3434        )
3435    }
3436
3437    /// Return bounded authenticated projections with use-case state.
3438    ///
3439    /// # Errors
3440    ///
3441    /// Returns a stable account, event, or persistence error.
3442    #[allow(clippy::too_many_arguments)]
3443    pub fn list_event_nodes(
3444        &self,
3445        kind: Option<crate::ProjectionKind>,
3446        logical_event_id: String,
3447        conversation_id: String,
3448        target_event_id: String,
3449        author_public_key: String,
3450        before_created_at: Option<i64>,
3451        limit: u32,
3452        offset: u32,
3453    ) -> Result<Vec<AccountEventNode>, SoftchatError> {
3454        self.database()?.list_event_nodes(
3455            kind,
3456            logical_event_id,
3457            conversation_id,
3458            target_event_id,
3459            author_public_key,
3460            before_created_at,
3461            limit,
3462            offset,
3463        )
3464    }
3465
3466    /// Return bounded canonical public keys known to authenticated state.
3467    ///
3468    /// # Errors
3469    ///
3470    /// Returns a stable account or persistence error.
3471    pub fn list_known_public_keys(
3472        &self,
3473        limit: u32,
3474        offset: u32,
3475    ) -> Result<Vec<String>, SoftchatError> {
3476        self.database()?.list_known_public_keys(limit, offset)
3477    }
3478
3479    /// Insert or replace one bounded Android-local contact.
3480    ///
3481    /// # Errors
3482    ///
3483    /// Returns a stable account or persistence error.
3484    pub fn put_local_contact(
3485        &self,
3486        contact: LocalContactRecord,
3487    ) -> Result<AccountMutationResult, SoftchatError> {
3488        self.database()?.put_local_contact(contact)
3489    }
3490
3491    /// Remove one Android-local contact idempotently.
3492    ///
3493    /// # Errors
3494    ///
3495    /// Returns a stable account or persistence error.
3496    pub fn remove_local_contact(
3497        &self,
3498        public_key: String,
3499    ) -> Result<AccountMutationResult, SoftchatError> {
3500        self.database()?.remove_local_contact(public_key)
3501    }
3502
3503    /// Return a bounded stable page of Android-local contacts.
3504    ///
3505    /// # Errors
3506    ///
3507    /// Returns a stable account or persistence error.
3508    pub fn local_contacts(
3509        &self,
3510        limit: u32,
3511        offset: u32,
3512    ) -> Result<Vec<LocalContactRecord>, SoftchatError> {
3513        self.database()?.local_contacts(limit, offset)
3514    }
3515
3516    /// Replace the complete bounded account sticker cache.
3517    ///
3518    /// # Errors
3519    ///
3520    /// Returns a stable account or persistence error.
3521    pub fn replace_stickers(
3522        &self,
3523        stickers: Vec<StickerRecord>,
3524    ) -> Result<AccountMutationResult, SoftchatError> {
3525        self.database()?.replace_stickers(stickers)
3526    }
3527
3528    /// Return the complete bounded account sticker cache.
3529    ///
3530    /// # Errors
3531    ///
3532    /// Returns a stable persistence error.
3533    pub fn stickers(&self) -> Result<Vec<StickerRecord>, SoftchatError> {
3534        self.database()?.stickers()
3535    }
3536
3537    /// Insert or replace one account link-preview cache result.
3538    ///
3539    /// # Errors
3540    ///
3541    /// Returns a stable account or persistence error.
3542    pub fn put_link_preview(
3543        &self,
3544        preview: LinkPreviewRecord,
3545    ) -> Result<AccountMutationResult, SoftchatError> {
3546        self.database()?.put_link_preview(preview)
3547    }
3548
3549    /// Return one account link-preview cache result.
3550    ///
3551    /// # Errors
3552    ///
3553    /// Returns a stable account or persistence error.
3554    pub fn link_preview(&self, url: String) -> Result<Option<LinkPreviewRecord>, SoftchatError> {
3555        self.database()?.link_preview(url)
3556    }
3557
3558    /// Clear every account-owned durable row and process-local coordinator.
3559    ///
3560    /// The account binding and revision remain valid. Settings and relay
3561    /// catalog return to safe defaults. Active sync engines, transport state,
3562    /// and authored typing state are discarded only after SQLite commits.
3563    ///
3564    /// # Errors
3565    ///
3566    /// Returns a stable persistence error.
3567    pub fn clear_account(&self) -> Result<AccountMutationResult, SoftchatError> {
3568        let mut sync_engines = lock(&self.sync_engines);
3569        let mut database = self.database()?;
3570        let mut transport_run = lock(&self.transport_run);
3571        let result = database.clear()?;
3572        let mut diagnostic = Diagnostic::new(
3573            AccountLogCategory::Recovery,
3574            "account.cleared",
3575            "Account data cleared",
3576        );
3577        diagnostic.revision = Some(result.revision);
3578        self.logs.emit(diagnostic);
3579        sync_engines.clear();
3580        transport_run.take();
3581        lock(&self.typing_authored).clear();
3582        Ok(result)
3583    }
3584
3585    /// Return exact canonical signed-event JSON by ID.
3586    ///
3587    /// # Errors
3588    ///
3589    /// Returns a stable event or persistence error.
3590    pub fn event_json(&self, event_id: String) -> Result<String, SoftchatError> {
3591        self.database()?.event_json(&event_id)
3592    }
3593
3594    /// Return exact canonical JSON for bounded synchronization resend IDs.
3595    ///
3596    /// # Errors
3597    ///
3598    /// Returns a stable event, account, or persistence error.
3599    pub fn event_jsons(
3600        &self,
3601        event_ids: Vec<String>,
3602    ) -> Result<Vec<StoredEventJson>, SoftchatError> {
3603        self.database()?.event_jsons(event_ids)
3604    }
3605
3606    /// Load a bounded Rust-owned synchronization fingerprint snapshot.
3607    ///
3608    /// # Errors
3609    ///
3610    /// Returns a stable sync or persistence error.
3611    pub fn sync_fingerprints(
3612        &self,
3613        since_timestamp: Option<i64>,
3614        until_timestamp: i64,
3615        limit: u32,
3616    ) -> Result<Vec<NegentropyItem>, SoftchatError> {
3617        self.database()?
3618            .sync_fingerprints(since_timestamp, until_timestamp, limit)
3619    }
3620
3621    /// Return a durable synchronization checkpoint, when present.
3622    ///
3623    /// # Errors
3624    ///
3625    /// Returns a stable sync or persistence error.
3626    pub fn sync_checkpoint(&self, sync_id: String) -> Result<Option<i64>, SoftchatError> {
3627        self.database()?.sync_checkpoint(&sync_id)
3628    }
3629
3630    /// Commit one synchronization checkpoint and increment account revision.
3631    ///
3632    /// # Errors
3633    ///
3634    /// Returns a stable sync or persistence error.
3635    pub fn save_sync_checkpoint(
3636        &self,
3637        sync_id: String,
3638        checkpoint_timestamp: i64,
3639        now: i64,
3640    ) -> Result<AccountMutationResult, SoftchatError> {
3641        self.database()?
3642            .save_sync_checkpoint(&sync_id, checkpoint_timestamp, now)
3643    }
3644
3645    /// Start one sync and return its first transport action.
3646    ///
3647    /// Fingerprint snapshot loading is completed inside Rust before this
3648    /// method returns, so Android never receives a database action.
3649    ///
3650    /// # Errors
3651    ///
3652    /// Returns a stable sync, Negentropy, or persistence error.
3653    #[allow(clippy::too_many_arguments)]
3654    pub fn start_sync(
3655        &self,
3656        sync_id: String,
3657        neg_subscription_id: String,
3658        event_subscription_id: String,
3659        now: i64,
3660        overlap_seconds: u32,
3661        frame_size_limit: u64,
3662    ) -> Result<SyncAction, SoftchatError> {
3663        self.ensure_open()?;
3664        if overlap_seconds > 604_800 {
3665            return Err(SoftchatError::InvalidSyncState);
3666        }
3667        let mut engines = lock(&self.sync_engines);
3668        if engines.contains_key(&sync_id) {
3669            return Err(SoftchatError::InvalidSyncState);
3670        }
3671        let since_timestamp = self
3672            .database()?
3673            .sync_checkpoint(&sync_id)?
3674            .map(|checkpoint| checkpoint.saturating_sub(i64::from(overlap_seconds)));
3675        let mut engine = SyncEngine::new(
3676            sync_id.clone(),
3677            neg_subscription_id,
3678            event_subscription_id,
3679            since_timestamp,
3680            now,
3681        )?;
3682        let action = engine.begin()?;
3683        let action = {
3684            let mut database = self.database()?;
3685            resolve_sync_action(&mut database, &mut engine, action, frame_size_limit)?
3686        };
3687        let recipient_public_key = self
3688            .identity
3689            .with_identity(|identity| Ok(identity.public_key().to_hex()))?;
3690        let action = plan_sync_transport(action, &recipient_public_key)?;
3691        engines.insert(sync_id, engine);
3692        Ok(action)
3693    }
3694
3695    /// Consume one correlated `NEG-MSG` and return transport-only actions.
3696    ///
3697    /// # Errors
3698    ///
3699    /// Returns a stable sync, Negentropy, or persistence error.
3700    pub fn reconcile_sync(
3701        &self,
3702        sync_id: String,
3703        subscription_id: String,
3704        message: Vec<u8>,
3705        frame_size_limit: u64,
3706    ) -> Result<Vec<SyncAction>, SoftchatError> {
3707        self.ensure_open()?;
3708        let mut engines = lock(&self.sync_engines);
3709        let engine = engines
3710            .get_mut(&sync_id)
3711            .ok_or(SoftchatError::InvalidSyncState)?;
3712        let actions = engine.reconcile(subscription_id, message)?;
3713        let actions = {
3714            let mut database = self.database()?;
3715            resolve_sync_actions(&mut database, engine, actions, frame_size_limit)?
3716        };
3717        let recipient_public_key = self
3718            .identity
3719            .with_identity(|identity| Ok(identity.public_key().to_hex()))?;
3720        actions
3721            .into_iter()
3722            .map(|action| plan_sync_transport(action, &recipient_public_key))
3723            .collect()
3724    }
3725
3726    /// Route one complete relay text frame to Negentropy when applicable.
3727    ///
3728    /// Ordinary Nostr relay frames return `None` and must be passed to
3729    /// `RelaySession.receive`. Correlated `NEG-MSG` is parsed, bounded, and
3730    /// reconciled entirely in Rust.
3731    ///
3732    /// # Errors
3733    ///
3734    /// Returns a stable sync error for malformed, stale, or relay-error frames.
3735    pub fn receive_sync_frame(
3736        &self,
3737        sync_id: String,
3738        frame_json: String,
3739        frame_size_limit: u64,
3740    ) -> Result<Option<Vec<SyncAction>>, SoftchatError> {
3741        let Some(frame) = parse_sync_message_frame(&frame_json)? else {
3742            return Ok(None);
3743        };
3744        let SyncTransportFrame::Message {
3745            subscription_id,
3746            message,
3747        } = frame
3748        else {
3749            return Err(SoftchatError::InvalidSyncState);
3750        };
3751        self.reconcile_sync(sync_id, subscription_id, message, frame_size_limit)
3752            .map(Some)
3753    }
3754
3755    /// Authenticate and commit requested sync events before marking progress.
3756    ///
3757    /// # Errors
3758    ///
3759    /// Returns a stable sync, event, envelope, projection, or persistence
3760    /// error. Sync state does not advance unless the database commits.
3761    pub fn ingest_sync_events(
3762        &self,
3763        sync_id: String,
3764        subscription_id: String,
3765        events: Vec<SignedEvent>,
3766        received_at: i64,
3767    ) -> Result<StoredIngestion, SoftchatError> {
3768        let event_ids = events
3769            .iter()
3770            .map(|event| event.id.clone())
3771            .collect::<Vec<_>>();
3772        let mut engines = lock(&self.sync_engines);
3773        let engine = engines
3774            .get_mut(&sync_id)
3775            .ok_or(SoftchatError::InvalidSyncState)?;
3776        engine.validate_fetched_events(&subscription_id, &event_ids)?;
3777        let mut database = self.database()?;
3778        let stored = self
3779            .identity
3780            .with_identity(|identity| database.ingest(identity, events, received_at))?;
3781        for event_id in &event_ids {
3782            engine.record_fetched_event(subscription_id.clone(), event_id.clone())?;
3783        }
3784        engine.confirm_committed(event_ids)?;
3785        Ok(stored)
3786    }
3787
3788    /// Finish a requested-event subscription after every event committed.
3789    ///
3790    /// # Errors
3791    ///
3792    /// Returns a stable sync, Negentropy, or persistence error.
3793    pub fn finish_sync_fetch(
3794        &self,
3795        sync_id: String,
3796        subscription_id: String,
3797        frame_size_limit: u64,
3798    ) -> Result<Vec<SyncAction>, SoftchatError> {
3799        let mut engines = lock(&self.sync_engines);
3800        let engine = engines
3801            .get_mut(&sync_id)
3802            .ok_or(SoftchatError::InvalidSyncState)?;
3803        let actions = engine.finish_fetch(subscription_id)?;
3804        let actions = {
3805            let mut database = self.database()?;
3806            resolve_sync_actions(&mut database, engine, actions, frame_size_limit)?
3807        };
3808        let recipient_public_key = self
3809            .identity
3810            .with_identity(|identity| Ok(identity.public_key().to_hex()))?;
3811        actions
3812            .into_iter()
3813            .map(|action| plan_sync_transport(action, &recipient_public_key))
3814            .collect()
3815    }
3816
3817    /// Return exact locally stored payloads requested by a resend action.
3818    ///
3819    /// # Errors
3820    ///
3821    /// Returns a stable event, account, or persistence error.
3822    pub fn sync_resend_payloads(
3823        &self,
3824        event_ids: Vec<String>,
3825    ) -> Result<Vec<StoredEventJson>, SoftchatError> {
3826        self.database()?.event_jsons(event_ids)
3827    }
3828
3829    /// Return one active synchronization snapshot.
3830    ///
3831    /// # Errors
3832    ///
3833    /// Returns a stable sync error for an unknown ID.
3834    pub fn sync_snapshot(&self, sync_id: String) -> Result<SyncEngineSnapshot, SoftchatError> {
3835        self.ensure_open()?;
3836        lock(&self.sync_engines)
3837            .get(&sync_id)
3838            .map(SyncEngine::snapshot)
3839            .ok_or(SoftchatError::InvalidSyncState)
3840    }
3841
3842    /// Cancel and remove one active synchronization coordinator.
3843    ///
3844    /// # Errors
3845    ///
3846    /// Returns a stable sync error for an unknown ID.
3847    pub fn cancel_sync(&self, sync_id: String) -> Result<SyncEngineSnapshot, SoftchatError> {
3848        self.ensure_open()?;
3849        let mut engine = lock(&self.sync_engines)
3850            .remove(&sync_id)
3851            .ok_or(SoftchatError::InvalidSyncState)?;
3852        Ok(engine.cancel())
3853    }
3854}
3855
3856impl AccountRuntimeHandle {
3857    fn ingest_unlogged(
3858        &self,
3859        events: Vec<SignedEvent>,
3860        received_at: i64,
3861    ) -> Result<StoredIngestion, SoftchatError> {
3862        let mut database = self.database()?;
3863        self.identity
3864            .with_identity(|identity| database.ingest(identity, events, received_at))
3865    }
3866
3867    fn enqueue_follow_replacement(
3868        &self,
3869        database: &mut AccountDatabase,
3870        command_id: String,
3871        contacts: Vec<Contact>,
3872        now: i64,
3873    ) -> Result<ProductAccountOperationResult, SoftchatError> {
3874        validate_command_id(&command_id)?;
3875        let payload_hash = command_hash(&("follows", &contacts))?;
3876        let own_public_key = self.public_key()?;
3877        let latest = database.authenticated_product_follow_updated_at(&own_public_key)?;
3878        let authored_at = if latest > 0 && latest >= now {
3879            latest
3880                .checked_add(1)
3881                .ok_or(SoftchatError::InvalidAccountOperation)?
3882        } else {
3883            now
3884        };
3885        let created_at =
3886            u64::try_from(authored_at).map_err(|_| SoftchatError::InvalidAccountOperation)?;
3887        let rumor = self.identity.with_identity(|identity| {
3888            identity
3889                .create_follow_list(created_at, contacts.clone())
3890                .map(|view| view.rumor)
3891        })?;
3892        let recipients = contacts
3893            .into_iter()
3894            .map(|contact| contact.public_key)
3895            .filter(|key| key != &own_public_key)
3896            .collect::<Vec<_>>();
3897        self.identity.with_identity(|identity| {
3898            database.enqueue_product_rumor(
3899                identity,
3900                command_id,
3901                payload_hash,
3902                String::new(),
3903                rumor,
3904                recipients,
3905                Vec::new(),
3906                Nip59EnvelopeKind::Durable,
3907                now,
3908                None,
3909            )
3910        })
3911    }
3912
3913    fn ensure_open(&self) -> Result<(), SoftchatError> {
3914        if self.closed.load(Ordering::Acquire) {
3915            Err(SoftchatError::AccountClosed)
3916        } else {
3917            Ok(())
3918        }
3919    }
3920
3921    fn database(&self) -> Result<MutexGuard<'_, AccountDatabase>, SoftchatError> {
3922        self.ensure_open()?;
3923        Ok(lock(&self.database))
3924    }
3925}
3926
3927impl Drop for AccountRuntimeHandle {
3928    fn drop(&mut self) {
3929        self.closed.store(true, Ordering::Release);
3930        self.identity.erase();
3931        lock(open_account_paths()).remove(&self.database_path);
3932    }
3933}
3934
3935fn remote_members(conversation: &ConversationRecord, own_public_key: &str) -> Vec<String> {
3936    conversation
3937        .member_public_keys
3938        .iter()
3939        .filter(|key| key.as_str() != own_public_key)
3940        .cloned()
3941        .collect()
3942}
3943
3944fn all_known_public_keys(database: &AccountDatabase) -> Result<Vec<String>, SoftchatError> {
3945    let page_limit = crate::MAX_PRODUCT_QUERY_PAGE;
3946    let mut offset = 0;
3947    let mut result = Vec::new();
3948    while result.len() < crate::MAX_CONTACTS {
3949        let page = database.list_known_public_keys(page_limit, offset)?;
3950        let page_len = page.len();
3951        result.extend(page);
3952        if page_len < page_limit as usize {
3953            break;
3954        }
3955        offset = offset
3956            .checked_add(page_limit)
3957            .ok_or(SoftchatError::InvalidAccountOperation)?;
3958    }
3959    if result.len() > crate::MAX_CONTACTS {
3960        return Err(SoftchatError::InvalidAccountOperation);
3961    }
3962    Ok(result)
3963}
3964
3965fn send_message_command_hash(
3966    conversation_id: &str,
3967    content: &MessageContentInput,
3968    reply_to_message_id: &str,
3969    draft_match: Option<&crate::account_product::ProductDraftMatch>,
3970) -> Result<String, SoftchatError> {
3971    if let Some(draft_match) = draft_match {
3972        command_hash(&(
3973            "send",
3974            conversation_id,
3975            content,
3976            reply_to_message_id,
3977            draft_match,
3978        ))
3979    } else {
3980        command_hash(&("send", conversation_id, content, reply_to_message_id))
3981    }
3982}
3983
3984fn media_message_command_hash(
3985    conversation_id: &str,
3986    content: &MessageContentInput,
3987    reply_to_message_id: &str,
3988    sources: &[crate::MediaPreparationInput],
3989    draft_match: Option<&crate::account_product::ProductDraftMatch>,
3990) -> Result<String, SoftchatError> {
3991    if let Some(draft_match) = draft_match {
3992        command_hash(&(
3993            "media-message",
3994            conversation_id,
3995            content,
3996            reply_to_message_id,
3997            sources,
3998            draft_match,
3999        ))
4000    } else {
4001        command_hash(&(
4002            "media-message",
4003            conversation_id,
4004            content,
4005            reply_to_message_id,
4006            sources,
4007        ))
4008    }
4009}
4010
4011fn public_keys(values: &[String]) -> Result<Vec<NostrPublicKey>, SoftchatError> {
4012    values
4013        .iter()
4014        .map(|value| NostrPublicKey::from_hex(value))
4015        .collect()
4016}
4017
4018fn author_product_chat_message(
4019    account: &AccountRuntimeHandle,
4020    conversation_id: &str,
4021    content: MessageContentInput,
4022    reply_to_message_id: &str,
4023    now: i64,
4024) -> Result<(RumorEvent, Vec<String>), SoftchatError> {
4025    content.validate()?;
4026    let own_public_key = account.public_key()?;
4027    let conversation = account
4028        .database()?
4029        .product_conversation(conversation_id, &own_public_key)?
4030        .ok_or(SoftchatError::InvalidAccountOperation)?;
4031    let recipients = remote_members(&conversation, &own_public_key);
4032    let relation = if reply_to_message_id.is_empty() {
4033        ChatRelation::None
4034    } else {
4035        let parent = account
4036            .database()?
4037            .product_message(reply_to_message_id)?
4038            .filter(|message| message.conversation_id == conversation_id)
4039            .ok_or(SoftchatError::InvalidAccountOperation)?;
4040        ChatRelation::Reply {
4041            event_id: NostrEventId::from_hex(&parent.id)?,
4042            relay_hint: Some(account.database()?.active_relay_url()?),
4043            legacy_unmarked: false,
4044        }
4045    };
4046    let created_at = u64::try_from(now).map_err(|_| SoftchatError::InvalidAccountOperation)?;
4047    let draft = crate::chat::prepare_chat_message_draft(
4048        NostrPublicKey::from_hex(&own_public_key)?,
4049        ChatMessageDraft {
4050            created_at,
4051            participants: public_keys(&recipients)?,
4052            content: content.text,
4053            relation,
4054            attachments: content.attachments,
4055            emoji_tags: nostr_tags(content.emoji_tags)?,
4056            extension_tags: Vec::new(),
4057        },
4058    )?;
4059    crate::envelope::validate_nip59_draft_wire_size(
4060        &draft,
4061        Nip59EnvelopeKind::Durable,
4062        created_at,
4063        !recipients.is_empty(),
4064    )
4065    .map_err(|_| SoftchatError::InvalidAccountOperation)?;
4066    let rumor = account.identity.with_identity(|identity| {
4067        identity
4068            .create_rumor(draft)
4069            .map(|rumor| RumorEvent::from(&rumor))
4070            .map_err(|_| SoftchatError::SoftchatEventCreationFailed)
4071    })?;
4072    Ok((rumor, recipients))
4073}
4074
4075fn preflight_ingestion_jsons(event_jsons: &[String]) -> Result<u64, SoftchatError> {
4076    if event_jsons.is_empty() || event_jsons.len() > crate::MAX_ACCOUNT_INGESTION_EVENTS {
4077        return Err(SoftchatError::InvalidAccountOperation);
4078    }
4079    bounded_ingestion_json_bytes(event_jsons.iter().map(String::len))
4080}
4081
4082fn bounded_ingestion_json_bytes(
4083    lengths: impl IntoIterator<Item = usize>,
4084) -> Result<u64, SoftchatError> {
4085    let total = lengths.into_iter().try_fold(0_usize, |total, length| {
4086        total
4087            .checked_add(length)
4088            .filter(|next| *next <= crate::MAX_ACCOUNT_INGESTION_BYTES)
4089            .ok_or(SoftchatError::InvalidAccountOperation)
4090    })?;
4091    u64::try_from(total).map_err(|_| SoftchatError::InvalidAccountOperation)
4092}
4093
4094fn nostr_tags(values: Vec<Vec<String>>) -> Result<Vec<NostrTag>, SoftchatError> {
4095    values.into_iter().map(NostrTag::new).collect()
4096}
4097
4098fn stored_product_result(
4099    account: &AccountRuntimeHandle,
4100    operation: ProductAccountOperationResult,
4101) -> Result<ProductOperationResult, SoftchatError> {
4102    let message = if operation.result_message_id.is_empty() {
4103        None
4104    } else {
4105        account
4106            .database()?
4107            .product_message(&operation.result_message_id)?
4108    };
4109    Ok(ProductOperationResult {
4110        operation: operation.operation.operation,
4111        revision: operation.operation.revision,
4112        inserted: operation.operation.inserted,
4113        message,
4114    })
4115}
4116
4117/// Session-scoped account planner backed by one opaque identity.
4118#[cfg_attr(feature = "native-bindings", derive(uniffi::Object))]
4119pub struct AccountEngineHandle {
4120    account_id: String,
4121    identity: Arc<LocalIdentityHandle>,
4122}
4123
4124impl fmt::Debug for AccountEngineHandle {
4125    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
4126        formatter
4127            .debug_struct("AccountEngineHandle")
4128            .field("account_id", &self.account_id)
4129            .finish_non_exhaustive()
4130    }
4131}
4132
4133#[cfg_attr(feature = "native-bindings", uniffi::export)]
4134impl AccountEngineHandle {
4135    /// Bind one opaque identity to one non-secret account partition.
4136    ///
4137    /// # Errors
4138    ///
4139    /// Returns a stable account error for an invalid partition identifier.
4140    #[cfg_attr(feature = "native-bindings", uniffi::constructor)]
4141    pub fn new(
4142        account_id: String,
4143        identity: Arc<LocalIdentityHandle>,
4144    ) -> Result<Self, SoftchatError> {
4145        AccountEngine::validate_account_id(&account_id)?;
4146        identity.with_identity(|_| Ok(()))?;
4147        Ok(Self {
4148            account_id,
4149            identity,
4150        })
4151    }
4152
4153    /// Return the non-secret account partition.
4154    #[must_use]
4155    pub fn account_id(&self) -> String {
4156        self.account_id.clone()
4157    }
4158
4159    /// Authenticate a bounded incoming batch before a host transaction.
4160    ///
4161    /// # Errors
4162    ///
4163    /// Returns stable event, envelope, projection, or account errors.
4164    pub fn prepare_incoming(
4165        &self,
4166        events: Vec<SignedEvent>,
4167    ) -> Result<PreparedIncomingBatch, SoftchatError> {
4168        self.identity.with_identity(|identity| {
4169            AccountEngine::prepare_incoming(identity, self.account_id.clone(), events)
4170        })
4171    }
4172
4173    /// Validate a host store's total transaction receipt.
4174    ///
4175    /// # Errors
4176    ///
4177    /// Returns a stable persistence-result error for any mismatch.
4178    pub fn validate_ingestion_receipt(
4179        &self,
4180        batch: PreparedIncomingBatch,
4181        receipt: IngestionReceipt,
4182    ) -> Result<(), SoftchatError> {
4183        AccountEngine::validate_ingestion_receipt(&batch, &receipt)
4184    }
4185
4186    /// Create independently wrapped copies and per-relay durable intents.
4187    ///
4188    /// # Errors
4189    ///
4190    /// Returns stable identity, rumor, account, recipient, or relay errors.
4191    #[allow(clippy::too_many_arguments)]
4192    pub fn prepare_rumor_operation(
4193        &self,
4194        operation_id: String,
4195        rumor: RumorEvent,
4196        recipients: Vec<String>,
4197        notification_recipients: Vec<String>,
4198        relay_urls: Vec<String>,
4199        kind: Nip59EnvelopeKind,
4200        now: i64,
4201    ) -> Result<PreparedOutgoingOperation, SoftchatError> {
4202        self.identity.with_identity(|identity| {
4203            AccountEngine::prepare_rumor_operation(
4204                identity,
4205                self.account_id.clone(),
4206                operation_id,
4207                rumor,
4208                recipients,
4209                notification_recipients,
4210                relay_urls,
4211                kind,
4212                now,
4213            )
4214        })
4215    }
4216
4217    /// Create durable intents for already signed direct events.
4218    ///
4219    /// # Errors
4220    ///
4221    /// Returns stable event, account, operation, or relay errors.
4222    pub fn prepare_signed_operation(
4223        &self,
4224        operation_id: String,
4225        events: Vec<SignedEvent>,
4226        relay_urls: Vec<String>,
4227    ) -> Result<PreparedOutgoingOperation, SoftchatError> {
4228        self.identity.with_identity(|_| {
4229            AccountEngine::prepare_signed_operation(
4230                self.account_id.clone(),
4231                operation_id,
4232                events,
4233                relay_urls,
4234            )
4235        })
4236    }
4237}
4238
4239/// Claim one bounded durable-delivery batch.
4240///
4241/// # Errors
4242///
4243/// Returns a stable delivery-state error for malformed or unclaimable rows.
4244#[cfg_attr(feature = "native-bindings", uniffi::export)]
4245pub fn claim_deliveries(
4246    intents: Vec<DeliveryIntentSnapshot>,
4247    lease_id: String,
4248) -> Result<DeliveryClaim, SoftchatError> {
4249    DeliveryReducer::claim(intents, lease_id)
4250}
4251
4252/// Mark an exact leased subset as socket-written.
4253///
4254/// # Errors
4255///
4256/// Returns a stable delivery-state error for stale or contradictory state.
4257#[cfg_attr(feature = "native-bindings", uniffi::export)]
4258pub fn mark_deliveries_socket_written(
4259    claim: DeliveryClaim,
4260    intent_ids: Vec<String>,
4261) -> Result<Vec<DeliveryStateMutation>, SoftchatError> {
4262    DeliveryReducer::mark_socket_written(&claim, intent_ids)
4263}
4264
4265/// Apply stable relay results to one durable lease.
4266///
4267/// # Errors
4268///
4269/// Returns a stable delivery-state error for stale or contradictory results.
4270#[cfg_attr(feature = "native-bindings", uniffi::export)]
4271pub fn apply_relay_delivery_results(
4272    claim: DeliveryClaim,
4273    results: Vec<RelayDeliveryResult>,
4274) -> Result<Vec<DeliveryStateMutation>, SoftchatError> {
4275    DeliveryReducer::apply_relay_results(&claim, results)
4276}
4277
4278/// Release every unfinished row from one durable lease.
4279///
4280/// # Errors
4281///
4282/// Returns a stable delivery-state error for malformed claim state.
4283#[cfg_attr(feature = "native-bindings", uniffi::export)]
4284pub fn release_delivery_claim(
4285    claim: DeliveryClaim,
4286) -> Result<Vec<DeliveryStateMutation>, SoftchatError> {
4287    DeliveryReducer::release_claim(&claim)
4288}
4289
4290/// Derive one aggregate user-visible operation state.
4291///
4292/// # Errors
4293///
4294/// Returns a stable operation error for empty or mixed-operation rows.
4295#[cfg_attr(feature = "native-bindings", uniffi::export)]
4296pub fn derive_operation_snapshot(
4297    intents: Vec<DeliveryIntentSnapshot>,
4298) -> Result<OperationSnapshot, SoftchatError> {
4299    DeliveryReducer::operation_snapshot(intents)
4300}
4301
4302/// Compute the released-Android conversation hash from complete participants.
4303///
4304/// # Errors
4305///
4306/// Returns a stable account error for malformed or duplicate public keys.
4307#[cfg_attr(feature = "native-bindings", uniffi::export)]
4308pub fn derive_conversation_id(public_keys: Vec<String>) -> Result<String, SoftchatError> {
4309    conversation_id(public_keys)
4310}
4311
4312/// Reconcile authenticated DNS records with persisted user catalog state.
4313///
4314/// # Errors
4315///
4316/// Returns a stable relay-catalog error for malformed or excessive input.
4317#[cfg_attr(feature = "native-bindings", uniffi::export)]
4318pub fn reconcile_relay_catalog(
4319    current: Vec<RelayCatalogEntry>,
4320    discovered: Vec<DnsRelayRecord>,
4321    refreshed_at: i64,
4322    fallback_url: String,
4323    dns_authenticated: bool,
4324) -> Result<RelayCatalogPlan, SoftchatError> {
4325    RelayCatalogReducer::reconcile(
4326        current,
4327        discovered,
4328        refreshed_at,
4329        fallback_url,
4330        dns_authenticated,
4331    )
4332}
4333
4334/// Add one custom relay.
4335///
4336/// # Errors
4337///
4338/// Returns a stable relay-catalog error for malformed state or URL.
4339#[cfg_attr(feature = "native-bindings", uniffi::export)]
4340pub fn add_custom_relay(
4341    current: Vec<RelayCatalogEntry>,
4342    relay_url: String,
4343    created_at: i64,
4344) -> Result<Vec<RelayCatalogEntry>, SoftchatError> {
4345    RelayCatalogReducer::add_custom(current, relay_url, created_at)
4346}
4347
4348/// Exclude or remove one relay.
4349///
4350/// # Errors
4351///
4352/// Returns a stable relay-catalog error for malformed state or URL.
4353#[cfg_attr(feature = "native-bindings", uniffi::export)]
4354pub fn exclude_relay(
4355    current: Vec<RelayCatalogEntry>,
4356    relay_url: String,
4357) -> Result<Vec<RelayCatalogEntry>, SoftchatError> {
4358    RelayCatalogReducer::exclude(current, relay_url)
4359}
4360
4361/// Restore all currently discovered automatic relays.
4362///
4363/// # Errors
4364///
4365/// Returns a stable relay-catalog error for malformed persisted state.
4366#[cfg_attr(feature = "native-bindings", uniffi::export)]
4367pub fn restore_automatic_relays(
4368    current: Vec<RelayCatalogEntry>,
4369) -> Result<Vec<RelayCatalogEntry>, SoftchatError> {
4370    RelayCatalogReducer::restore_automatic(current)
4371}
4372
4373/// Select one effective active relay.
4374///
4375/// # Errors
4376///
4377/// Returns a stable relay-catalog error for malformed or excluded targets.
4378#[cfg_attr(feature = "native-bindings", uniffi::export)]
4379pub fn set_active_relay(
4380    current: Vec<RelayCatalogEntry>,
4381    relay_url: String,
4382) -> Result<Vec<RelayCatalogEntry>, SoftchatError> {
4383    RelayCatalogReducer::set_active(current, relay_url)
4384}
4385
4386/// Return effective failover candidates.
4387///
4388/// # Errors
4389///
4390/// Returns a stable relay-catalog error for malformed persisted state.
4391#[cfg_attr(feature = "native-bindings", uniffi::export)]
4392pub fn relay_failover_candidates(
4393    current: Vec<RelayCatalogEntry>,
4394    failed_url: String,
4395) -> Result<Vec<String>, SoftchatError> {
4396    RelayCatalogReducer::failover_candidates(current, failed_url)
4397}
4398
4399/// Return the bounded retry/failover plan for one failure.
4400#[cfg_attr(feature = "native-bindings", uniffi::export)]
4401#[must_use]
4402pub fn relay_retry_plan(
4403    attempt: u32,
4404    failure: RelayFailureKind,
4405    bypass_delay_once: bool,
4406) -> RelayRetryPlan {
4407    plan_relay_retry(attempt, failure, bypass_delay_once)
4408}
4409
4410/// Validate and split one configured relay endpoint.
4411///
4412/// # Errors
4413///
4414/// Returns a stable relay-catalog error for insecure or malformed endpoints.
4415#[cfg_attr(feature = "native-bindings", uniffi::export)]
4416pub fn relay_endpoint_plan(configured_url: String) -> Result<RelayEndpointPlan, SoftchatError> {
4417    parse_relay_endpoint(&configured_url)
4418}
4419
4420/// Thread-safe generated-binding owner for one synchronization coordinator.
4421#[cfg_attr(feature = "native-bindings", derive(uniffi::Object))]
4422pub struct SyncEngineHandle {
4423    engine: Mutex<SyncEngine>,
4424}
4425
4426impl fmt::Debug for SyncEngineHandle {
4427    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
4428        formatter
4429            .debug_struct("SyncEngineHandle")
4430            .field("snapshot", &lock(&self.engine).snapshot())
4431            .finish()
4432    }
4433}
4434
4435#[cfg_attr(feature = "native-bindings", uniffi::export)]
4436impl SyncEngineHandle {
4437    /// Construct one bounded synchronization coordinator.
4438    ///
4439    /// # Errors
4440    ///
4441    /// Returns a stable sync-state error for invalid IDs or timestamps.
4442    #[cfg_attr(feature = "native-bindings", uniffi::constructor)]
4443    pub fn new(
4444        sync_id: String,
4445        neg_subscription_id: String,
4446        event_subscription_id: String,
4447        since_timestamp: Option<i64>,
4448        checkpoint_timestamp: i64,
4449    ) -> Result<Self, SoftchatError> {
4450        Ok(Self {
4451            engine: Mutex::new(SyncEngine::new(
4452                sync_id,
4453                neg_subscription_id,
4454                event_subscription_id,
4455                since_timestamp,
4456                checkpoint_timestamp,
4457            )?),
4458        })
4459    }
4460
4461    /// Begin and request the first durable fingerprint snapshot.
4462    ///
4463    /// # Errors
4464    ///
4465    /// Returns a stable sync-state error outside the initial phase.
4466    pub fn begin(&self) -> Result<SyncAction, SoftchatError> {
4467        lock(&self.engine).begin()
4468    }
4469
4470    /// Consume a durable fingerprint snapshot and produce NEG-OPEN.
4471    ///
4472    /// # Errors
4473    ///
4474    /// Returns stable sync/Negentropy errors for malformed or stale input.
4475    pub fn accept_snapshot(
4476        &self,
4477        items: Vec<NegentropyItem>,
4478        frame_size_limit: u64,
4479    ) -> Result<SyncAction, SoftchatError> {
4480        lock(&self.engine).accept_snapshot(items, frame_size_limit)
4481    }
4482
4483    /// Consume one correlated NEG-MSG.
4484    ///
4485    /// # Errors
4486    ///
4487    /// Returns stable sync/Negentropy errors for malformed or stale input.
4488    pub fn reconcile(
4489        &self,
4490        subscription_id: String,
4491        message: Vec<u8>,
4492    ) -> Result<Vec<SyncAction>, SoftchatError> {
4493        lock(&self.engine).reconcile(subscription_id, message)
4494    }
4495
4496    /// Record one correlated requested event.
4497    ///
4498    /// # Errors
4499    ///
4500    /// Returns a stable sync-state error for stale or unrequested IDs.
4501    pub fn record_fetched_event(
4502        &self,
4503        subscription_id: String,
4504        event_id: String,
4505    ) -> Result<(), SoftchatError> {
4506        lock(&self.engine).record_fetched_event(subscription_id, event_id)
4507    }
4508
4509    /// Confirm a durable transaction committed requested events.
4510    ///
4511    /// # Errors
4512    ///
4513    /// Returns a stable sync-state error for stale or unknown IDs.
4514    pub fn confirm_committed(&self, event_ids: Vec<String>) -> Result<(), SoftchatError> {
4515        lock(&self.engine).confirm_committed(event_ids)
4516    }
4517
4518    /// Finish one requested-event subscription.
4519    ///
4520    /// # Errors
4521    ///
4522    /// Returns a stable sync-state error until all requested IDs committed.
4523    pub fn finish_fetch(&self, subscription_id: String) -> Result<Vec<SyncAction>, SoftchatError> {
4524        lock(&self.engine).finish_fetch(subscription_id)
4525    }
4526
4527    /// Confirm the final checkpoint transaction.
4528    ///
4529    /// # Errors
4530    ///
4531    /// Returns a stable sync-state error for stale or mismatched state.
4532    pub fn checkpoint_saved(&self, checkpoint_timestamp: i64) -> Result<SyncAction, SoftchatError> {
4533        lock(&self.engine).checkpoint_saved(checkpoint_timestamp)
4534    }
4535
4536    /// Permanently cancel and clear process-local reconciliation state.
4537    #[must_use]
4538    pub fn cancel(&self) -> SyncEngineSnapshot {
4539        lock(&self.engine).cancel()
4540    }
4541
4542    /// Return one redacted serializable snapshot.
4543    #[must_use]
4544    pub fn snapshot(&self) -> SyncEngineSnapshot {
4545        lock(&self.engine).snapshot()
4546    }
4547}
4548
4549fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
4550    match mutex.lock() {
4551        Ok(guard) => guard,
4552        Err(poisoned) => poisoned.into_inner(),
4553    }
4554}
4555
4556fn open_account_paths() -> &'static Mutex<BTreeSet<PathBuf>> {
4557    static OPEN_ACCOUNT_PATHS: OnceLock<Mutex<BTreeSet<PathBuf>>> = OnceLock::new();
4558    OPEN_ACCOUNT_PATHS.get_or_init(|| Mutex::new(BTreeSet::new()))
4559}
4560
4561fn derived_account_path(
4562    database_directory: &str,
4563    public_key: &str,
4564) -> Result<PathBuf, SoftchatError> {
4565    if database_directory.is_empty() || database_directory.len() > 4_096 {
4566        return Err(SoftchatError::InvalidPersistenceResult);
4567    }
4568    let directory = Path::new(database_directory)
4569        .canonicalize()
4570        .map_err(|_| SoftchatError::InvalidPersistenceResult)?;
4571    if !directory.is_dir() {
4572        return Err(SoftchatError::InvalidPersistenceResult);
4573    }
4574    Ok(directory.join(account_database_filename(public_key)?))
4575}
4576
4577fn account_database_filename(public_key: &str) -> Result<String, SoftchatError> {
4578    let public_key_bytes =
4579        hex::decode(public_key).map_err(|_| SoftchatError::InvalidPersistenceResult)?;
4580    let digest = hex::encode(Sha256::digest(public_key_bytes));
4581    Ok(format!("softchat-account-{digest}.sqlite3"))
4582}
4583
4584pub(crate) fn resolve_sync_actions(
4585    database: &mut AccountDatabase,
4586    engine: &mut SyncEngine,
4587    actions: Vec<SyncAction>,
4588    frame_size_limit: u64,
4589) -> Result<Vec<SyncAction>, SoftchatError> {
4590    actions
4591        .into_iter()
4592        .map(|action| resolve_sync_action(database, engine, action, frame_size_limit))
4593        .collect()
4594}
4595
4596pub(crate) fn resolve_sync_action(
4597    database: &mut AccountDatabase,
4598    engine: &mut SyncEngine,
4599    action: SyncAction,
4600    frame_size_limit: u64,
4601) -> Result<SyncAction, SoftchatError> {
4602    match action.kind {
4603        SyncActionKind::LoadSnapshot => {
4604            let since = (action.since_timestamp >= 0).then_some(action.since_timestamp);
4605            let items = database.sync_fingerprints(since, action.until_timestamp, action.limit)?;
4606            engine.accept_snapshot(items, frame_size_limit)
4607        }
4608        SyncActionKind::SaveCheckpoint => {
4609            database.save_sync_checkpoint(
4610                &engine.snapshot().sync_id,
4611                action.checkpoint_timestamp,
4612                action.checkpoint_timestamp,
4613            )?;
4614            engine.checkpoint_saved(action.checkpoint_timestamp)
4615        }
4616        _ => Ok(action),
4617    }
4618}
4619
4620pub(crate) fn plan_sync_transport(
4621    mut action: SyncAction,
4622    recipient_public_key: &str,
4623) -> Result<SyncAction, SoftchatError> {
4624    NostrPublicKey::from_hex(recipient_public_key).map_err(|_| SoftchatError::InvalidSyncState)?;
4625    match action.kind {
4626        SyncActionKind::SendNegOpen => {
4627            if action.subscription_id.is_empty()
4628                || action.until_timestamp < 0
4629                || action.message.is_empty()
4630            {
4631                return Err(SoftchatError::InvalidSyncState);
4632            }
4633            let mut filter = serde_json::Map::new();
4634            filter.insert(
4635                "kinds".to_owned(),
4636                serde_json::json!([
4637                    NostrEventKind::GIFT_WRAP.as_u16(),
4638                    NostrEventKind::EPHEMERAL_GIFT_WRAP.as_u16()
4639                ]),
4640            );
4641            filter.insert("#p".to_owned(), serde_json::json!([recipient_public_key]));
4642            if action.since_timestamp >= 0 {
4643                filter.insert(
4644                    "since".to_owned(),
4645                    serde_json::json!(action.since_timestamp),
4646                );
4647            }
4648            filter.insert(
4649                "until".to_owned(),
4650                serde_json::json!(action.until_timestamp),
4651            );
4652            action.frame_json = serde_json::to_string(&serde_json::json!([
4653                "NEG-OPEN",
4654                action.subscription_id,
4655                serde_json::Value::Object(filter),
4656                hex::encode(&action.message)
4657            ]))
4658            .map_err(|_| SoftchatError::InternalFailure)?;
4659        }
4660        SyncActionKind::SendNegMessage => {
4661            if action.subscription_id.is_empty() || action.message.is_empty() {
4662                return Err(SoftchatError::InvalidSyncState);
4663            }
4664            action.frame_json = serde_json::to_string(&serde_json::json!([
4665                "NEG-MSG",
4666                action.subscription_id,
4667                hex::encode(&action.message)
4668            ]))
4669            .map_err(|_| SoftchatError::InternalFailure)?;
4670        }
4671        SyncActionKind::SendNegClose => {
4672            if action.subscription_id.is_empty() {
4673                return Err(SoftchatError::InvalidSyncState);
4674            }
4675            action.frame_json =
4676                serde_json::to_string(&serde_json::json!(["NEG-CLOSE", action.subscription_id]))
4677                    .map_err(|_| SoftchatError::InternalFailure)?;
4678        }
4679        SyncActionKind::RequestEvents => {
4680            if action.subscription_id.is_empty() || action.event_ids.is_empty() {
4681                return Err(SoftchatError::InvalidSyncState);
4682            }
4683            action.filters_json.push(
4684                serde_json::to_string(&serde_json::json!({ "ids": action.event_ids }))
4685                    .map_err(|_| SoftchatError::InternalFailure)?,
4686            );
4687        }
4688        SyncActionKind::LoadSnapshot
4689        | SyncActionKind::SaveCheckpoint
4690        | SyncActionKind::ResendEvents
4691        | SyncActionKind::Complete => {}
4692    }
4693    Ok(action)
4694}
4695
4696pub(crate) fn inbox_filter_json(
4697    recipient_public_key: &str,
4698    since_timestamp: Option<i64>,
4699    until_timestamp: Option<i64>,
4700) -> Result<String, SoftchatError> {
4701    NostrPublicKey::from_hex(recipient_public_key)
4702        .map_err(|_| SoftchatError::InvalidRelayFilter)?;
4703    let since = since_timestamp
4704        .map(|value| u64::try_from(value).map_err(|_| SoftchatError::InvalidEventTimestamp))
4705        .transpose()?;
4706    let until = until_timestamp
4707        .map(|value| u64::try_from(value).map_err(|_| SoftchatError::InvalidEventTimestamp))
4708        .transpose()?;
4709    if since
4710        .into_iter()
4711        .chain(until)
4712        .any(|value| value > crate::MAX_PORTABLE_TIMESTAMP_SECONDS)
4713    {
4714        return Err(SoftchatError::InvalidEventTimestamp);
4715    }
4716    let mut generic_tags = BTreeMap::new();
4717    generic_tags.insert("#p".to_owned(), vec![recipient_public_key.to_owned()]);
4718    RelayFilter {
4719        kinds: vec![
4720            NostrEventKind::GIFT_WRAP.as_u16(),
4721            NostrEventKind::EPHEMERAL_GIFT_WRAP.as_u16(),
4722        ],
4723        since,
4724        until,
4725        generic_tags,
4726        ..RelayFilter::default()
4727    }
4728    .to_json()
4729}
4730
4731#[derive(Clone, Debug, Eq, PartialEq)]
4732pub(crate) enum SyncTransportFrame {
4733    Message {
4734        subscription_id: String,
4735        message: Vec<u8>,
4736    },
4737    Error {
4738        subscription_id: String,
4739        max_records: Option<u64>,
4740    },
4741    Control {
4742        subscription_id: String,
4743    },
4744}
4745
4746pub(crate) fn parse_sync_message_frame(
4747    frame_json: &str,
4748) -> Result<Option<SyncTransportFrame>, SoftchatError> {
4749    if frame_json.len() > crate::MAX_RELAY_FRAME_BYTES {
4750        return Err(SoftchatError::InvalidSyncState);
4751    }
4752    let Ok(serde_json::Value::Array(values)) =
4753        serde_json::from_str::<serde_json::Value>(frame_json)
4754    else {
4755        return Ok(None);
4756    };
4757    let Some(kind) = values.first().and_then(serde_json::Value::as_str) else {
4758        return Ok(None);
4759    };
4760    match kind {
4761        "NEG-MSG" => {
4762            if values.len() != 3 {
4763                return Err(SoftchatError::InvalidSyncState);
4764            }
4765            let subscription_id = values
4766                .get(1)
4767                .and_then(serde_json::Value::as_str)
4768                .ok_or(SoftchatError::InvalidSyncState)?
4769                .to_owned();
4770            let message_hex = values
4771                .get(2)
4772                .and_then(serde_json::Value::as_str)
4773                .ok_or(SoftchatError::InvalidSyncState)?;
4774            if message_hex.is_empty() || message_hex.len() > crate::MAX_RELAY_FRAME_BYTES {
4775                return Err(SoftchatError::InvalidSyncState);
4776            }
4777            let message = hex::decode(message_hex).map_err(|_| SoftchatError::InvalidSyncState)?;
4778            Ok(Some(SyncTransportFrame::Message {
4779                subscription_id,
4780                message,
4781            }))
4782        }
4783        "NEG-ERR" => {
4784            if !matches!(values.len(), 3 | 4) {
4785                return Err(SoftchatError::InvalidSyncState);
4786            }
4787            let subscription_id = values
4788                .get(1)
4789                .and_then(serde_json::Value::as_str)
4790                .filter(|value| !value.is_empty() && value.len() <= crate::MAX_RELAY_FRAME_BYTES)
4791                .ok_or(SoftchatError::InvalidSyncState)?
4792                .to_owned();
4793            values
4794                .get(2)
4795                .and_then(serde_json::Value::as_str)
4796                .filter(|value| value.len() <= crate::MAX_RELAY_FRAME_BYTES)
4797                .ok_or(SoftchatError::InvalidSyncState)?;
4798            let max_records = values
4799                .get(3)
4800                .map(|value| value.as_u64().ok_or(SoftchatError::InvalidSyncState))
4801                .transpose()?;
4802            Ok(Some(SyncTransportFrame::Error {
4803                subscription_id,
4804                max_records,
4805            }))
4806        }
4807        "NEG-OPEN" | "NEG-CLOSE" => {
4808            let expected_len = if kind == "NEG-OPEN" { 4 } else { 2 };
4809            if values.len() != expected_len {
4810                return Err(SoftchatError::InvalidSyncState);
4811            }
4812            let subscription_id = values
4813                .get(1)
4814                .and_then(serde_json::Value::as_str)
4815                .filter(|value| !value.is_empty() && value.len() <= crate::MAX_RELAY_FRAME_BYTES)
4816                .ok_or(SoftchatError::InvalidSyncState)?
4817                .to_owned();
4818            Ok(Some(SyncTransportFrame::Control { subscription_id }))
4819        }
4820        _ => Ok(None),
4821    }
4822}
4823
4824#[cfg(test)]
4825mod log_tests {
4826    use super::*;
4827
4828    #[test]
4829    fn ingestion_json_preflight_checks_count_bytes_and_overflow_before_parsing() {
4830        assert!(matches!(
4831            preflight_ingestion_jsons(&[]),
4832            Err(SoftchatError::InvalidAccountOperation)
4833        ));
4834        assert!(matches!(
4835            preflight_ingestion_jsons(&vec![
4836                "not-json".to_owned();
4837                crate::MAX_ACCOUNT_INGESTION_EVENTS + 1
4838            ]),
4839            Err(SoftchatError::InvalidAccountOperation)
4840        ));
4841        assert_eq!(
4842            bounded_ingestion_json_bytes([crate::MAX_ACCOUNT_INGESTION_BYTES]),
4843            Ok(crate::MAX_ACCOUNT_INGESTION_BYTES as u64)
4844        );
4845        assert!(matches!(
4846            bounded_ingestion_json_bytes([crate::MAX_ACCOUNT_INGESTION_BYTES, 1]),
4847            Err(SoftchatError::InvalidAccountOperation)
4848        ));
4849        assert!(matches!(
4850            bounded_ingestion_json_bytes([crate::MAX_ACCOUNT_INGESTION_BYTES, usize::MAX]),
4851            Err(SoftchatError::InvalidAccountOperation)
4852        ));
4853    }
4854
4855    #[test]
4856    fn diagnostic_ring_is_bounded_ordered_redacted_and_thresholded() -> Result<(), SoftchatError> {
4857        let logs = AccountLogBuffer::new(AccountLogLevel::Warn);
4858        logs.record(
4859            AccountLogLevel::Info,
4860            AccountLogCategory::Messaging,
4861            "message.send",
4862            None,
4863            None,
4864            AccountLogMetadata::EMPTY,
4865        );
4866        for index in 0..260 {
4867            logs.record(
4868                AccountLogLevel::Error,
4869                AccountLogCategory::Storage,
4870                "storage.ingest",
4871                None,
4872                Some(SoftchatError::InvalidEventJson),
4873                AccountLogMetadata {
4874                    revision: Some(i64::from(index)),
4875                    item_count: Some(1),
4876                    byte_count: Some(128),
4877                },
4878            );
4879        }
4880
4881        let first = logs.drain(MAX_ACCOUNT_LOG_BATCH)?;
4882        assert_eq!(first.dropped_count, 4);
4883        assert_eq!(first.events.len(), MAX_ACCOUNT_LOG_BATCH as usize);
4884        assert_eq!(first.events.first().map(|event| event.sequence), Some(5));
4885        assert_eq!(first.events.last().map(|event| event.sequence), Some(68));
4886        assert!(first.events.iter().all(|event| {
4887            event.operation == "storage.ingest"
4888                && event.error_code == "invalid_event_json"
4889                && event.item_count == Some(1)
4890                && event.byte_count == Some(128)
4891        }));
4892
4893        let second = logs.drain(1)?;
4894        assert_eq!(second.dropped_count, 0);
4895        assert_eq!(second.events[0].sequence, 69);
4896        assert_eq!(logs.drain(0), Err(SoftchatError::InvalidAccountOperation));
4897        assert_eq!(
4898            logs.drain(MAX_ACCOUNT_LOG_BATCH + 1),
4899            Err(SoftchatError::InvalidAccountOperation)
4900        );
4901        Ok(())
4902    }
4903
4904    #[test]
4905    fn consecutive_equivalent_diagnostics_are_counted_once() -> Result<(), SoftchatError> {
4906        let logs = AccountLogBuffer::new(AccountLogLevel::Info);
4907        for _ in 0..3 {
4908            logs.record(
4909                AccountLogLevel::Info,
4910                AccountLogCategory::Profile,
4911                "profile.follow",
4912                None,
4913                None,
4914                AccountLogMetadata {
4915                    revision: Some(7),
4916                    item_count: Some(1),
4917                    byte_count: None,
4918                },
4919            );
4920        }
4921
4922        let batch = logs.drain(1)?;
4923        assert_eq!(batch.events.len(), 1);
4924        assert_eq!(batch.events[0].sequence, 3);
4925        assert_eq!(batch.events[0].occurrence_count, 3);
4926        assert_eq!(batch.events[0].operation, "profile.follow");
4927        Ok(())
4928    }
4929
4930    #[test]
4931    fn disabled_diagnostics_do_not_retain_events() -> Result<(), SoftchatError> {
4932        let logs = AccountLogBuffer::new(AccountLogLevel::Off);
4933        logs.record(
4934            AccountLogLevel::Error,
4935            AccountLogCategory::Lifecycle,
4936            "account.open",
4937            None,
4938            Some(SoftchatError::InternalFailure),
4939            AccountLogMetadata::EMPTY,
4940        );
4941        assert_eq!(
4942            logs.drain(1)?,
4943            AccountLogBatch {
4944                events: Vec::new(),
4945                dropped_count: 0,
4946            }
4947        );
4948        Ok(())
4949    }
4950}
4951
4952#[cfg(test)]
4953mod transport_tests {
4954    use super::*;
4955
4956    const RECIPIENT: &str = "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d";
4957
4958    fn action(kind: SyncActionKind) -> SyncAction {
4959        SyncAction {
4960            kind,
4961            subscription_id: "sync-1".to_owned(),
4962            since_timestamp: 10,
4963            until_timestamp: 20,
4964            limit: 500,
4965            message: vec![1, 2, 3],
4966            frame_json: String::new(),
4967            filters_json: Vec::new(),
4968            event_ids: Vec::new(),
4969            checkpoint_timestamp: -1,
4970        }
4971    }
4972
4973    #[test]
4974    fn sync_transport_plans_complete_frames_and_filters() -> Result<(), Box<dyn std::error::Error>>
4975    {
4976        let open = plan_sync_transport(action(SyncActionKind::SendNegOpen), RECIPIENT)?;
4977        let open_json = serde_json::from_str::<serde_json::Value>(&open.frame_json)?;
4978        assert_eq!(open_json[0], "NEG-OPEN");
4979        assert_eq!(open_json[2]["kinds"], serde_json::json!([1059, 21059]));
4980        assert_eq!(open_json[2]["#p"], serde_json::json!([RECIPIENT]));
4981        assert_eq!(open_json[2]["since"], 10);
4982        assert_eq!(open_json[2]["until"], 20);
4983
4984        let mut request = action(SyncActionKind::RequestEvents);
4985        request.message.clear();
4986        request.event_ids = vec![hex::encode([7_u8; 32])];
4987        let request = plan_sync_transport(request, RECIPIENT)?;
4988        assert_eq!(request.filters_json.len(), 1);
4989        let filter_json = serde_json::from_str::<serde_json::Value>(&request.filters_json[0])?;
4990        assert_eq!(filter_json["ids"][0], hex::encode([7_u8; 32]));
4991        Ok(())
4992    }
4993
4994    #[test]
4995    fn inbox_filter_is_canonical_bounded_and_account_addressed()
4996    -> Result<(), Box<dyn std::error::Error>> {
4997        let filter = inbox_filter_json(RECIPIENT, Some(10), Some(20))?;
4998        assert_eq!(
4999            filter,
5000            format!(r##"{{"kinds":[1059,21059],"since":10,"until":20,"#p":["{RECIPIENT}"]}}"##)
5001        );
5002        assert!(inbox_filter_json(RECIPIENT, Some(-1), None).is_err());
5003        let maximum_timestamp = i64::try_from(crate::MAX_PORTABLE_TIMESTAMP_SECONDS)?;
5004        assert!(inbox_filter_json(RECIPIENT, Some(maximum_timestamp), None).is_ok());
5005        Ok(())
5006    }
5007
5008    #[test]
5009    fn sync_frame_router_accepts_only_bounded_correlated_shapes()
5010    -> Result<(), Box<dyn std::error::Error>> {
5011        assert_eq!(
5012            parse_sync_message_frame(r#"["NEG-MSG","sync-1","010203"]"#)?,
5013            Some(SyncTransportFrame::Message {
5014                subscription_id: "sync-1".to_owned(),
5015                message: vec![1, 2, 3],
5016            })
5017        );
5018        assert!(parse_sync_message_frame(r#"["EVENT","sync-1",{}]"#)?.is_none());
5019        assert!(parse_sync_message_frame(r#"["NEG-MSG","sync-1","zz"]"#).is_err());
5020        assert_eq!(
5021            parse_sync_message_frame(r#"["NEG-ERR","sync-1","failure",500]"#)?,
5022            Some(SyncTransportFrame::Error {
5023                subscription_id: "sync-1".to_owned(),
5024                max_records: Some(500),
5025            })
5026        );
5027        assert_eq!(
5028            parse_sync_message_frame(r#"["NEG-CLOSE","sync-1"]"#)?,
5029            Some(SyncTransportFrame::Control {
5030                subscription_id: "sync-1".to_owned(),
5031            })
5032        );
5033        Ok(())
5034    }
5035}
5036
5037#[cfg(test)]
5038mod product_tests {
5039    use super::*;
5040    use crate::{
5041        AccountTransportAction, AccountTransportActionKind, AccountTransportResultKind,
5042        RelayConnectionState, RelayResponseFrame, SyncPhase,
5043    };
5044
5045    const ALICE_SECRET: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
5046    const BOB_SECRET: &str = "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d";
5047
5048    fn secret(value: &str) -> Result<Vec<u8>, SoftchatError> {
5049        hex::decode(value).map_err(|_| SoftchatError::InvalidSecretKey)
5050    }
5051
5052    fn temporary_directory(label: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
5053        let nonce = std::time::SystemTime::now()
5054            .duration_since(std::time::UNIX_EPOCH)?
5055            .as_nanos();
5056        let path =
5057            std::env::temp_dir().join(format!("softchat-{label}-{}-{nonce}", std::process::id()));
5058        std::fs::create_dir_all(&path)?;
5059        Ok(path)
5060    }
5061
5062    fn transport_result(
5063        action: &AccountTransportAction,
5064        kind: AccountTransportResultKind,
5065        text_frame: String,
5066    ) -> AccountTransportResult {
5067        AccountTransportResult {
5068            run_id: action.run_id.clone(),
5069            action_id: action.action_id.clone(),
5070            generation: action.generation,
5071            kind,
5072            text_frame,
5073        }
5074    }
5075
5076    fn transport_action(
5077        batch: &AccountTransportBatch,
5078        kind: AccountTransportActionKind,
5079    ) -> Result<AccountTransportAction, SoftchatError> {
5080        batch
5081            .actions
5082            .iter()
5083            .find(|action| action.kind == kind)
5084            .cloned()
5085            .ok_or(SoftchatError::UnsupportedSystemAction)
5086    }
5087
5088    fn neg_subscription_id(
5089        batch: &AccountTransportBatch,
5090    ) -> Result<String, Box<dyn std::error::Error>> {
5091        for action in &batch.actions {
5092            if action.kind != AccountTransportActionKind::SendText {
5093                continue;
5094            }
5095            let value = serde_json::from_str::<serde_json::Value>(&action.text_frame)?;
5096            if value.get(0).and_then(serde_json::Value::as_str) == Some("NEG-OPEN") {
5097                return value
5098                    .get(1)
5099                    .and_then(serde_json::Value::as_str)
5100                    .map(ToOwned::to_owned)
5101                    .ok_or_else(|| SoftchatError::InvalidSyncState.into());
5102            }
5103        }
5104        Err(SoftchatError::InvalidSyncState.into())
5105    }
5106
5107    fn delivery_send_actions(
5108        batch: &AccountTransportBatch,
5109    ) -> Result<Vec<(AccountTransportAction, Vec<String>)>, SoftchatError> {
5110        let mut deliveries = Vec::new();
5111        for action in &batch.actions {
5112            if action.kind != AccountTransportActionKind::SendText {
5113                continue;
5114            }
5115            let event_ids = match crate::parse_client_relay_frame(&action.text_frame) {
5116                Ok(crate::ClientRelayFrame::Event(event)) => vec![event.id().to_hex()],
5117                Ok(crate::ClientRelayFrame::Events(events)) => {
5118                    events.iter().map(|event| event.id().to_hex()).collect()
5119                }
5120                Ok(_) | Err(_) => Vec::new(),
5121            };
5122            if !event_ids.is_empty() {
5123                deliveries.push((action.clone(), event_ids));
5124            }
5125        }
5126        Ok(deliveries)
5127    }
5128
5129    fn content(text: &str) -> MessageContentInput {
5130        MessageContentInput {
5131            text: text.to_owned(),
5132            attachments: Vec::new(),
5133            emoji_tags: Vec::new(),
5134        }
5135    }
5136
5137    #[test]
5138    fn draftless_command_hashes_preserve_the_released_shapes()
5139    -> Result<(), Box<dyn std::error::Error>> {
5140        let conversation_id = "ab".repeat(32);
5141        let message = content("legacy command");
5142        let reply_to_message_id = String::new();
5143        let sources = vec![crate::MediaPreparationInput {
5144            source_fingerprint: "legacy-source".to_owned(),
5145        }];
5146
5147        let legacy_send =
5148            command_hash(&("send", &conversation_id, &message, &reply_to_message_id))?;
5149        assert_eq!(
5150            send_message_command_hash(&conversation_id, &message, &reply_to_message_id, None,)?,
5151            legacy_send,
5152        );
5153
5154        let legacy_media = command_hash(&(
5155            "media-message",
5156            &conversation_id,
5157            &message,
5158            &reply_to_message_id,
5159            &sources,
5160        ))?;
5161        assert_eq!(
5162            media_message_command_hash(
5163                &conversation_id,
5164                &message,
5165                &reply_to_message_id,
5166                &sources,
5167                None,
5168            )?,
5169            legacy_media,
5170        );
5171
5172        let draft_match = product_draft_match(AccountDraft {
5173            text: "legacy command".to_owned(),
5174            emoji_tags: Vec::new(),
5175            spans: Vec::new(),
5176            reply_to_message_id: String::new(),
5177            attachment_operation_ids: Vec::new(),
5178            updated_at: 1_700_000_000,
5179        })?;
5180        let draft_send = send_message_command_hash(
5181            &conversation_id,
5182            &message,
5183            &reply_to_message_id,
5184            Some(&draft_match),
5185        );
5186        assert_eq!(
5187            draft_send?,
5188            command_hash(&(
5189                "send",
5190                &conversation_id,
5191                &message,
5192                &reply_to_message_id,
5193                &Some(draft_match.clone()),
5194            ))?,
5195        );
5196        assert_ne!(
5197            send_message_command_hash(
5198                &conversation_id,
5199                &message,
5200                &reply_to_message_id,
5201                Some(&draft_match),
5202            )?,
5203            legacy_send,
5204        );
5205        assert_eq!(
5206            media_message_command_hash(
5207                &conversation_id,
5208                &message,
5209                &reply_to_message_id,
5210                &sources,
5211                Some(&draft_match),
5212            )?,
5213            command_hash(&(
5214                "media-message",
5215                &conversation_id,
5216                &message,
5217                &reply_to_message_id,
5218                &sources,
5219                &Some(draft_match.clone()),
5220            ))?,
5221        );
5222        assert_ne!(
5223            media_message_command_hash(
5224                &conversation_id,
5225                &message,
5226                &reply_to_message_id,
5227                &sources,
5228                Some(&draft_match),
5229            )?,
5230            legacy_media,
5231        );
5232        Ok(())
5233    }
5234
5235    #[test]
5236    fn delete_message_rejects_an_oversized_id_before_command_work()
5237    -> Result<(), Box<dyn std::error::Error>> {
5238        let directory = temporary_directory("delete-message-bound")?;
5239        let account =
5240            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
5241
5242        assert!(matches!(
5243            account.delete_message(
5244                "delete-message-oversized-id".to_owned(),
5245                "a".repeat(1_000_000),
5246                1_700_000_000,
5247            ),
5248            Err(SoftchatError::InvalidAccountOperation)
5249        ));
5250
5251        drop(account);
5252        std::fs::remove_dir_all(directory)?;
5253        Ok(())
5254    }
5255
5256    #[test]
5257    fn product_message_preserves_a_sticker_name_with_spaces()
5258    -> Result<(), Box<dyn std::error::Error>> {
5259        let directory = temporary_directory("sticker-message")?;
5260        let account =
5261            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
5262        let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
5263        let conversation =
5264            account.get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
5265        let emoji_tags = vec![vec![
5266            "emoji".to_owned(),
5267            "Device sticker".to_owned(),
5268            "https://cdn.example/device-sticker.webp".to_owned(),
5269        ]];
5270
5271        let sent = account.send_message(
5272            "sticker-message".to_owned(),
5273            conversation.id,
5274            MessageContentInput {
5275                text: ":Device sticker:".to_owned(),
5276                attachments: Vec::new(),
5277                emoji_tags: emoji_tags.clone(),
5278            },
5279            String::new(),
5280            None,
5281            1_700_000_001,
5282        )?;
5283
5284        let sender_copy = sent
5285            .message
5286            .ok_or_else(|| std::io::Error::other("missing stored sender copy"))?;
5287        assert_eq!(sender_copy.emoji_tags, emoji_tags);
5288        drop(account);
5289        std::fs::remove_dir_all(directory)?;
5290        Ok(())
5291    }
5292
5293    #[test]
5294    fn signed_outbox_recovery_is_bounded_verified_and_idempotent()
5295    -> Result<(), Box<dyn std::error::Error>> {
5296        let directory = temporary_directory("signed-outbox-recovery")?;
5297        let account =
5298            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
5299        let identity = crate::LocalIdentity::from_secret_hex(ALICE_SECRET)?;
5300        let event = identity.sign_event(crate::NostrEventDraft::new(
5301            1_700_000_000,
5302            NostrEventKind::SHORT_TEXT_NOTE,
5303            Vec::new(),
5304            "recover me",
5305        )?)?;
5306        let event_value = serde_json::from_str::<serde_json::Value>(&event.to_json()?)?;
5307        let batch = serde_json::to_string(&vec![event_value.clone()])?;
5308
5309        let inserted = account.recover_signed_outbox_batch(
5310            "legacy-batch-1".to_owned(),
5311            batch.clone(),
5312            1_700_000_001,
5313        )?;
5314        assert!(inserted.inserted);
5315        let repeated = account.recover_signed_outbox_batch(
5316            "legacy-batch-1".to_owned(),
5317            batch,
5318            1_700_000_001,
5319        )?;
5320        assert!(!repeated.inserted);
5321        assert_eq!(inserted.operation, repeated.operation);
5322
5323        let mut forged = event_value;
5324        forged["content"] = serde_json::Value::String("forged".to_owned());
5325        assert!(matches!(
5326            account.recover_signed_outbox_batch(
5327                "legacy-batch-forged".to_owned(),
5328                serde_json::to_string(&vec![forged])?,
5329                1_700_000_001,
5330            ),
5331            Err(SoftchatError::InvalidEventId)
5332        ));
5333        let second = identity.sign_event(crate::NostrEventDraft::new(
5334            1_700_000_001,
5335            NostrEventKind::SHORT_TEXT_NOTE,
5336            Vec::new(),
5337            "different recovery",
5338        )?)?;
5339        assert!(matches!(
5340            account.recover_signed_outbox_batch(
5341                "legacy-batch-1".to_owned(),
5342                serde_json::to_string(&vec![serde_json::from_str::<serde_json::Value>(
5343                    &second.to_json()?
5344                )?])?,
5345                1_700_000_002,
5346            ),
5347            Err(SoftchatError::CommandConflict)
5348        ));
5349        assert!(matches!(
5350            account.recover_signed_outbox_batch(
5351                "legacy-batch-oversized".to_owned(),
5352                " ".repeat(crate::MAX_NOSTR_EVENT_JSON_BYTES + 1),
5353                1_700_000_001,
5354            ),
5355            Err(SoftchatError::InvalidAccountOperation)
5356        ));
5357
5358        drop(account);
5359        std::fs::remove_dir_all(directory)?;
5360        Ok(())
5361    }
5362
5363    #[test]
5364    fn account_derives_path_rejects_duplicate_open_and_erases_on_close()
5365    -> Result<(), Box<dyn std::error::Error>> {
5366        let directory = temporary_directory("account-open")?;
5367        let first =
5368            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
5369        assert_eq!(first.account_id(), first.public_key()?);
5370        assert!(first.database_filename().starts_with("softchat-account-"));
5371        assert!(first.database_filename().ends_with(".sqlite3"));
5372        assert!(matches!(
5373            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string()),
5374            Err(SoftchatError::AccountAlreadyOpen)
5375        ));
5376        first.close_account();
5377        assert!(matches!(
5378            first.public_key(),
5379            Err(SoftchatError::AccountClosed)
5380        ));
5381        drop(first);
5382        let reopened =
5383            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
5384        drop(reopened);
5385        std::fs::remove_dir_all(directory)?;
5386        Ok(())
5387    }
5388
5389    #[test]
5390    fn send_is_payload_idempotent_and_returns_the_original_logical_message()
5391    -> Result<(), Box<dyn std::error::Error>> {
5392        let directory = temporary_directory("command-idempotency")?;
5393        let account =
5394            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
5395        let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
5396        let conversation =
5397            account.get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
5398
5399        let first = account.send_message(
5400            "command-send-1".to_owned(),
5401            conversation.id.clone(),
5402            content("one durable message"),
5403            String::new(),
5404            None,
5405            1_700_000_001,
5406        )?;
5407        let retried = account.send_message(
5408            "command-send-1".to_owned(),
5409            conversation.id.clone(),
5410            content("one durable message"),
5411            String::new(),
5412            None,
5413            1_700_000_999,
5414        )?;
5415        assert!(first.inserted);
5416        assert!(!retried.inserted);
5417        assert_eq!(first.operation, retried.operation);
5418        assert_eq!(first.message, retried.message);
5419        assert_eq!(
5420            first.message.as_ref().map(|message| message.text.as_str()),
5421            Some("one durable message")
5422        );
5423        assert!(matches!(
5424            account.send_message(
5425                "command-send-1".to_owned(),
5426                conversation.id,
5427                content("different payload"),
5428                String::new(),
5429                None,
5430                1_700_001_000,
5431            ),
5432            Err(SoftchatError::CommandConflict)
5433        ));
5434        drop(account);
5435        std::fs::remove_dir_all(directory)?;
5436        Ok(())
5437    }
5438
5439    #[test]
5440    fn sending_can_consume_only_the_draft_owned_by_the_new_command()
5441    -> Result<(), Box<dyn std::error::Error>> {
5442        let directory = temporary_directory("send-consumes-draft")?;
5443        let account =
5444            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
5445        let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
5446        let conversation =
5447            account.get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
5448        let first_draft = AccountDraft {
5449            text: "send this".to_owned(),
5450            emoji_tags: Vec::new(),
5451            spans: Vec::new(),
5452            reply_to_message_id: String::new(),
5453            attachment_operation_ids: Vec::new(),
5454            updated_at: 1_700_000_001,
5455        };
5456        account.save_conversation_draft(
5457            conversation.id.clone(),
5458            Some(first_draft.clone()),
5459            1_700_000_001,
5460        )?;
5461
5462        let sent = account.send_message(
5463            "send-consumes-draft-command".to_owned(),
5464            conversation.id.clone(),
5465            content("send this"),
5466            String::new(),
5467            Some(first_draft.clone()),
5468            1_700_000_002,
5469        )?;
5470        assert!(sent.inserted);
5471        assert!(
5472            account
5473                .conversation(conversation.id.clone())?
5474                .ok_or(SoftchatError::InvalidPersistenceResult)?
5475                .draft
5476                .is_none()
5477        );
5478
5479        let newer_draft = AccountDraft {
5480            text: "typed after send".to_owned(),
5481            emoji_tags: Vec::new(),
5482            spans: Vec::new(),
5483            reply_to_message_id: String::new(),
5484            attachment_operation_ids: Vec::new(),
5485            updated_at: 1_700_000_003,
5486        };
5487        account.save_conversation_draft(
5488            conversation.id.clone(),
5489            Some(newer_draft.clone()),
5490            1_700_000_003,
5491        )?;
5492        let raced = account.send_message(
5493            "send-preserves-raced-draft-command".to_owned(),
5494            conversation.id.clone(),
5495            content("send this"),
5496            String::new(),
5497            Some(first_draft.clone()),
5498            1_700_000_004,
5499        )?;
5500        assert!(raced.inserted);
5501        assert_eq!(
5502            account
5503                .conversation(conversation.id.clone())?
5504                .ok_or(SoftchatError::InvalidPersistenceResult)?
5505                .draft,
5506            Some(newer_draft.clone())
5507        );
5508        let replay = account.send_message(
5509            "send-consumes-draft-command".to_owned(),
5510            conversation.id.clone(),
5511            content("send this"),
5512            String::new(),
5513            Some(first_draft),
5514            1_700_000_005,
5515        )?;
5516        assert!(!replay.inserted);
5517        assert_eq!(
5518            account
5519                .conversation(conversation.id.clone())?
5520                .ok_or(SoftchatError::InvalidPersistenceResult)?
5521                .draft,
5522            Some(newer_draft)
5523        );
5524        assert!(matches!(
5525            account.send_message(
5526                "send-consumes-draft-command".to_owned(),
5527                conversation.id,
5528                content("send this"),
5529                String::new(),
5530                None,
5531                1_700_000_006,
5532            ),
5533            Err(SoftchatError::CommandConflict)
5534        ));
5535
5536        drop(account);
5537        std::fs::remove_dir_all(directory)?;
5538        Ok(())
5539    }
5540
5541    #[test]
5542    fn media_preparation_clears_only_the_matching_draft_snapshot()
5543    -> Result<(), Box<dyn std::error::Error>> {
5544        let directory = temporary_directory("media-message-draft-race")?;
5545        let account =
5546            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
5547        let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
5548        let conversation =
5549            account.get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
5550        let expected = AccountDraft {
5551            text: "attachment".to_owned(),
5552            emoji_tags: Vec::new(),
5553            spans: Vec::new(),
5554            reply_to_message_id: String::new(),
5555            attachment_operation_ids: Vec::new(),
5556            updated_at: 1_700_000_001,
5557        };
5558        let newer = AccountDraft {
5559            text: "typed while staging".to_owned(),
5560            updated_at: 1_700_000_002,
5561            ..expected.clone()
5562        };
5563        account.save_conversation_draft(
5564            conversation.id.clone(),
5565            Some(newer.clone()),
5566            1_700_000_002,
5567        )?;
5568        let prepared = account.prepare_media_message(
5569            "media-message-draft-race-command".to_owned(),
5570            conversation.id.clone(),
5571            content("attachment"),
5572            String::new(),
5573            vec![crate::MediaPreparationInput {
5574                source_fingerprint: "media-message-draft-race-source".to_owned(),
5575            }],
5576            Some(expected),
5577            1_700_000_003,
5578        )?;
5579        assert_eq!(
5580            prepared.state,
5581            crate::PendingMediaMessageState::WaitingForUploads
5582        );
5583        assert_eq!(
5584            account
5585                .conversation(conversation.id)?
5586                .ok_or(SoftchatError::InvalidPersistenceResult)?
5587                .draft,
5588            Some(newer)
5589        );
5590
5591        drop(account);
5592        std::fs::remove_dir_all(directory)?;
5593        Ok(())
5594    }
5595
5596    #[test]
5597    fn delete_many_is_atomic_bounded_and_retry_idempotent() -> Result<(), Box<dyn std::error::Error>>
5598    {
5599        let directory = temporary_directory("delete-many")?;
5600        let account =
5601            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
5602        let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
5603        let conversation =
5604            account.get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
5605        let first = account
5606            .send_message(
5607                "delete-many-first".to_owned(),
5608                conversation.id.clone(),
5609                content("first"),
5610                String::new(),
5611                None,
5612                1_700_000_001,
5613            )?
5614            .message
5615            .ok_or(SoftchatError::InvalidPersistenceResult)?;
5616        let second = account
5617            .send_message(
5618                "delete-many-second".to_owned(),
5619                conversation.id.clone(),
5620                content("second"),
5621                String::new(),
5622                None,
5623                1_700_000_002,
5624            )?
5625            .message
5626            .ok_or(SoftchatError::InvalidPersistenceResult)?;
5627        let targets = vec![first.id.clone(), second.id.clone()];
5628
5629        assert!(matches!(
5630            account.delete_messages(
5631                "delete-many-oversized-id".to_owned(),
5632                vec!["a".repeat(1024 * 1024)],
5633                1_700_000_003,
5634            ),
5635            Err(SoftchatError::InvalidAccountOperation)
5636        ));
5637        assert!(matches!(
5638            account.delete_messages(
5639                "delete-many-malformed-id".to_owned(),
5640                vec!["z".repeat(64)],
5641                1_700_000_003,
5642            ),
5643            Err(SoftchatError::InvalidEventId)
5644        ));
5645        assert!(matches!(
5646            account.delete_messages(
5647                "delete-many-duplicate".to_owned(),
5648                vec![first.id.clone(), first.id.clone()],
5649                1_700_000_003,
5650            ),
5651            Err(SoftchatError::InvalidAccountOperation)
5652        ));
5653        let deleted = account.delete_messages(
5654            "delete-many-command".to_owned(),
5655            targets.clone(),
5656            1_700_000_004,
5657        )?;
5658        assert!(deleted.inserted);
5659        let retried = account.delete_messages(
5660            "delete-many-command".to_owned(),
5661            targets.clone(),
5662            1_700_000_999,
5663        )?;
5664        assert!(!retried.inserted);
5665        assert_eq!(retried.operation, deleted.operation);
5666        assert!(matches!(
5667            account.delete_messages(
5668                "delete-many-command".to_owned(),
5669                targets.into_iter().rev().collect(),
5670                1_700_001_000,
5671            ),
5672            Err(SoftchatError::CommandConflict)
5673        ));
5674        let page = account.message_page(conversation.id, None, 50)?;
5675        assert!(
5676            page.messages
5677                .iter()
5678                .filter(|message| message.id == first.id || message.id == second.id)
5679                .all(|message| message.deleted)
5680        );
5681
5682        drop(account);
5683        std::fs::remove_dir_all(directory)?;
5684        Ok(())
5685    }
5686
5687    #[test]
5688    fn profile_patch_is_lossless_and_uses_the_account_owned_self_copy()
5689    -> Result<(), Box<dyn std::error::Error>> {
5690        let directory = temporary_directory("profile-patch")?;
5691        let account =
5692            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
5693        account.update_profile(
5694            "profile-first".to_owned(),
5695            r#"{"name":"alice","picture":"https://cdn.example/avatar.png","website":"https://example.com"}"#.to_owned(),
5696            1_700_000_001,
5697        )?;
5698        account.update_profile(
5699            "profile-second".to_owned(),
5700            r#"{"about":"updated"}"#.to_owned(),
5701            1_700_000_002,
5702        )?;
5703        let profile = account.profile(account.public_key()?)?;
5704        assert_eq!(profile.name.as_deref(), Some("alice"));
5705        assert_eq!(profile.about.as_deref(), Some("updated"));
5706        assert_eq!(
5707            profile.picture.as_deref(),
5708            Some("https://cdn.example/avatar.png")
5709        );
5710        assert_eq!(profile.website.as_deref(), Some("https://example.com"));
5711
5712        let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
5713        account.replace_follows(
5714            "follow-first".to_owned(),
5715            vec![Contact {
5716                public_key: bob.public_key().to_hex(),
5717                relay_hint: String::new(),
5718                local_name: "Bob".to_owned(),
5719            }],
5720            1_700_000_003,
5721        )?;
5722        assert!(
5723            account
5724                .conversation_page(false, None, 100)?
5725                .conversations
5726                .is_empty(),
5727            "account-data p tags must not create product conversations"
5728        );
5729        account.replace_follows("follow-second".to_owned(), Vec::new(), 1_700_000_003)?;
5730        assert!(account.follows()?.is_empty());
5731        account.follow(
5732            "follow-atomic".to_owned(),
5733            Contact {
5734                public_key: bob.public_key().to_hex(),
5735                relay_hint: "wss://relay.example".to_owned(),
5736                local_name: "Robert".to_owned(),
5737            },
5738            1_700_000_003,
5739        )?;
5740        assert_eq!(account.follows()?.len(), 1);
5741        assert_eq!(account.follows()?[0].local_name, "Robert");
5742        account.unfollow(
5743            "unfollow-atomic".to_owned(),
5744            bob.public_key().to_hex(),
5745            1_700_000_003,
5746        )?;
5747        assert!(account.follows()?.is_empty());
5748
5749        account.apply_account_settings_patch(
5750            "settings-first".to_owned(),
5751            r#"{"quickReaction":"one"}"#.to_owned(),
5752            1_700_000_004,
5753        )?;
5754        let first_settings_at = account.database()?.connection.query_row(
5755            "SELECT event_created_at FROM app_data_effective WHERE context = 'app-settings'",
5756            [],
5757            |row| row.get::<_, i64>(0),
5758        )?;
5759        account.apply_account_settings_patch(
5760            "settings-second".to_owned(),
5761            r#"{"quickReaction":"two"}"#.to_owned(),
5762            1_700_000_004,
5763        )?;
5764        let second_settings_at = account.database()?.connection.query_row(
5765            "SELECT event_created_at FROM app_data_effective WHERE context = 'app-settings'",
5766            [],
5767            |row| row.get::<_, i64>(0),
5768        )?;
5769        assert!(second_settings_at > first_settings_at);
5770        assert_eq!(
5771            account.account_settings()?.quick_reaction.as_deref(),
5772            Some("two")
5773        );
5774
5775        let conversation =
5776            account.get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_005)?;
5777        let message = account
5778            .send_message(
5779                "read-state-message".to_owned(),
5780                conversation.id.clone(),
5781                content("read state"),
5782                String::new(),
5783                None,
5784                1_700_000_006,
5785            )?
5786            .message
5787            .ok_or(SoftchatError::InvalidPersistenceResult)?;
5788        account.mark_conversation_read(
5789            "mark-read-first".to_owned(),
5790            conversation.id.clone(),
5791            MessageCursor {
5792                created_at: message.created_at,
5793                message_id: message.id,
5794            },
5795            1_700_000_007,
5796        )?;
5797        let read_at = account.account_read_state()?[0].updated_at;
5798        account.mark_conversation_unread(
5799            "mark-unread-second".to_owned(),
5800            conversation.id,
5801            1_700_000_007,
5802        )?;
5803        let unread = account.account_read_state()?.remove(0);
5804        assert!(unread.forced_unread);
5805        assert!(unread.updated_at > read_at);
5806        drop(account);
5807        std::fs::remove_dir_all(directory)?;
5808        Ok(())
5809    }
5810
5811    #[test]
5812    fn message_tuple_cursor_is_lossless_for_equal_timestamps()
5813    -> Result<(), Box<dyn std::error::Error>> {
5814        let directory = temporary_directory("message-cursor")?;
5815        let account =
5816            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
5817        let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
5818        let conversation =
5819            account.get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
5820        for (command, text) in [("equal-time-1", "alpha"), ("equal-time-2", "beta")] {
5821            account.send_message(
5822                command.to_owned(),
5823                conversation.id.clone(),
5824                content(text),
5825                String::new(),
5826                None,
5827                1_700_000_001,
5828            )?;
5829        }
5830        let first = account.message_page(conversation.id.clone(), None, 1)?;
5831        let second = account.message_page(conversation.id, first.next_cursor.clone(), 1)?;
5832        assert_eq!(first.messages.len(), 1);
5833        assert_eq!(second.messages.len(), 1);
5834        assert_ne!(first.messages[0].id, second.messages[0].id);
5835        assert_eq!(first.messages[0].created_at, second.messages[0].created_at);
5836        assert!(second.next_cursor.is_none());
5837        drop(account);
5838        std::fs::remove_dir_all(directory)?;
5839        Ok(())
5840    }
5841
5842    #[test]
5843    fn product_pages_batch_complete_effective_state() -> Result<(), Box<dyn std::error::Error>> {
5844        let directory = temporary_directory("batched-product-pages")?;
5845        let account =
5846            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
5847        let alice = crate::LocalIdentity::from_secret_hex(ALICE_SECRET)?;
5848        let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
5849        let carol = crate::LocalIdentity::from_secret_hex(
5850            "0000000000000000000000000000000000000000000000000000000000000002",
5851        )?;
5852        let conversation = account.get_or_create_conversation(
5853            vec![bob.public_key().to_hex(), carol.public_key().to_hex()],
5854            1_700_000_000,
5855        )?;
5856        let first = account
5857            .send_message(
5858                "batch-first".to_owned(),
5859                conversation.id.clone(),
5860                content("first"),
5861                String::new(),
5862                None,
5863                1_700_000_001,
5864            )?
5865            .message
5866            .ok_or(SoftchatError::InvalidPersistenceResult)?;
5867        let second = account
5868            .send_message(
5869                "batch-second".to_owned(),
5870                conversation.id.clone(),
5871                content("second"),
5872                String::new(),
5873                None,
5874                1_700_000_002,
5875            )?
5876            .message
5877            .ok_or(SoftchatError::InvalidPersistenceResult)?;
5878        account.edit_message(
5879            "batch-edit".to_owned(),
5880            first.id.clone(),
5881            content("edited"),
5882            1_700_000_003,
5883        )?;
5884        let reaction = bob.create_reaction(ReactionDraft {
5885            participants: vec![alice.public_key(), carol.public_key()],
5886            parent_id: NostrEventId::from_hex(&first.id)?,
5887            parent_author: alice.public_key(),
5888            parent_kind: NostrEventKind::PRIVATE_DIRECT_MESSAGE,
5889            reaction: "👍".to_owned(),
5890            custom_emoji_url: None,
5891            created_at: 1_700_000_004,
5892        })?;
5893        let reaction_rumor = crate::NostrRumor::try_from(reaction.rumor)?;
5894        let reaction_wrap = bob.gift_wrap(
5895            &alice.public_key(),
5896            &reaction_rumor,
5897            Nip59EnvelopeKind::Durable,
5898            1_700_000_004,
5899        )?;
5900        account.ingest(vec![SignedEvent::from(&reaction_wrap)], 1_700_000_004)?;
5901        account.delete_message("batch-delete".to_owned(), second.id.clone(), 1_700_000_005)?;
5902        account.set_conversation_subject(
5903            "batch-subject".to_owned(),
5904            conversation.id.clone(),
5905            "Batched".to_owned(),
5906            None,
5907            Vec::new(),
5908            1_700_000_006,
5909        )?;
5910        account.update_profile(
5911            "batch-profile".to_owned(),
5912            r#"{"name":"alice"}"#.to_owned(),
5913            1_700_000_007,
5914        )?;
5915
5916        let messages = account.message_page(conversation.id.clone(), None, 50)?;
5917        let effective_first = messages
5918            .messages
5919            .iter()
5920            .find(|message| message.id == first.id)
5921            .ok_or(SoftchatError::InvalidPersistenceResult)?;
5922        assert_eq!(effective_first.text, "edited");
5923        assert!(effective_first.edited);
5924        assert_eq!(effective_first.reactions.len(), 1);
5925        assert_eq!(effective_first.reactions[0].value, "👍");
5926        assert!(
5927            messages
5928                .messages
5929                .iter()
5930                .any(|message| message.id == second.id && message.deleted)
5931        );
5932
5933        // Exact lookup shares the effective projection, independently of the page window.
5934        let exact = account.messages_by_ids(vec![
5935            first.id.to_uppercase(),
5936            "00".repeat(32),
5937            second.id.clone(),
5938        ])?;
5939        assert_eq!(exact.len(), 2);
5940        assert_eq!(&exact[0], effective_first);
5941        assert_eq!(exact[1].id, second.id);
5942        assert!(exact[1].deleted);
5943        assert!(account.messages_by_ids(Vec::new())?.is_empty());
5944        assert!(matches!(
5945            account.messages_by_ids(vec![first.id.clone(), first.id.to_uppercase()]),
5946            Err(SoftchatError::InvalidAccountOperation)
5947        ));
5948        let maximum_ids = (0..crate::MAX_PRODUCT_MESSAGE_LOOKUPS)
5949            .map(|index| format!("{index:064x}"))
5950            .collect::<Vec<_>>();
5951        assert!(account.messages_by_ids(maximum_ids)?.is_empty());
5952        assert!(matches!(
5953            account.messages_by_ids(vec![String::new(); crate::MAX_PRODUCT_MESSAGE_LOOKUPS + 1]),
5954            Err(SoftchatError::InvalidAccountOperation)
5955        ));
5956        assert!(account.messages_by_ids(vec!["invalid".to_owned()]).is_err());
5957
5958        let conversations = account.conversation_page(false, None, 50)?;
5959        assert_eq!(conversations.conversations.len(), 1);
5960        assert_eq!(conversations.conversations[0].subject, "Batched");
5961        assert_eq!(conversations.conversations[0].message_count, 1);
5962        assert_eq!(
5963            conversations.conversations[0]
5964                .last_message
5965                .as_ref()
5966                .map(|message| message.id.as_str()),
5967            Some(first.id.as_str())
5968        );
5969        let own_public_key = account.public_key()?;
5970        assert_eq!(
5971            account
5972                .profile_page(50, 0)?
5973                .into_iter()
5974                .find(|profile| profile.public_key == own_public_key)
5975                .and_then(|profile| profile.name),
5976            Some("alice".to_owned())
5977        );
5978        let bob_public_key = bob.public_key().to_hex();
5979        let exact_profiles =
5980            account.profiles(vec![bob_public_key.clone(), own_public_key.clone()])?;
5981        assert_eq!(
5982            exact_profiles
5983                .iter()
5984                .map(|profile| profile.public_key.as_str())
5985                .collect::<Vec<_>>(),
5986            vec![bob_public_key.as_str(), own_public_key.as_str()]
5987        );
5988        assert!(exact_profiles[0].name.is_none());
5989        assert_eq!(exact_profiles[1].name.as_deref(), Some("alice"));
5990        assert!(matches!(
5991            account.profiles(vec![own_public_key; crate::MAX_PRODUCT_PROFILE_LOOKUPS + 1]),
5992            Err(SoftchatError::InvalidAccountOperation)
5993        ));
5994
5995        drop(account);
5996        std::fs::remove_dir_all(directory)?;
5997        Ok(())
5998    }
5999
6000    #[test]
6001    fn message_local_extras_are_bounded_revisioned_and_account_local()
6002    -> Result<(), Box<dyn std::error::Error>> {
6003        let directory = temporary_directory("message-extras")?;
6004        let account =
6005            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
6006        let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
6007        let conversation =
6008            account.get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
6009        let message = account
6010            .send_message(
6011                "extras-message".to_owned(),
6012                conversation.id,
6013                content("voice note"),
6014                String::new(),
6015                None,
6016                1_700_000_001,
6017            )?
6018            .message
6019            .ok_or(SoftchatError::InvalidPersistenceResult)?;
6020        let before = account.database_info()?.revision;
6021        let mutation = account.set_message_local_extras(
6022            message.id.clone(),
6023            Some("local transcript".to_owned()),
6024            vec![0, 64, 255],
6025            1_700_000_002,
6026        )?;
6027        assert_eq!(mutation.revision, before + 1);
6028        let extras = account
6029            .message_local_extras(message.id.clone())?
6030            .ok_or(SoftchatError::InvalidPersistenceResult)?;
6031        assert_eq!(extras.transcript.as_deref(), Some("local transcript"));
6032        assert_eq!(extras.waveform, vec![0, 64, 255]);
6033        assert!(matches!(
6034            account.set_message_local_extras(
6035                message.id,
6036                None,
6037                vec![0; crate::MAX_PRODUCT_WAVEFORM_SAMPLES + 1],
6038                1_700_000_003,
6039            ),
6040            Err(SoftchatError::InvalidAccountOperation)
6041        ));
6042        drop(account);
6043        std::fs::remove_dir_all(directory)?;
6044        Ok(())
6045    }
6046
6047    #[test]
6048    fn settings_and_media_are_revisioned_and_survive_reopen()
6049    -> Result<(), Box<dyn std::error::Error>> {
6050        let directory = temporary_directory("product-state")?;
6051        let filename;
6052        {
6053            let account =
6054                AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
6055            filename = account.database_filename();
6056            assert_eq!(
6057                account.media_upload_url()?,
6058                "https://it2.softchat.c84ad60.xyz/api/v2/files"
6059            );
6060            let revision_before_settings = account.database_info()?.revision;
6061            let settings = account.apply_account_settings_patch(
6062                "settings-1".to_owned(),
6063                r#"{"future":{"preserved":true},"mediaService":"https://media.example/upload","quickReaction":"🔥"}"#.to_owned(),
6064                1_700_000_001,
6065            )?;
6066            assert_eq!(settings.settings.quick_reaction.as_deref(), Some("🔥"));
6067            assert_eq!(account.media_upload_url()?, "https://media.example/upload");
6068            assert_eq!(settings.revision, revision_before_settings + 1);
6069            assert!(matches!(
6070                account.apply_account_settings_patch(
6071                    "settings-1".to_owned(),
6072                    r#"{"quickReaction":"different"}"#.to_owned(),
6073                    1_700_000_002,
6074                ),
6075                Err(SoftchatError::CommandConflict)
6076            ));
6077            assert_eq!(
6078                account.account_settings()?.quick_reaction.as_deref(),
6079                Some("🔥")
6080            );
6081            let diagnostics = account.account_diagnostics()?;
6082            assert_eq!(diagnostics.revision, settings.revision);
6083            assert!(diagnostics.unfinished_delivery_count > 0);
6084            let media = account.prepare_media_operation(
6085                "media-1".to_owned(),
6086                String::new(),
6087                "upload".to_owned(),
6088                "sha256:fixture".to_owned(),
6089                1_700_000_003,
6090            )?;
6091            let other_media = account.prepare_media_operation(
6092                "media-2".to_owned(),
6093                String::new(),
6094                "upload".to_owned(),
6095                "sha256:other-fixture".to_owned(),
6096                1_700_000_003,
6097            )?;
6098            let caller_prefixed_media = account.prepare_media_operation(
6099                "conversation-icon-download-caller-owned".to_owned(),
6100                String::new(),
6101                "upload".to_owned(),
6102                "caller-owned-prefix".to_owned(),
6103                1_700_000_003,
6104            )?;
6105            let caller_prefixed_lease = account
6106                .claim_media_operation(
6107                    caller_prefixed_media.id,
6108                    "caller-owned-prefix-lease".to_owned(),
6109                    1_700_000_004,
6110                    300,
6111                )?
6112                .ok_or(SoftchatError::InvalidMediaOperation)?;
6113            assert_eq!(
6114                account
6115                    .complete_media_operation(
6116                        caller_prefixed_lease.operation.id,
6117                        caller_prefixed_lease.lease_id,
6118                        AttachmentMetadata {
6119                            url: "https://media.example/caller-owned-prefix.bin".to_owned(),
6120                            sha256: Some(hex::encode([3_u8; 32])),
6121                            byte_size: Some(4),
6122                            ..AttachmentMetadata::default()
6123                        },
6124                        4,
6125                        1_700_000_005,
6126                    )?
6127                    .operation
6128                    .state,
6129                crate::AccountMediaOperationState::Completed
6130            );
6131            assert_eq!(media.state, crate::AccountMediaOperationState::Prepared);
6132            let lease = account
6133                .claim_media_operation(
6134                    media.id.clone(),
6135                    "media-lease-1".to_owned(),
6136                    1_700_000_004,
6137                    300,
6138                )?
6139                .ok_or(SoftchatError::InvalidMediaOperation)?;
6140            assert_eq!(lease.operation.id, media.id);
6141            assert_eq!(
6142                account
6143                    .claim_media_operation(
6144                        other_media.id.clone(),
6145                        "media-lease-2".to_owned(),
6146                        1_700_000_004,
6147                        300,
6148                    )?
6149                    .ok_or(SoftchatError::InvalidMediaOperation)?
6150                    .operation
6151                    .id,
6152                other_media.id
6153            );
6154            assert_eq!(
6155                lease.operation.state,
6156                crate::AccountMediaOperationState::Running
6157            );
6158            assert!(matches!(
6159                account.complete_media_operation(
6160                    lease.operation.id.clone(),
6161                    lease.lease_id.clone(),
6162                    AttachmentMetadata {
6163                        url: "http://media.example/insecure".to_owned(),
6164                        ..AttachmentMetadata::default()
6165                    },
6166                    42,
6167                    1_700_000_005,
6168                ),
6169                Err(SoftchatError::InvalidAttachmentMetadata)
6170            ));
6171            assert_eq!(
6172                account
6173                    .media_operation(lease.operation.id.clone())?
6174                    .ok_or(SoftchatError::InvalidPersistenceResult)?
6175                    .state,
6176                crate::AccountMediaOperationState::Running
6177            );
6178            let retryable = account.finish_media_operation_lease(
6179                lease.operation.id,
6180                lease.lease_id,
6181                true,
6182                "network_unavailable".to_owned(),
6183                1_700_000_006,
6184            )?;
6185            assert_eq!(
6186                retryable.state,
6187                crate::AccountMediaOperationState::Retryable
6188            );
6189        }
6190        let reopened =
6191            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
6192        assert_eq!(reopened.database_filename(), filename);
6193        assert_eq!(
6194            reopened.account_settings()?.quick_reaction.as_deref(),
6195            Some("🔥")
6196        );
6197        assert_eq!(reopened.media_operations(10)?.len(), 3);
6198        drop(reopened);
6199        std::fs::remove_dir_all(directory)?;
6200        Ok(())
6201    }
6202
6203    #[test]
6204    fn recoverable_media_pagination_does_not_drop_work_beyond_the_recent_limit()
6205    -> Result<(), Box<dyn std::error::Error>> {
6206        let directory = temporary_directory("recoverable-media-pages")?;
6207        let account =
6208            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
6209        let mut expected_ids = Vec::new();
6210        for index in 0..(crate::MAX_PRODUCT_QUERY_PAGE + 5) {
6211            let operation = account.prepare_media_operation(
6212                format!("recoverable-command-{index}"),
6213                String::new(),
6214                "upload".to_owned(),
6215                format!("recoverable-source-{index}"),
6216                1_700_000_000 + i64::from(index),
6217            )?;
6218            expected_ids.push(operation.id);
6219        }
6220        let cancelled = expected_ids.remove(2);
6221        account.transition_media_operation(
6222            cancelled.clone(),
6223            "cancel".to_owned(),
6224            String::new(),
6225            1_700_001_000,
6226        )?;
6227        expected_ids.sort();
6228
6229        let first = account.recoverable_media_operations(None, crate::MAX_PRODUCT_QUERY_PAGE)?;
6230        assert_eq!(
6231            first.operations.len(),
6232            crate::MAX_PRODUCT_QUERY_PAGE as usize
6233        );
6234        let cursor = first
6235            .next_cursor
6236            .clone()
6237            .ok_or(SoftchatError::InvalidPersistenceResult)?;
6238        let second =
6239            account.recoverable_media_operations(Some(cursor), crate::MAX_PRODUCT_QUERY_PAGE)?;
6240        assert_eq!(second.operations.len(), 4);
6241        assert!(second.next_cursor.is_none());
6242        let actual_ids = first
6243            .operations
6244            .into_iter()
6245            .chain(second.operations)
6246            .map(|operation| operation.id)
6247            .collect::<Vec<_>>();
6248        assert_eq!(actual_ids, expected_ids);
6249        let exact_ids = vec![
6250            expected_ids[expected_ids.len() - 1].clone(),
6251            cancelled.clone(),
6252            "0".repeat(64),
6253            expected_ids[0].clone(),
6254        ];
6255        let exact = account.media_operations_by_ids(exact_ids.clone())?;
6256        assert_eq!(
6257            exact
6258                .into_iter()
6259                .map(|operation| operation.id)
6260                .collect::<Vec<_>>(),
6261            vec![exact_ids[0].clone(), cancelled, exact_ids[3].clone()],
6262        );
6263        assert!(matches!(
6264            account.media_operations_by_ids(vec![expected_ids[0].clone(); 2]),
6265            Err(SoftchatError::InvalidMediaOperation)
6266        ));
6267        assert!(matches!(
6268            account.recoverable_media_operations(Some("not-an-operation".to_owned()), 10),
6269            Err(SoftchatError::InvalidMediaOperation)
6270        ));
6271
6272        drop(account);
6273        std::fs::remove_dir_all(directory)?;
6274        Ok(())
6275    }
6276
6277    #[test]
6278    fn media_diagnostics_preserve_completion_when_dependent_publication_fails()
6279    -> Result<(), Box<dyn std::error::Error>> {
6280        let directory = temporary_directory("media-commit-diagnostics")?;
6281        let account =
6282            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
6283        account.set_log_level(AccountLogLevel::Info)?;
6284        let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
6285        let conversation =
6286            account.get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
6287        let pending = account.prepare_media_message(
6288            "diagnostic-media".to_owned(),
6289            conversation.id,
6290            content("private caption"),
6291            String::new(),
6292            vec![crate::MediaPreparationInput {
6293                source_fingerprint: "private-source".to_owned(),
6294            }],
6295            None,
6296            1_700_000_001,
6297        )?;
6298        let lease = account
6299            .claim_media_operation(
6300                pending.attachments[0].id.clone(),
6301                "diagnostic-lease".to_owned(),
6302                1_700_000_002,
6303                30,
6304            )?
6305            .ok_or(SoftchatError::InvalidMediaOperation)?;
6306        account.drain_log_events(64)?;
6307        account.database()?.connection.execute_batch(
6308            "CREATE TEMP TRIGGER reject_test_publication BEFORE INSERT ON outgoing_operations
6309             BEGIN SELECT RAISE(FAIL, 'test publication failure'); END;",
6310        )?;
6311        let result = account.complete_media_operation(
6312            lease.operation.id.clone(),
6313            lease.lease_id.clone(),
6314            AttachmentMetadata {
6315                url: "https://media.example/test".to_owned(),
6316                sha256: Some(hex::encode([7_u8; 32])),
6317                byte_size: Some(42),
6318                ..AttachmentMetadata::default()
6319            },
6320            42,
6321            1_700_000_003,
6322        );
6323        assert!(result.is_err());
6324        assert_eq!(
6325            account
6326                .database()?
6327                .product_media_operation(&lease.operation.id)?
6328                .ok_or(SoftchatError::InvalidMediaOperation)?
6329                .state,
6330            crate::AccountMediaOperationState::Completed
6331        );
6332        let events = account.drain_log_events(64)?.events;
6333        assert_eq!(
6334            events
6335                .iter()
6336                .filter(|event| event.operation == "media.upload.completed")
6337                .count(),
6338            1
6339        );
6340        assert!(
6341            events
6342                .iter()
6343                .any(|event| event.operation == "media.publication.deferred")
6344        );
6345        assert!(
6346            !events
6347                .iter()
6348                .any(|event| event.operation == "message.queued"
6349                    || event.operation == "media.completion.failed")
6350        );
6351        assert!(!format!("{events:?}").contains("private"));
6352        account
6353            .database()?
6354            .connection
6355            .execute_batch("DROP TRIGGER reject_test_publication")?;
6356        assert_eq!(
6357            account
6358                .recover_pending_media_messages(10, 1_700_000_004)?
6359                .len(),
6360            1
6361        );
6362        let recovered = account.drain_log_events(64)?.events;
6363        assert!(
6364            recovered
6365                .iter()
6366                .any(|event| event.operation == "recovery.completed")
6367        );
6368        assert!(
6369            !recovered
6370                .iter()
6371                .any(|event| event.operation == "media.upload.completed")
6372        );
6373        assert!(
6374            account
6375                .recover_pending_media_messages(10, 1_700_000_005)?
6376                .is_empty()
6377        );
6378        assert!(account.drain_log_events(64)?.events.is_empty());
6379        drop(account);
6380        std::fs::remove_dir_all(directory)?;
6381        Ok(())
6382    }
6383
6384    #[test]
6385    fn media_message_dependencies_claim_publication_and_recover_after_reopen()
6386    -> Result<(), Box<dyn std::error::Error>> {
6387        let directory = temporary_directory("media-message-recovery")?;
6388        let command_id = "media-message-command".to_owned();
6389        {
6390            let account =
6391                AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
6392            let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
6393            let conversation = account
6394                .get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
6395            let revision_before_rejection = account.database_info()?.revision;
6396            assert!(matches!(
6397                account.prepare_media_message(
6398                    "media-message-too-large".to_owned(),
6399                    conversation.id.clone(),
6400                    content(&"x".repeat(crate::MAX_PRODUCT_MESSAGE_BYTES)),
6401                    String::new(),
6402                    vec![crate::MediaPreparationInput {
6403                        source_fingerprint: "must-not-start-upload".to_owned(),
6404                    }],
6405                    None,
6406                    1_700_000_001,
6407                ),
6408                Err(SoftchatError::InvalidMediaOperation)
6409            ));
6410            assert_eq!(account.database_info()?.revision, revision_before_rejection);
6411            assert!(
6412                account
6413                    .pending_media_message("media-message-too-large".to_owned())?
6414                    .is_none()
6415            );
6416            let draft = AccountDraft {
6417                text: "attachment".to_owned(),
6418                emoji_tags: Vec::new(),
6419                spans: Vec::new(),
6420                reply_to_message_id: String::new(),
6421                attachment_operation_ids: Vec::new(),
6422                updated_at: 1_700_000_001,
6423            };
6424            account.save_conversation_draft(
6425                conversation.id.clone(),
6426                Some(draft.clone()),
6427                1_700_000_001,
6428            )?;
6429            let pending = account.prepare_media_message(
6430                command_id.clone(),
6431                conversation.id.clone(),
6432                content("attachment"),
6433                String::new(),
6434                vec![crate::MediaPreparationInput {
6435                    source_fingerprint: "staged-sha256-transform-v1".to_owned(),
6436                }],
6437                Some(draft),
6438                1_700_000_001,
6439            )?;
6440            assert!(
6441                account
6442                    .conversation(conversation.id)?
6443                    .ok_or(SoftchatError::InvalidPersistenceResult)?
6444                    .draft
6445                    .is_none()
6446            );
6447            assert_eq!(
6448                pending.state,
6449                crate::PendingMediaMessageState::WaitingForUploads
6450            );
6451            let failed_command = "media-message-failed".to_owned();
6452            let failed_pending = account.prepare_media_message(
6453                failed_command.clone(),
6454                pending.conversation_id.clone(),
6455                content("failed attachment"),
6456                String::new(),
6457                vec![crate::MediaPreparationInput {
6458                    source_fingerprint: "failed-staged-source-v1".to_owned(),
6459                }],
6460                None,
6461                1_700_000_002,
6462            )?;
6463            let failed_lease = account
6464                .claim_media_operation(
6465                    failed_pending.attachments[0].id.clone(),
6466                    "failed-media-message-lease".to_owned(),
6467                    1_700_000_003,
6468                    300,
6469                )?
6470                .ok_or(SoftchatError::InvalidMediaOperation)?;
6471            account.finish_media_operation_lease(
6472                failed_lease.operation.id,
6473                failed_lease.lease_id,
6474                false,
6475                "upload_rejected".to_owned(),
6476                1_700_000_004,
6477            )?;
6478            assert_eq!(
6479                account
6480                    .pending_media_message(failed_command.clone())?
6481                    .ok_or(SoftchatError::InvalidPersistenceResult)?
6482                    .state,
6483                crate::PendingMediaMessageState::Failed
6484            );
6485            let cancelled =
6486                account.cancel_pending_media_message(failed_command.clone(), 1_700_000_005)?;
6487            assert_eq!(cancelled.state, crate::PendingMediaMessageState::Cancelled);
6488            assert_eq!(
6489                cancelled.attachments[0].state,
6490                crate::AccountMediaOperationState::Cancelled
6491            );
6492            assert_eq!(
6493                account.cancel_pending_media_message(failed_command, 1_700_000_006)?,
6494                cancelled
6495            );
6496            let partially_cancelled_command = "media-message-partially-cancelled".to_owned();
6497            let partially_cancelled = account.prepare_media_message(
6498                partially_cancelled_command.clone(),
6499                pending.conversation_id.clone(),
6500                content("two attachments"),
6501                String::new(),
6502                vec![
6503                    crate::MediaPreparationInput {
6504                        source_fingerprint: "partially-cancelled-source-a".to_owned(),
6505                    },
6506                    crate::MediaPreparationInput {
6507                        source_fingerprint: "partially-cancelled-source-b".to_owned(),
6508                    },
6509                ],
6510                None,
6511                1_700_000_006,
6512            )?;
6513            account.transition_media_operation(
6514                partially_cancelled.attachments[0].id.clone(),
6515                "cancel".to_owned(),
6516                String::new(),
6517                1_700_000_007,
6518            )?;
6519            let partially_cancelled = account
6520                .pending_media_message(partially_cancelled_command.clone())?
6521                .ok_or(SoftchatError::InvalidPersistenceResult)?;
6522            assert_eq!(
6523                partially_cancelled.state,
6524                crate::PendingMediaMessageState::Cancelled
6525            );
6526            assert_eq!(
6527                partially_cancelled.attachments[0].state,
6528                crate::AccountMediaOperationState::Cancelled
6529            );
6530            assert_eq!(
6531                partially_cancelled.attachments[1].state,
6532                crate::AccountMediaOperationState::Prepared
6533            );
6534            let fully_cancelled = account
6535                .cancel_pending_media_message(partially_cancelled_command.clone(), 1_700_000_008)?;
6536            assert!(
6537                fully_cancelled
6538                    .attachments
6539                    .iter()
6540                    .all(|attachment| attachment.state
6541                        == crate::AccountMediaOperationState::Cancelled)
6542            );
6543            assert_eq!(
6544                account.cancel_pending_media_message(partially_cancelled_command, 1_700_000_009,)?,
6545                fully_cancelled
6546            );
6547            let lease = account
6548                .claim_media_operation(
6549                    pending.attachments[0].id.clone(),
6550                    "media-message-lease".to_owned(),
6551                    1_700_000_007,
6552                    300,
6553                )?
6554                .ok_or(SoftchatError::InvalidMediaOperation)?;
6555            account.database()?.complete_product_media(
6556                &lease.operation.id,
6557                &lease.lease_id,
6558                AttachmentMetadata {
6559                    url: "https://media.example/file".to_owned(),
6560                    mime_type: Some("image/jpeg".to_owned()),
6561                    sha256: Some(hex::encode([7_u8; 32])),
6562                    byte_size: Some(42),
6563                    ..AttachmentMetadata::default()
6564                },
6565                42,
6566                1_700_000_008,
6567            )?;
6568            assert_eq!(
6569                account
6570                    .pending_media_message(command_id.clone())?
6571                    .ok_or(SoftchatError::InvalidPersistenceResult)?
6572                    .state,
6573                crate::PendingMediaMessageState::ReadyToPublish
6574            );
6575            assert!(
6576                account
6577                    .database()?
6578                    .claim_pending_media_publication(&command_id)?
6579            );
6580            assert!(matches!(
6581                account.cancel_pending_media_message(command_id.clone(), 1_700_000_009),
6582                Err(SoftchatError::InvalidMediaOperation)
6583            ));
6584            let ready = account
6585                .pending_media_message(command_id.clone())?
6586                .ok_or(SoftchatError::InvalidPersistenceResult)?;
6587            let attachments = ready
6588                .attachments
6589                .iter()
6590                .map(|operation| {
6591                    operation
6592                        .attachment
6593                        .clone()
6594                        .ok_or(SoftchatError::InvalidPersistenceResult)
6595                })
6596                .collect::<Result<Vec<_>, _>>()?;
6597            let sent = account.send_message(
6598                ready.command_id,
6599                ready.conversation_id,
6600                MessageContentInput {
6601                    text: ready.text,
6602                    attachments,
6603                    emoji_tags: ready.emoji_tags,
6604                },
6605                ready.reply_to_message_id,
6606                None,
6607                1_700_000_010,
6608            )?;
6609            assert!(sent.inserted);
6610        }
6611
6612        let reopened =
6613            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
6614        let published = reopened.recover_pending_media_messages(10, 1_700_000_004)?;
6615        assert_eq!(published.len(), 1);
6616        assert!(!published[0].inserted);
6617        assert_eq!(
6618            published[0]
6619                .message
6620                .as_ref()
6621                .map(|message| message.attachments.len()),
6622            Some(1)
6623        );
6624        let published_message = published[0]
6625            .message
6626            .as_ref()
6627            .ok_or(SoftchatError::InvalidPersistenceResult)?;
6628        let cleared_caption = reopened.edit_message(
6629            "media-message-clear-caption".to_owned(),
6630            published_message.id.clone(),
6631            MessageContentInput {
6632                text: String::new(),
6633                attachments: Vec::new(),
6634                emoji_tags: Vec::new(),
6635            },
6636            1_700_000_005,
6637        )?;
6638        let cleared_message = cleared_caption
6639            .message
6640            .as_ref()
6641            .ok_or(SoftchatError::InvalidPersistenceResult)?;
6642        assert!(cleared_message.text.is_empty());
6643        assert_eq!(cleared_message.attachments, published_message.attachments);
6644        assert!(cleared_message.edited);
6645        let download = reopened.prepare_media_download(
6646            published_message.id.clone(),
6647            "https://media.example/file".to_owned(),
6648            1_700_000_006,
6649        )?;
6650        let repeated = reopened.prepare_media_download(
6651            published_message.id.clone(),
6652            "https://media.example/file".to_owned(),
6653            1_700_000_999,
6654        )?;
6655        assert_eq!(download, repeated);
6656        assert_eq!(download.source_message_id, published_message.id);
6657        assert_eq!(
6658            download
6659                .attachment
6660                .as_ref()
6661                .map(|attachment| attachment.url.as_str()),
6662            Some("https://media.example/file")
6663        );
6664        let download_lease = reopened
6665            .claim_media_operation(
6666                download.id.clone(),
6667                "media-download-lease".to_owned(),
6668                1_700_001_000,
6669                300,
6670            )?
6671            .ok_or(SoftchatError::InvalidMediaOperation)?;
6672        let completed_download = reopened.complete_media_operation(
6673            download.id.clone(),
6674            download_lease.lease_id,
6675            download
6676                .attachment
6677                .clone()
6678                .ok_or(SoftchatError::InvalidMediaOperation)?,
6679            42,
6680            1_700_001_001,
6681        )?;
6682        assert_eq!(
6683            completed_download.operation.state,
6684            crate::AccountMediaOperationState::Completed
6685        );
6686        let restarted = reopened.restart_media_download(download.id.clone(), 1_700_001_002)?;
6687        assert_eq!(restarted.id, download.id);
6688        assert_eq!(restarted.state, crate::AccountMediaOperationState::Prepared);
6689        assert_eq!(restarted.attachment, download.attachment);
6690        let cancelled = reopened.transition_media_operation(
6691            download.id.clone(),
6692            "cancel".to_owned(),
6693            String::new(),
6694            1_700_001_003,
6695        )?;
6696        assert_eq!(
6697            cancelled.state,
6698            crate::AccountMediaOperationState::Cancelled
6699        );
6700        let restarted_cancelled =
6701            reopened.restart_media_download(download.id.clone(), 1_700_001_004)?;
6702        assert_eq!(
6703            restarted_cancelled.state,
6704            crate::AccountMediaOperationState::Prepared
6705        );
6706        let failed_lease = reopened
6707            .claim_media_operation(
6708                download.id.clone(),
6709                "media-download-failed-lease".to_owned(),
6710                1_700_001_005,
6711                300,
6712            )?
6713            .ok_or(SoftchatError::InvalidMediaOperation)?;
6714        let failed = reopened.finish_media_operation_lease(
6715            download.id.clone(),
6716            failed_lease.lease_id,
6717            false,
6718            "download_rejected".to_owned(),
6719            1_700_001_006,
6720        )?;
6721        assert_eq!(failed.state, crate::AccountMediaOperationState::Failed);
6722        assert_eq!(
6723            reopened
6724                .restart_media_download(download.id.clone(), 1_700_001_007)?
6725                .state,
6726            crate::AccountMediaOperationState::Prepared
6727        );
6728        assert!(matches!(
6729            reopened.prepare_media_download(
6730                published_message.id.clone(),
6731                "https://media.example/other".to_owned(),
6732                1_700_000_005,
6733            ),
6734            Err(SoftchatError::InvalidMediaOperation)
6735        ));
6736        assert_eq!(
6737            reopened
6738                .pending_media_message(command_id)?
6739                .ok_or(SoftchatError::InvalidPersistenceResult)?
6740                .state,
6741            crate::PendingMediaMessageState::Published
6742        );
6743        drop(reopened);
6744        std::fs::remove_dir_all(directory)?;
6745        Ok(())
6746    }
6747
6748    #[test]
6749    fn public_asset_replacements_are_durable_idempotent_and_recoverable()
6750    -> Result<(), Box<dyn std::error::Error>> {
6751        let directory = temporary_directory("asset-replacement-recovery")?;
6752        let profile_command = "replace-profile-picture".to_owned();
6753        let conversation_id;
6754        {
6755            let account =
6756                AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
6757            let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
6758            let conversation = account
6759                .get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
6760            conversation_id = conversation.id.clone();
6761            account.set_conversation_subject(
6762                "initial-subject".to_owned(),
6763                conversation.id,
6764                "Family".to_owned(),
6765                None,
6766                vec![vec![
6767                    "emoji".to_owned(),
6768                    "party".to_owned(),
6769                    "https://media.example/party.png".to_owned(),
6770                ]],
6771                1_700_000_001,
6772            )?;
6773
6774            let pending = account.prepare_asset_replacement(
6775                profile_command.clone(),
6776                AssetReplacementTarget::ProfilePicture,
6777                String::new(),
6778                "profile-picture-source-v1".to_owned(),
6779                1_700_000_002,
6780            )?;
6781            assert_eq!(
6782                pending.operation.protection,
6783                crate::AccountMediaProtection::Public
6784            );
6785            assert_eq!(
6786                pending.state,
6787                crate::PendingAssetReplacementState::WaitingForUpload
6788            );
6789            assert_eq!(
6790                account.prepare_asset_replacement(
6791                    profile_command.clone(),
6792                    AssetReplacementTarget::ProfilePicture,
6793                    String::new(),
6794                    "profile-picture-source-v1".to_owned(),
6795                    1_700_000_999,
6796                )?,
6797                pending
6798            );
6799            assert!(matches!(
6800                account.prepare_asset_replacement(
6801                    profile_command.clone(),
6802                    AssetReplacementTarget::ProfilePicture,
6803                    String::new(),
6804                    "different-source".to_owned(),
6805                    1_700_000_003,
6806                ),
6807                Err(SoftchatError::CommandConflict)
6808            ));
6809            let lease = account
6810                .claim_media_operation(
6811                    pending.operation.id,
6812                    "profile-picture-lease".to_owned(),
6813                    1_700_000_003,
6814                    300,
6815                )?
6816                .ok_or(SoftchatError::InvalidMediaOperation)?;
6817            account.database()?.complete_product_media(
6818                &lease.operation.id,
6819                &lease.lease_id,
6820                AttachmentMetadata {
6821                    url: "https://media.example/avatar.jpg".to_owned(),
6822                    mime_type: Some("image/jpeg".to_owned()),
6823                    sha256: Some(hex::encode([8_u8; 32])),
6824                    byte_size: Some(42),
6825                    ..AttachmentMetadata::default()
6826                },
6827                42,
6828                1_700_000_004,
6829            )?;
6830            assert_eq!(
6831                account
6832                    .pending_asset_replacement(profile_command.clone())?
6833                    .ok_or(SoftchatError::InvalidPersistenceResult)?
6834                    .state,
6835                crate::PendingAssetReplacementState::ReadyToPublish
6836            );
6837        }
6838
6839        let reopened =
6840            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
6841        assert_eq!(
6842            reopened
6843                .recover_pending_asset_replacements(10, 1_700_000_005)?
6844                .len(),
6845            1
6846        );
6847        let own_public_key = reopened.public_key()?;
6848        assert_eq!(
6849            reopened.profile(own_public_key)?.picture.as_deref(),
6850            Some("https://media.example/avatar.jpg")
6851        );
6852        assert_eq!(
6853            reopened
6854                .pending_asset_replacement(profile_command)?
6855                .ok_or(SoftchatError::InvalidPersistenceResult)?
6856                .state,
6857            crate::PendingAssetReplacementState::Published
6858        );
6859
6860        let icon = reopened.prepare_asset_replacement(
6861            "replace-conversation-icon".to_owned(),
6862            AssetReplacementTarget::ConversationIcon,
6863            conversation_id.clone(),
6864            "conversation-icon-source-v1".to_owned(),
6865            1_700_000_006,
6866        )?;
6867        let icon_lease = reopened
6868            .claim_media_operation(
6869                icon.operation.id,
6870                "conversation-icon-lease".to_owned(),
6871                1_700_000_007,
6872                300,
6873            )?
6874            .ok_or(SoftchatError::InvalidMediaOperation)?;
6875        let icon_completion = reopened.complete_media_operation(
6876            icon_lease.operation.id,
6877            icon_lease.lease_id,
6878            AttachmentMetadata {
6879                url: "https://media.example/conversation.png".to_owned(),
6880                mime_type: Some("image/png".to_owned()),
6881                sha256: Some(hex::encode([9_u8; 32])),
6882                dimensions: Some("256x256".to_owned()),
6883                byte_size: Some(64),
6884                ..AttachmentMetadata::default()
6885            },
6886            64,
6887            1_700_000_008,
6888        )?;
6889        assert!(icon_completion.completed_operation.is_some());
6890        let conversation = reopened
6891            .conversation(conversation_id.clone())?
6892            .ok_or(SoftchatError::InvalidPersistenceResult)?;
6893        assert_eq!(conversation.subject, "Family");
6894        assert_eq!(
6895            conversation
6896                .subject_icon
6897                .as_ref()
6898                .map(|value| value.url.as_str()),
6899            Some("https://media.example/conversation.png")
6900        );
6901        assert_eq!(
6902            conversation
6903                .subject_icon
6904                .as_ref()
6905                .and_then(|value| value.sha256.clone()),
6906            Some(hex::encode([9_u8; 32]))
6907        );
6908        let icon_download =
6909            reopened.prepare_conversation_icon_download(conversation_id.clone(), 1_700_000_009)?;
6910        assert_eq!(icon_download.direction, "download");
6911        assert_eq!(icon_download.conversation_id, conversation_id);
6912        assert!(icon_download.source_message_id.is_empty());
6913        assert_eq!(icon_download.attachment, conversation.subject_icon);
6914        let original_icon = conversation
6915            .subject_icon
6916            .clone()
6917            .ok_or(SoftchatError::InvalidPersistenceResult)?;
6918        reopened.set_conversation_subject(
6919            "rename-conversation-without-icon".to_owned(),
6920            conversation_id.clone(),
6921            "Family renamed".to_owned(),
6922            None,
6923            Vec::new(),
6924            1_700_000_010,
6925        )?;
6926        let renamed = reopened
6927            .conversation(conversation_id.clone())?
6928            .ok_or(SoftchatError::InvalidPersistenceResult)?;
6929        assert_eq!(renamed.subject, "Family renamed");
6930        assert_eq!(renamed.subject_icon, Some(original_icon.clone()));
6931        assert_eq!(
6932            reopened.prepare_conversation_icon_download(
6933                icon_download.conversation_id.clone(),
6934                1_700_000_010,
6935            )?,
6936            icon_download
6937        );
6938        let inherited_icon_lease = reopened
6939            .claim_media_operation(
6940                icon_download.id.clone(),
6941                "inherited-conversation-icon-lease".to_owned(),
6942                1_700_000_011,
6943                300,
6944            )?
6945            .ok_or(SoftchatError::InvalidMediaOperation)?;
6946        let inherited_operation_id = inherited_icon_lease.operation.id.clone();
6947        let inherited_lease_id = inherited_icon_lease.lease_id.clone();
6948        assert_eq!(
6949            reopened
6950                .complete_media_operation(
6951                    inherited_operation_id.clone(),
6952                    inherited_lease_id.clone(),
6953                    original_icon.clone(),
6954                    64,
6955                    1_700_000_012,
6956                )?
6957                .operation
6958                .state,
6959            crate::AccountMediaOperationState::Completed
6960        );
6961        let replacement_icon = AttachmentMetadata {
6962            url: "https://media.example/conversation-v2.png".to_owned(),
6963            mime_type: Some("image/png".to_owned()),
6964            sha256: Some(hex::encode([10_u8; 32])),
6965            dimensions: Some("512x512".to_owned()),
6966            byte_size: Some(128),
6967            ..AttachmentMetadata::default()
6968        };
6969        reopened.set_conversation_subject(
6970            "replace-conversation-icon-subject".to_owned(),
6971            conversation_id.clone(),
6972            "Family".to_owned(),
6973            Some(replacement_icon.clone()),
6974            Vec::new(),
6975            1_700_000_013,
6976        )?;
6977        assert_eq!(
6978            reopened
6979                .complete_media_operation(
6980                    inherited_operation_id,
6981                    inherited_lease_id,
6982                    original_icon,
6983                    64,
6984                    1_700_000_014,
6985                )?
6986                .operation
6987                .state,
6988            crate::AccountMediaOperationState::Completed
6989        );
6990        let replacement_download =
6991            reopened.prepare_conversation_icon_download(conversation_id.clone(), 1_700_000_014)?;
6992        assert_ne!(replacement_download.id, icon_download.id);
6993        assert_eq!(
6994            replacement_download.attachment,
6995            Some(replacement_icon.clone())
6996        );
6997        let stale_icon_lease = reopened
6998            .claim_media_operation(
6999                replacement_download.id,
7000                "stale-conversation-icon-lease".to_owned(),
7001                1_700_000_015,
7002                300,
7003            )?
7004            .ok_or(SoftchatError::InvalidMediaOperation)?;
7005        let stale_operation_id = stale_icon_lease.operation.id.clone();
7006        let stale_lease_id = stale_icon_lease.lease_id.clone();
7007        reopened.set_conversation_subject(
7008            "replace-conversation-icon-again".to_owned(),
7009            conversation_id.clone(),
7010            "Family".to_owned(),
7011            Some(AttachmentMetadata {
7012                url: "https://media.example/conversation-v3.png".to_owned(),
7013                mime_type: Some("image/png".to_owned()),
7014                sha256: Some(hex::encode([11_u8; 32])),
7015                dimensions: Some("768x768".to_owned()),
7016                byte_size: Some(256),
7017                ..AttachmentMetadata::default()
7018            }),
7019            Vec::new(),
7020            1_700_000_016,
7021        )?;
7022        assert!(matches!(
7023            reopened.complete_media_operation(
7024                stale_operation_id.clone(),
7025                stale_lease_id.clone(),
7026                replacement_icon,
7027                128,
7028                1_700_000_017,
7029            ),
7030            Err(SoftchatError::InvalidMediaOperation)
7031        ));
7032        assert_eq!(
7033            reopened
7034                .finish_media_operation_lease(
7035                    stale_operation_id.clone(),
7036                    stale_lease_id,
7037                    false,
7038                    "stale_icon".to_owned(),
7039                    1_700_000_018,
7040                )?
7041                .state,
7042            crate::AccountMediaOperationState::Failed
7043        );
7044        assert!(matches!(
7045            reopened.restart_media_download(stale_operation_id.clone(), 1_700_000_019),
7046            Err(SoftchatError::InvalidMediaOperation)
7047        ));
7048        assert_eq!(
7049            reopened
7050                .media_operation(stale_operation_id)?
7051                .ok_or(SoftchatError::InvalidPersistenceResult)?
7052                .state,
7053            crate::AccountMediaOperationState::Failed
7054        );
7055        reopened.set_conversation_subject(
7056            "replace-conversation-icon-ox-only".to_owned(),
7057            conversation_id.clone(),
7058            "Family".to_owned(),
7059            Some(AttachmentMetadata {
7060                url: "https://media.example/conversation-ox-only.png".to_owned(),
7061                original_sha256: Some(hex::encode([12_u8; 32])),
7062                ..AttachmentMetadata::default()
7063            }),
7064            Vec::new(),
7065            1_700_000_020,
7066        )?;
7067        assert!(matches!(
7068            reopened.prepare_conversation_icon_download(conversation_id.clone(), 1_700_000_021),
7069            Err(SoftchatError::InvalidMediaOperation)
7070        ));
7071
7072        let banner = reopened.prepare_asset_replacement(
7073            "replace-profile-banner".to_owned(),
7074            AssetReplacementTarget::ProfileBanner,
7075            String::new(),
7076            "profile-banner-source-v1".to_owned(),
7077            1_700_000_009,
7078        )?;
7079        let banner_lease = reopened
7080            .claim_media_operation(
7081                banner.operation.id,
7082                "profile-banner-lease".to_owned(),
7083                1_700_000_010,
7084                300,
7085            )?
7086            .ok_or(SoftchatError::InvalidMediaOperation)?;
7087        assert!(matches!(
7088            reopened.complete_media_operation(
7089                banner_lease.operation.id.clone(),
7090                banner_lease.lease_id.clone(),
7091                AttachmentMetadata {
7092                    url: "https://media.example/banner.jpg".to_owned(),
7093                    encryption: Some("encrypted-value".to_owned()),
7094                    ..AttachmentMetadata::default()
7095                },
7096                64,
7097                1_700_000_011,
7098            ),
7099            Err(SoftchatError::InvalidMediaOperation)
7100        ));
7101        let failed = reopened.finish_media_operation_lease(
7102            banner_lease.operation.id,
7103            banner_lease.lease_id,
7104            false,
7105            "public_upload_rejected".to_owned(),
7106            1_700_000_012,
7107        )?;
7108        assert_eq!(failed.state, crate::AccountMediaOperationState::Failed);
7109        assert_eq!(
7110            reopened
7111                .pending_asset_replacement("replace-profile-banner".to_owned())?
7112                .ok_or(SoftchatError::InvalidPersistenceResult)?
7113                .state,
7114            crate::PendingAssetReplacementState::Failed
7115        );
7116
7117        drop(reopened);
7118        std::fs::remove_dir_all(directory)?;
7119        Ok(())
7120    }
7121
7122    #[test]
7123    fn settings_and_read_state_merge_incoming_ios_compatible_snapshots()
7124    -> Result<(), Box<dyn std::error::Error>> {
7125        let directory = temporary_directory("app-data-merge")?;
7126        let account =
7127            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
7128        let alice = crate::LocalIdentity::from_secret_hex(ALICE_SECRET)?;
7129        let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
7130        let conversation =
7131            account.get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
7132        let sent = account.send_message(
7133            "read-message".to_owned(),
7134            conversation.id.clone(),
7135            content("read boundary"),
7136            String::new(),
7137            None,
7138            1_700_000_001,
7139        )?;
7140        let message = sent
7141            .message
7142            .ok_or(SoftchatError::InvalidPersistenceResult)?;
7143        let marked = account.mark_conversation_read(
7144            "read-command".to_owned(),
7145            conversation.id.clone(),
7146            MessageCursor {
7147                created_at: message.created_at,
7148                message_id: message.id,
7149            },
7150            1_700_000_002,
7151        )?;
7152        assert!(marked.inserted);
7153        assert!(!account.account_read_state()?[0].forced_unread);
7154
7155        let legacy_chat_key = [alice.public_key().to_hex(), bob.public_key().to_hex()]
7156            .into_iter()
7157            .collect::<BTreeSet<_>>()
7158            .into_iter()
7159            .collect::<Vec<_>>()
7160            .join(",");
7161        let unread = alice.create_app_data_sync(
7162            1_700_000_004,
7163            AppDataContext::ReadState,
7164            &serde_json::json!({
7165                "chats": [{
7166                    "chatKey": legacy_chat_key,
7167                    "readAt": null
7168                }]
7169            })
7170            .to_string(),
7171        )?;
7172        account.ingest(vec![unread.event], 1_700_000_005)?;
7173        let read_state = account.account_read_state()?;
7174        assert_eq!(read_state.len(), 1);
7175        assert!(read_state[0].forced_unread);
7176
7177        let settings = alice.create_app_data_sync(
7178            1_700_000_006,
7179            AppDataContext::AppSettings,
7180            r#"{
7181                "videoUploadPreset":"AVAssetExportPresetHighestQuality",
7182                "preserveHDROnUpload":false,
7183                "christmasTheme":true,
7184                "typingIndicatorsEnabled":false,
7185                "hideNotificationContent":true,
7186                "domainReplacementRules":[],
7187                "savedStickerURLs":["https://example.com/sticker.webp"]
7188            }"#,
7189        )?;
7190        account.ingest(vec![settings.event], 1_700_000_007)?;
7191        let effective = account.account_settings()?;
7192        assert_eq!(effective.video_upload_quality, "high");
7193        assert!(!effective.preserve_hdr);
7194        assert!(!effective.typing_indicators_enabled);
7195        assert_eq!(effective.notification_content_privacy, "hidden");
7196        assert_eq!(effective.seasonal_appearance.as_deref(), Some("christmas"));
7197        assert_eq!(
7198            effective.saved_sticker_references,
7199            vec!["https://example.com/sticker.webp"]
7200        );
7201
7202        drop(account);
7203        std::fs::remove_dir_all(directory)?;
7204        Ok(())
7205    }
7206
7207    #[test]
7208    fn operation_retry_cancel_and_relay_rotation_are_durable()
7209    -> Result<(), Box<dyn std::error::Error>> {
7210        let directory = temporary_directory("operation-recovery")?;
7211        let account =
7212            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
7213        let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
7214        let conversation =
7215            account.get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
7216
7217        account.add_account_relay("wss://relay-a.example".to_owned(), 1_700_000_001)?;
7218        account.add_account_relay("wss://relay-b.example".to_owned(), 1_700_000_002)?;
7219        account.set_account_active_relay("wss://relay-a.example/".to_owned(), 1_700_000_003)?;
7220        let first = account.send_message(
7221            "recover-first".to_owned(),
7222            conversation.id.clone(),
7223            content("retry me"),
7224            String::new(),
7225            None,
7226            1_700_000_004,
7227        )?;
7228        let claim = account
7229            .claim_deliveries(
7230                "lease-recovery".to_owned(),
7231                "wss://relay-a.example/".to_owned(),
7232                1_700_000_005,
7233                30,
7234            )?
7235            .ok_or(SoftchatError::InvalidDeliveryState)?;
7236        let intent_ids = claim
7237            .claim
7238            .intents
7239            .iter()
7240            .map(|intent| intent.intent_id.clone())
7241            .collect::<Vec<_>>();
7242        account.mark_socket_written(claim.claim.clone(), intent_ids.clone())?;
7243        account.apply_relay_results(
7244            claim.claim,
7245            intent_ids
7246                .into_iter()
7247                .map(|intent_id| RelayDeliveryResult {
7248                    intent_id,
7249                    kind: crate::RelayDeliveryResultKind::Rejected,
7250                    category: "relay_rejected".to_owned(),
7251                })
7252                .collect(),
7253        )?;
7254        assert_eq!(
7255            account
7256                .operation(first.operation.operation_id.clone())?
7257                .state,
7258            crate::OperationState::Failed
7259        );
7260        assert_eq!(
7261            account
7262                .retry_operation(first.operation.operation_id.clone())?
7263                .operation
7264                .state,
7265            crate::OperationState::Queued
7266        );
7267
7268        let second = account.send_message(
7269            "recover-second".to_owned(),
7270            conversation.id,
7271            content("cancel me"),
7272            String::new(),
7273            None,
7274            1_700_000_006,
7275        )?;
7276        assert_eq!(
7277            account
7278                .cancel_operation(second.operation.operation_id)?
7279                .operation
7280                .state,
7281            crate::OperationState::Cancelled
7282        );
7283
7284        let rotated = account
7285            .rotate_failed_account_relay("wss://relay-a.example/".to_owned(), 1_700_000_007)?;
7286        assert_ne!(rotated.active_relay_url, "wss://relay-a.example/");
7287        assert!(rotated.rebound_intent_count > 0);
7288        assert_eq!(
7289            account
7290                .relay_catalog()?
7291                .into_iter()
7292                .filter(|entry| entry.is_active)
7293                .count(),
7294            1
7295        );
7296        drop(account);
7297        std::fs::remove_dir_all(directory)?;
7298        Ok(())
7299    }
7300
7301    #[test]
7302    fn account_clear_resets_durable_and_process_local_state_without_crossing_accounts()
7303    -> Result<(), Box<dyn std::error::Error>> {
7304        let directory = temporary_directory("complete-account-clear")?;
7305        let alice =
7306            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
7307        let bob = AccountRuntimeHandle::open(secret(BOB_SECRET)?, directory.display().to_string())?;
7308        assert_ne!(alice.account_id(), bob.account_id());
7309
7310        {
7311            let database = lock(&alice.database);
7312            database.connection.execute(
7313                "INSERT INTO app_data_effective
7314                 (context, canonical_json, event_created_at, event_id)
7315                 VALUES ('app-settings', '{\"quickReaction\":\"retained\"}', 1, 'event')",
7316                [],
7317            )?;
7318        }
7319        bob.put_local_contact(LocalContactRecord {
7320            public_key: alice.account_id(),
7321            name: Some("Alice survives Bob isolation".to_owned()),
7322            updated_at_millis: 1,
7323        })?;
7324        alice.start_sync(
7325            "manual-reset-sync".to_owned(),
7326            "manual-reset-neg".to_owned(),
7327            "manual-reset-events".to_owned(),
7328            1_700_000_000,
7329            0,
7330            crate::MIN_NEGENTROPY_FRAME_BYTES,
7331        )?;
7332        let first_transport = alice.start_account_transport("clear-run-before".to_owned())?;
7333        assert_eq!(first_transport.actions.len(), 1);
7334        lock(&alice.typing_authored).insert(
7335            hex::encode([0x44; 32]),
7336            (1_700_000_000, "typing-command".to_owned()),
7337        );
7338
7339        let cleared = alice.clear_account()?;
7340        assert!(cleared.revision > 0);
7341        assert!(lock(&alice.sync_engines).is_empty());
7342        assert!(lock(&alice.transport_run).is_none());
7343        assert!(lock(&alice.typing_authored).is_empty());
7344        assert_eq!(
7345            alice.sync_snapshot("manual-reset-sync".to_owned()),
7346            Err(SoftchatError::InvalidSyncState)
7347        );
7348        assert_eq!(
7349            lock(&alice.database).connection.query_row(
7350                "SELECT COUNT(*) FROM app_data_effective",
7351                [],
7352                |row| row.get::<_, i64>(0),
7353            )?,
7354            0
7355        );
7356        assert_eq!(bob.local_contacts(20, 0)?.len(), 1);
7357
7358        alice.start_sync(
7359            "manual-reset-sync".to_owned(),
7360            "manual-reset-neg-after".to_owned(),
7361            "manual-reset-events-after".to_owned(),
7362            1_700_000_001,
7363            0,
7364            crate::MIN_NEGENTROPY_FRAME_BYTES,
7365        )?;
7366        let restarted = alice.start_account_transport("clear-run-after".to_owned())?;
7367        assert_eq!(
7368            restarted.actions[0].kind,
7369            AccountTransportActionKind::Connect
7370        );
7371        assert_eq!(
7372            restarted.actions[0].configured_url,
7373            "wss://it2.softchat.c84ad60.xyz/"
7374        );
7375        alice.cancel_account_transport()?;
7376        alice.cancel_sync("manual-reset-sync".to_owned())?;
7377        drop(alice);
7378        drop(bob);
7379        std::fs::remove_dir_all(directory)?;
7380        Ok(())
7381    }
7382
7383    #[test]
7384    fn correlated_transport_results_are_idempotent_and_cancel_releases_work()
7385    -> Result<(), Box<dyn std::error::Error>> {
7386        let directory = temporary_directory("transport-actions")?;
7387        let account =
7388            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
7389        account.set_log_level(AccountLogLevel::Debug)?;
7390        let started = account.start_account_transport("android-run-1".to_owned())?;
7391        assert_eq!(started.actions.len(), 1);
7392        assert_eq!(started.actions[0].kind, AccountTransportActionKind::Connect);
7393        let connected = AccountTransportResult {
7394            run_id: started.actions[0].run_id.clone(),
7395            action_id: started.actions[0].action_id.clone(),
7396            generation: started.actions[0].generation,
7397            kind: AccountTransportResultKind::Connected,
7398            text_frame: String::new(),
7399        };
7400        let first = account.apply_account_transport_result(connected.clone(), 1_700_000_000)?;
7401        account.logs.flush();
7402        account.drain_log_events(64)?;
7403        let duplicate = account.apply_account_transport_result(connected, 1_700_000_001)?;
7404        assert!(
7405            account.drain_log_events(64)?.events.is_empty(),
7406            "Replayed transport results are silent"
7407        );
7408        for _ in 0..30 {
7409            account.wake_account_transport(1_700_000_001)?;
7410        }
7411        let receive = transport_action(&first, AccountTransportActionKind::ReceiveText)?;
7412        account.apply_account_transport_result(
7413            transport_result(
7414                &receive,
7415                AccountTransportResultKind::TimedOut,
7416                String::new(),
7417            ),
7418            1_700_000_001,
7419        )?;
7420        assert!(
7421            account.drain_log_events(64)?.events.is_empty(),
7422            "Idle wake and receive timeout are silent at DEBUG"
7423        );
7424        assert_eq!(first.actions, duplicate.actions);
7425        assert_eq!(first.revision, duplicate.revision);
7426        assert_eq!(
7427            first.sync.as_ref().map(|sync| sync.phase),
7428            Some(SyncPhase::Reconciling)
7429        );
7430        assert_eq!(first.sync, duplicate.sync);
7431        assert!(
7432            first
7433                .actions
7434                .iter()
7435                .any(|action| action.kind == AccountTransportActionKind::ReceiveText)
7436        );
7437        let unknown = AccountTransportResult {
7438            run_id: started.actions[0].run_id.clone(),
7439            action_id: "unknown-stale-action".to_owned(),
7440            generation: started.actions[0].generation,
7441            kind: AccountTransportResultKind::Connected,
7442            text_frame: String::new(),
7443        };
7444        assert_eq!(
7445            account.apply_account_transport_result(unknown, 1_700_000_002),
7446            Err(SoftchatError::UnsupportedSystemAction)
7447        );
7448        assert!(!account.wake_account_transport(1_700_000_002)?.idle);
7449        let reset = account.reset_account_sync(1_700_000_002)?;
7450        assert!(reset.sync.is_none());
7451        assert_eq!(reset.actions.len(), 1);
7452        assert_eq!(reset.actions[0].kind, AccountTransportActionKind::Connect);
7453        assert!(reset.actions[0].generation > started.actions[0].generation);
7454
7455        let cancelled = account.cancel_account_transport()?;
7456        assert_eq!(cancelled.connection_state, RelayConnectionState::Cancelled);
7457        drop(account);
7458        std::fs::remove_dir_all(directory)?;
7459        Ok(())
7460    }
7461
7462    #[test]
7463    fn account_transport_replays_a_socket_written_profile_after_authentication()
7464    -> Result<(), Box<dyn std::error::Error>> {
7465        let directory = temporary_directory("transport-profile-during-connect")?;
7466        let account =
7467            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
7468        let started = account.start_account_transport("profile-connect-run".to_owned())?;
7469        account.update_profile(
7470            "profile-during-connect".to_owned(),
7471            serde_json::json!({ "displayName": "Alice" }).to_string(),
7472            1_700_000_000,
7473        )?;
7474
7475        let connected = account.apply_account_transport_result(
7476            transport_result(
7477                &started.actions[0],
7478                AccountTransportResultKind::Connected,
7479                String::new(),
7480            ),
7481            1_700_000_001,
7482        )?;
7483        for action in connected
7484            .actions
7485            .iter()
7486            .filter(|action| action.kind == AccountTransportActionKind::SendText)
7487        {
7488            account.apply_account_transport_result(
7489                transport_result(
7490                    action,
7491                    AccountTransportResultKind::FrameWritten,
7492                    String::new(),
7493                ),
7494                1_700_000_002,
7495            )?;
7496        }
7497
7498        let receive = transport_action(&connected, AccountTransportActionKind::ReceiveText)?;
7499        let authenticating = account.apply_account_transport_result(
7500            transport_result(
7501                &receive,
7502                AccountTransportResultKind::FrameReceived,
7503                RelayResponseFrame::Auth("profile-challenge".to_owned()).to_json()?,
7504            ),
7505            1_700_000_003,
7506        )?;
7507        let authentication_send =
7508            transport_action(&authenticating, AccountTransportActionKind::SendText)?;
7509        let authentication_event_id =
7510            match crate::parse_client_relay_frame(&authentication_send.text_frame)? {
7511                crate::ClientRelayFrame::Auth(event) => event.id().to_hex(),
7512                _ => return Err(SoftchatError::InvalidRelayAuthentication.into()),
7513            };
7514        account.apply_account_transport_result(
7515            transport_result(
7516                &authentication_send,
7517                AccountTransportResultKind::FrameWritten,
7518                String::new(),
7519            ),
7520            1_700_000_004,
7521        )?;
7522        let authentication_receive =
7523            transport_action(&authenticating, AccountTransportActionKind::ReceiveText)?;
7524        let ready = account.apply_account_transport_result(
7525            transport_result(
7526                &authentication_receive,
7527                AccountTransportResultKind::FrameReceived,
7528                RelayResponseFrame::Ok(crate::BatchAcknowledgement {
7529                    event_id: authentication_event_id,
7530                    accepted: true,
7531                    message: String::new(),
7532                })
7533                .to_json()?,
7534            ),
7535            1_700_000_005,
7536        )?;
7537        for action in ready
7538            .actions
7539            .iter()
7540            .filter(|action| action.kind == AccountTransportActionKind::SendText)
7541        {
7542            account.apply_account_transport_result(
7543                transport_result(
7544                    action,
7545                    AccountTransportResultKind::FrameWritten,
7546                    String::new(),
7547                ),
7548                1_700_000_006,
7549            )?;
7550        }
7551
7552        account.cancel_account_transport()?;
7553        drop(account);
7554        std::fs::remove_dir_all(directory)?;
7555        Ok(())
7556    }
7557
7558    #[test]
7559    fn account_transport_authentication_fence_drains_stale_sync_and_restarts_fresh()
7560    -> Result<(), Box<dyn std::error::Error>> {
7561        let directory = temporary_directory("transport-authentication-fence")?;
7562        let account =
7563            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
7564        let started = account.start_account_transport("android-auth-run".to_owned())?;
7565        let connected = account.apply_account_transport_result(
7566            transport_result(
7567                &started.actions[0],
7568                AccountTransportResultKind::Connected,
7569                String::new(),
7570            ),
7571            1_700_000_000,
7572        )?;
7573        let initial_neg_subscription = neg_subscription_id(&connected)?;
7574        let stale_send = transport_action(&connected, AccountTransportActionKind::SendText)?;
7575        let initial_receive =
7576            transport_action(&connected, AccountTransportActionKind::ReceiveText)?;
7577
7578        let authenticating = account.apply_account_transport_result(
7579            transport_result(
7580                &initial_receive,
7581                AccountTransportResultKind::FrameReceived,
7582                RelayResponseFrame::Auth("device-challenge".to_owned()).to_json()?,
7583            ),
7584            1_700_000_001,
7585        )?;
7586        assert_eq!(
7587            authenticating.connection_state,
7588            RelayConnectionState::Authenticating
7589        );
7590        assert!(authenticating.sync.is_none());
7591        let authentication_send =
7592            transport_action(&authenticating, AccountTransportActionKind::SendText)?;
7593        let mut receive =
7594            transport_action(&authenticating, AccountTransportActionKind::ReceiveText)?;
7595        let authentication_event_id =
7596            match crate::parse_client_relay_frame(&authentication_send.text_frame)? {
7597                crate::ClientRelayFrame::Auth(event) => event.id().to_hex(),
7598                _ => return Err(SoftchatError::InvalidRelayAuthentication.into()),
7599            };
7600
7601        account.apply_account_transport_result(
7602            transport_result(
7603                &stale_send,
7604                AccountTransportResultKind::FrameWritten,
7605                String::new(),
7606            ),
7607            1_700_000_002,
7608        )?;
7609        let after_notice = account.apply_account_transport_result(
7610            transport_result(
7611                &receive,
7612                AccountTransportResultKind::FrameReceived,
7613                RelayResponseFrame::Notice("authentication required".to_owned()).to_json()?,
7614            ),
7615            1_700_000_003,
7616        )?;
7617        receive = transport_action(&after_notice, AccountTransportActionKind::ReceiveText)?;
7618        let after_closed = account.apply_account_transport_result(
7619            transport_result(
7620                &receive,
7621                AccountTransportResultKind::FrameReceived,
7622                RelayResponseFrame::Closed {
7623                    subscription_id: initial_neg_subscription.clone(),
7624                    message: "authentication required".to_owned(),
7625                }
7626                .to_json()?,
7627            ),
7628            1_700_000_004,
7629        )?;
7630        receive = transport_action(&after_closed, AccountTransportActionKind::ReceiveText)?;
7631        let after_neg_error = account.apply_account_transport_result(
7632            transport_result(
7633                &receive,
7634                AccountTransportResultKind::FrameReceived,
7635                serde_json::json!([
7636                    "NEG-ERR",
7637                    initial_neg_subscription,
7638                    "authentication required",
7639                    500_u64
7640                ])
7641                .to_string(),
7642            ),
7643            1_700_000_005,
7644        )?;
7645        receive = transport_action(&after_neg_error, AccountTransportActionKind::ReceiveText)?;
7646        account.apply_account_transport_result(
7647            transport_result(
7648                &authentication_send,
7649                AccountTransportResultKind::FrameWritten,
7650                String::new(),
7651            ),
7652            1_700_000_006,
7653        )?;
7654
7655        let ready = account.apply_account_transport_result(
7656            transport_result(
7657                &receive,
7658                AccountTransportResultKind::FrameReceived,
7659                RelayResponseFrame::Ok(crate::BatchAcknowledgement {
7660                    event_id: authentication_event_id,
7661                    accepted: true,
7662                    message: String::new(),
7663                })
7664                .to_json()?,
7665            ),
7666            1_700_000_007,
7667        )?;
7668        assert_eq!(ready.connection_state, RelayConnectionState::Ready);
7669        assert_eq!(
7670            ready.sync.as_ref().map(|snapshot| snapshot.phase),
7671            Some(SyncPhase::Reconciling)
7672        );
7673        let fresh_neg_subscription = neg_subscription_id(&ready)?;
7674        assert_ne!(fresh_neg_subscription, initial_neg_subscription);
7675
7676        receive = transport_action(&ready, AccountTransportActionKind::ReceiveText)?;
7677        let stale_close_after_auth = account.apply_account_transport_result(
7678            transport_result(
7679                &receive,
7680                AccountTransportResultKind::FrameReceived,
7681                serde_json::json!(["NEG-CLOSE", initial_neg_subscription.clone()]).to_string(),
7682            ),
7683            1_700_000_008,
7684        )?;
7685        assert_eq!(
7686            stale_close_after_auth.connection_state,
7687            RelayConnectionState::Ready
7688        );
7689        receive = transport_action(
7690            &stale_close_after_auth,
7691            AccountTransportActionKind::ReceiveText,
7692        )?;
7693        let stale_after_auth = account.apply_account_transport_result(
7694            transport_result(
7695                &receive,
7696                AccountTransportResultKind::FrameReceived,
7697                serde_json::json!(["NEG-ERR", initial_neg_subscription, "stale", 500_u64])
7698                    .to_string(),
7699            ),
7700            1_700_000_009,
7701        )?;
7702        assert_eq!(
7703            stale_after_auth.connection_state,
7704            RelayConnectionState::Ready
7705        );
7706        receive = transport_action(&stale_after_auth, AccountTransportActionKind::ReceiveText)?;
7707        let reconnecting = account.apply_account_transport_result(
7708            transport_result(
7709                &receive,
7710                AccountTransportResultKind::FrameReceived,
7711                serde_json::json!(["NEG-ERR", fresh_neg_subscription, "current", 500_u64])
7712                    .to_string(),
7713            ),
7714            1_700_000_010,
7715        )?;
7716        assert_eq!(
7717            reconnecting.actions[0].kind,
7718            AccountTransportActionKind::Connect
7719        );
7720        assert!(reconnecting.actions[0].generation > started.actions[0].generation);
7721
7722        account.cancel_account_transport()?;
7723        drop(account);
7724        std::fs::remove_dir_all(directory)?;
7725        Ok(())
7726    }
7727
7728    #[test]
7729    fn account_transport_quarantines_bad_frames_and_keeps_the_same_connection()
7730    -> Result<(), Box<dyn std::error::Error>> {
7731        let directory = temporary_directory("transport-bad-frame-recovery")?;
7732        let account =
7733            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
7734        let started = account.start_account_transport("bad-frame-run".to_owned())?;
7735        let connected = account.apply_account_transport_result(
7736            transport_result(
7737                &started.actions[0],
7738                AccountTransportResultKind::Connected,
7739                String::new(),
7740            ),
7741            1_700_000_000,
7742        )?;
7743        let generation = started.actions[0].generation;
7744        let mut receive = transport_action(&connected, AccountTransportActionKind::ReceiveText)?;
7745        let bad_frames = [
7746            "{not-json".to_owned(),
7747            serde_json::json!(["FUTURE", { "extension": true }]).to_string(),
7748            serde_json::json!(["EVENT", "softchat-live-0", { "id": "00" }]).to_string(),
7749            RelayResponseFrame::Eose {
7750                subscription_id: "unknown-subscription".to_owned(),
7751            }
7752            .to_json()?,
7753            RelayResponseFrame::Ok(crate::BatchAcknowledgement {
7754                event_id: "00".repeat(32),
7755                accepted: true,
7756                message: String::new(),
7757            })
7758            .to_json()?,
7759        ];
7760
7761        for (index, bad_frame) in bad_frames.into_iter().enumerate() {
7762            let after_bad = account.apply_account_transport_result(
7763                transport_result(
7764                    &receive,
7765                    AccountTransportResultKind::FrameReceived,
7766                    bad_frame,
7767                ),
7768                1_700_000_001 + i64::try_from(index)? * 2,
7769            )?;
7770            assert_eq!(after_bad.connection_state, RelayConnectionState::Ready);
7771            assert!(
7772                after_bad
7773                    .actions
7774                    .iter()
7775                    .all(|action| action.generation == generation)
7776            );
7777            receive = transport_action(&after_bad, AccountTransportActionKind::ReceiveText)?;
7778
7779            let after_valid = account.apply_account_transport_result(
7780                transport_result(
7781                    &receive,
7782                    AccountTransportResultKind::FrameReceived,
7783                    RelayResponseFrame::Notice("valid-next-frame".to_owned()).to_json()?,
7784                ),
7785                1_700_000_002 + i64::try_from(index)? * 2,
7786            )?;
7787            assert_eq!(after_valid.connection_state, RelayConnectionState::Ready);
7788            receive = transport_action(&after_valid, AccountTransportActionKind::ReceiveText)?;
7789            assert_eq!(receive.generation, generation);
7790        }
7791
7792        account.cancel_account_transport()?;
7793        drop(account);
7794        std::fs::remove_dir_all(directory)?;
7795        Ok(())
7796    }
7797
7798    #[test]
7799    fn account_transport_reconnects_when_relay_closes_a_live_subscription()
7800    -> Result<(), Box<dyn std::error::Error>> {
7801        let directory = temporary_directory("transport-live-closed")?;
7802        let account =
7803            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
7804        let started = account.start_account_transport("closed-live-run".to_owned())?;
7805        let connected = account.apply_account_transport_result(
7806            transport_result(
7807                &started.actions[0],
7808                AccountTransportResultKind::Connected,
7809                String::new(),
7810            ),
7811            1_700_000_000,
7812        )?;
7813        let receive = transport_action(&connected, AccountTransportActionKind::ReceiveText)?;
7814
7815        let reconnecting = account.apply_account_transport_result(
7816            transport_result(
7817                &receive,
7818                AccountTransportResultKind::FrameReceived,
7819                RelayResponseFrame::Closed {
7820                    subscription_id: "softchat-live-0".to_owned(),
7821                    message: "rate-limited".to_owned(),
7822                }
7823                .to_json()?,
7824            ),
7825            1_700_000_001,
7826        )?;
7827        let connect = transport_action(&reconnecting, AccountTransportActionKind::Connect)?;
7828        assert!(connect.generation > started.actions[0].generation);
7829        assert_eq!(
7830            reconnecting.connection_state,
7831            RelayConnectionState::Connecting
7832        );
7833
7834        account.cancel_account_transport()?;
7835        drop(account);
7836        std::fs::remove_dir_all(directory)?;
7837        Ok(())
7838    }
7839
7840    #[test]
7841    fn account_transport_accepts_delivery_ack_before_socket_write()
7842    -> Result<(), Box<dyn std::error::Error>> {
7843        let directory = temporary_directory("transport-ack-before-write")?;
7844        let account =
7845            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
7846        let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
7847        let conversation =
7848            account.get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
7849        let sent = account.send_message(
7850            "ack-before-write".to_owned(),
7851            conversation.id,
7852            content("delivery ordering"),
7853            String::new(),
7854            None,
7855            1_700_000_001,
7856        )?;
7857        let operation_id = sent.operation.operation_id;
7858
7859        let started = account.start_account_transport("ack-order-run".to_owned())?;
7860        let connected = account.apply_account_transport_result(
7861            transport_result(
7862                &started.actions[0],
7863                AccountTransportResultKind::Connected,
7864                String::new(),
7865            ),
7866            1_700_000_002,
7867        )?;
7868        let delivery_sends = delivery_send_actions(&connected)?;
7869        assert!(!delivery_sends.is_empty());
7870        let first_event_id = delivery_sends[0].1[0].clone();
7871        let mut receive = transport_action(&connected, AccountTransportActionKind::ReceiveText)?;
7872
7873        for (_, event_ids) in &delivery_sends {
7874            for event_id in event_ids {
7875                let acknowledged = account.apply_account_transport_result(
7876                    transport_result(
7877                        &receive,
7878                        AccountTransportResultKind::FrameReceived,
7879                        RelayResponseFrame::Ok(crate::BatchAcknowledgement {
7880                            event_id: event_id.clone(),
7881                            accepted: true,
7882                            message: String::new(),
7883                        })
7884                        .to_json()?,
7885                    ),
7886                    1_700_000_003,
7887                )?;
7888                receive = transport_action(&acknowledged, AccountTransportActionKind::ReceiveText)?;
7889            }
7890        }
7891        for (action, _) in &delivery_sends {
7892            account.apply_account_transport_result(
7893                transport_result(
7894                    action,
7895                    AccountTransportResultKind::FrameWritten,
7896                    String::new(),
7897                ),
7898                1_700_000_004,
7899            )?;
7900        }
7901        assert_eq!(
7902            account.operation(operation_id.clone())?.state,
7903            crate::OperationState::Sent
7904        );
7905
7906        let duplicate = account.apply_account_transport_result(
7907            transport_result(
7908                &receive,
7909                AccountTransportResultKind::FrameReceived,
7910                RelayResponseFrame::Ok(crate::BatchAcknowledgement {
7911                    event_id: first_event_id.clone(),
7912                    accepted: true,
7913                    message: String::new(),
7914                })
7915                .to_json()?,
7916            ),
7917            1_700_000_005,
7918        )?;
7919        let conflicting_receive =
7920            transport_action(&duplicate, AccountTransportActionKind::ReceiveText)?;
7921        let after_conflict = account.apply_account_transport_result(
7922            transport_result(
7923                &conflicting_receive,
7924                AccountTransportResultKind::FrameReceived,
7925                RelayResponseFrame::Ok(crate::BatchAcknowledgement {
7926                    event_id: first_event_id,
7927                    accepted: false,
7928                    message: "blocked".to_owned(),
7929                })
7930                .to_json()?,
7931            ),
7932            1_700_000_006,
7933        )?;
7934        assert_eq!(after_conflict.connection_state, RelayConnectionState::Ready);
7935        transport_action(&after_conflict, AccountTransportActionKind::ReceiveText)?;
7936        assert_eq!(
7937            account.operation(operation_id)?.state,
7938            crate::OperationState::Sent
7939        );
7940
7941        assert_eq!(
7942            account.cancel_account_transport()?.connection_state,
7943            RelayConnectionState::Cancelled
7944        );
7945        drop(account);
7946        std::fs::remove_dir_all(directory)?;
7947        Ok(())
7948    }
7949
7950    #[test]
7951    fn account_transport_reclaims_a_delivery_once_after_reconnect()
7952    -> Result<(), Box<dyn std::error::Error>> {
7953        let directory = temporary_directory("transport-delivery-reconnect")?;
7954        let account =
7955            AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
7956        let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
7957        let conversation =
7958            account.get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
7959        let sent = account.send_message(
7960            "delivery-reconnect".to_owned(),
7961            conversation.id,
7962            content("retry after disconnect"),
7963            String::new(),
7964            None,
7965            1_700_000_001,
7966        )?;
7967
7968        let started = account.start_account_transport("reconnect-run".to_owned())?;
7969        let connected = account.apply_account_transport_result(
7970            transport_result(
7971                &started.actions[0],
7972                AccountTransportResultKind::Connected,
7973                String::new(),
7974            ),
7975            1_700_000_002,
7976        )?;
7977        let delivery_sends = delivery_send_actions(&connected)?;
7978        assert!(!delivery_sends.is_empty());
7979        let expected_event_ids = delivery_sends
7980            .iter()
7981            .flat_map(|(_, event_ids)| event_ids.iter().cloned())
7982            .collect::<BTreeSet<_>>();
7983        for (action, _) in &delivery_sends {
7984            account.apply_account_transport_result(
7985                transport_result(
7986                    action,
7987                    AccountTransportResultKind::FrameWritten,
7988                    String::new(),
7989                ),
7990                1_700_000_003,
7991            )?;
7992        }
7993        let receive = transport_action(&connected, AccountTransportActionKind::ReceiveText)?;
7994        let reconnecting = account.apply_account_transport_result(
7995            transport_result(
7996                &receive,
7997                AccountTransportResultKind::Disconnected,
7998                String::new(),
7999            ),
8000            1_700_000_004,
8001        )?;
8002        let reconnect = transport_action(&reconnecting, AccountTransportActionKind::Connect)?;
8003
8004        let reconnected = account.apply_account_transport_result(
8005            transport_result(
8006                &reconnect,
8007                AccountTransportResultKind::Connected,
8008                String::new(),
8009            ),
8010            1_700_000_005,
8011        )?;
8012        assert_eq!(reconnected.connection_state, RelayConnectionState::Ready);
8013        let reclaimed_event_ids = delivery_send_actions(&reconnected)?
8014            .into_iter()
8015            .flat_map(|(_, event_ids)| event_ids)
8016            .collect::<Vec<_>>();
8017        assert_eq!(
8018            reclaimed_event_ids.iter().collect::<BTreeSet<_>>(),
8019            expected_event_ids.iter().collect::<BTreeSet<_>>(),
8020        );
8021        assert_eq!(reclaimed_event_ids.len(), expected_event_ids.len());
8022        assert_eq!(
8023            account.operation(sent.operation.operation_id)?.state,
8024            crate::OperationState::Sending,
8025        );
8026
8027        account.cancel_account_transport()?;
8028        drop(account);
8029        std::fs::remove_dir_all(directory)?;
8030        Ok(())
8031    }
8032}