1#![allow(
10 clippy::too_many_lines,
11 reason = "the schema and its transactional invariants are reviewed together"
12)]
13
14use std::collections::BTreeSet;
15use std::fmt;
16use std::ops::Deref;
17use std::path::Path;
18use std::time::Duration;
19
20use rusqlite::{
21 Connection, OptionalExtension, Savepoint, Transaction, TransactionBehavior, params,
22};
23use serde::Serialize;
24use url::Url;
25
26use crate::account_diagnostics::{
27 AccountLogCategory, AccountLogCounterKind as Counter, AccountLogLevel, AccountLogSource,
28 AccountLogSubject, CommitDiagnostics, Diagnostic,
29};
30use crate::chat::subject_icon_metadata;
31use crate::event::{RumorEvent, SignedEvent};
32use crate::runtime::stable_intent_id;
33use crate::{
34 AccountEngine, DeliveryClaim, DeliveryIntentSnapshot, DeliveryIntentState, DeliveryReducer,
35 DnsRelayRecord, IngestionReceipt, LocalIdentity, NegentropyItem, Nip59EnvelopeKind,
36 OperationSnapshot, PreparedIncomingBatch, PreparedOutgoingOperation, ProjectionKind,
37 RelayCatalogEntry, RelayCatalogPlan, RelayCatalogReducer, RelayCatalogSource,
38 RelayDeliveryResult, SignedNostrEvent, SoftchatError,
39};
40
41const ACCOUNT_SCHEMA_VERSION: i64 = 16;
42
43const EFFECTIVE_MESSAGE_EDITS_SCHEMA: &str = "
46 CREATE VIEW IF NOT EXISTS effective_message_edits AS
47 SELECT original.logical_event_id AS message_id,
48 edit.logical_event_id AS edit_id,
49 edit.content AS content
50 FROM projections original
51 JOIN projections edit ON edit.logical_event_id = (
52 SELECT candidate.logical_event_id
53 FROM projection_targets target
54 JOIN projections candidate
55 ON candidate.logical_event_id = target.logical_event_id
56 WHERE target.target_event_id = original.logical_event_id
57 AND candidate.kind = 'edit'
58 AND candidate.author_public_key = original.author_public_key
59 AND NOT EXISTS (
60 SELECT 1
61 FROM projection_targets deletion_target
62 JOIN projections deletion
63 ON deletion.logical_event_id = deletion_target.logical_event_id
64 WHERE deletion_target.target_event_id = candidate.logical_event_id
65 AND deletion.kind = 'deletion'
66 AND deletion.author_public_key = candidate.author_public_key
67 )
68 ORDER BY candidate.created_at DESC, candidate.logical_event_id ASC
69 LIMIT 1
70 )
71 WHERE original.kind = 'chat_message' AND original.ephemeral = 0;
72";
73const MAX_DATABASE_PATH_BYTES: usize = 4_096;
74const MAX_QUERY_PAGE: u32 = 200;
75const MAX_LOCAL_CONTACT_NAME_BYTES: usize = 256;
76const MAX_CACHE_URL_BYTES: usize = 4_096;
77const MAX_STICKER_NAME_BYTES: usize = 128;
78const MAX_STICKER_EMOJIS: usize = 64;
79const MAX_STICKER_EMOJI_BYTES: usize = 64;
80const MAX_LINK_PREVIEW_TITLE_BYTES: usize = 1_024;
81const MAX_LINK_PREVIEW_DESCRIPTION_BYTES: usize = 8_192;
82const DEFAULT_BUSY_TIMEOUT: Duration = Duration::from_secs(5);
83const DEFAULT_RELAY_URL: &str = "wss://it2.softchat.c84ad60.xyz/";
84const MAX_STORED_TRANSPORT_OUTCOME_BYTES: usize = 6
89 * (crate::MAX_DELIVERY_CLAIM_BYTES
90 + crate::MAX_RELAY_FRAME_BYTES
91 + crate::account_transport::MAX_TRANSPORT_ACTIONS
92 * crate::account_transport::SYNC_FRAME_SIZE_LIMIT as usize)
93 + crate::account_transport::MAX_TRANSPORT_ACTIONS * 8 * 1_024;
94
95const ACCOUNT_CLEAR_TABLES: &[&str] = &[
102 "pending_message_attachments",
103 "pending_asset_replacements",
104 "pending_media_messages",
105 "media_operations",
106 "message_local_extras",
107 "message_fts",
108 "projection_targets",
109 "projection_participants",
110 "conversation_members",
111 "conversation_subject_icons",
112 "conversation_read_state",
113 "conversation_state",
114 "app_data_effective",
115 "projections",
116 "authenticated_rumor_wrappers",
117 "authenticated_rumors",
118 "event_fingerprints",
119 "signed_events",
120 "relay_delivery_intents",
121 "outgoing_event_copies",
122 "outgoing_operations",
123 "sync_checkpoints",
124 "local_contacts",
125 "sticker_cache",
126 "link_preview_cache",
127 "quarantine",
128 "processed_transport_results",
129];
130
131enum AccountWriteScope<'connection> {
132 Transaction(Transaction<'connection>),
133 Savepoint(Savepoint<'connection>),
134}
135
136impl Deref for AccountWriteScope<'_> {
137 type Target = Connection;
138
139 fn deref(&self) -> &Self::Target {
140 match self {
141 Self::Transaction(transaction) => transaction,
142 Self::Savepoint(savepoint) => savepoint,
143 }
144 }
145}
146
147impl AccountWriteScope<'_> {
148 fn commit(self) -> Result<(), SoftchatError> {
149 match self {
150 Self::Transaction(transaction) => transaction.commit(),
151 Self::Savepoint(savepoint) => savepoint.commit(),
152 }
153 .map_err(map_sqlite_error)
154 }
155}
156
157fn begin_write_scope(connection: &mut Connection) -> Result<AccountWriteScope<'_>, SoftchatError> {
158 if connection.is_autocommit() {
159 connection
160 .transaction_with_behavior(TransactionBehavior::Immediate)
161 .map(AccountWriteScope::Transaction)
162 .map_err(map_sqlite_error)
163 } else {
164 connection
165 .savepoint()
166 .map(AccountWriteScope::Savepoint)
167 .map_err(map_sqlite_error)
168 }
169}
170
171#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
173#[serde(rename_all = "camelCase")]
174#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
175pub struct AccountDatabaseInfo {
176 pub account_id: String,
178 pub schema_version: i64,
180 pub revision: i64,
182 pub write_ahead_logging: bool,
184}
185
186#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
188#[serde(rename_all = "camelCase")]
189#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
190pub struct AccountDiagnostics {
191 pub schema_version: i64,
193 pub revision: i64,
195 pub active_relay_url: String,
197 pub relay_count: u32,
199 pub unfinished_delivery_count: u32,
201 pub recoverable_media_count: u32,
203 pub stored_event_count: u32,
205 pub quarantine_count: u32,
207 pub database_bytes: u64,
209}
210
211#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
213#[serde(rename_all = "camelCase")]
214#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
215pub struct StoredIngestion {
216 pub receipt: IngestionReceipt,
218 pub revision: i64,
220 pub projections: Vec<AccountProjection>,
227 pub ephemeral_projections: Vec<AccountProjection>,
229}
230
231#[derive(Clone, Copy, Debug, Eq, PartialEq)]
232enum ReplayProjectionPolicy {
233 SuppressKnownRumors,
234 IncludeKnownRumors,
235}
236
237#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
239#[serde(rename_all = "camelCase")]
240#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
241pub struct AccountMutationResult {
242 pub revision: i64,
244}
245
246#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
248#[serde(rename_all = "camelCase")]
249#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
250pub struct RelayFailoverMutation {
251 pub entries: Vec<RelayCatalogEntry>,
253 pub active_relay_url: String,
255 pub rebound_intent_count: u32,
257 pub revision: i64,
259}
260
261#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
263#[serde(rename_all = "camelCase")]
264#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
265pub struct StoredEventJson {
266 pub event_id: String,
268 pub event_json: String,
270}
271
272#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
274#[serde(rename_all = "camelCase")]
275#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
276pub struct RelayCatalogMutation {
277 pub entries: Vec<RelayCatalogEntry>,
279 pub revision: i64,
281}
282
283#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
285#[serde(rename_all = "camelCase")]
286#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
287pub struct StoredRelayCatalogPlan {
288 pub plan: RelayCatalogPlan,
290 pub revision: i64,
292}
293
294#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
296#[serde(rename_all = "camelCase")]
297#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
298pub struct AccountOperationResult {
299 pub operation: OperationSnapshot,
301 pub revision: i64,
303 pub inserted: bool,
305}
306
307pub(crate) struct ProductAccountOperationResult {
308 pub operation: AccountOperationResult,
309 pub result_message_id: String,
310}
311
312#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
314#[serde(rename_all = "camelCase")]
315#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
316pub struct DeliveryPayload {
317 pub intent_id: String,
319 pub relay_url: String,
321 pub event_json: String,
323}
324
325#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
327#[serde(rename_all = "camelCase")]
328#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
329pub struct ClaimedDelivery {
330 pub claim: DeliveryClaim,
332 pub payloads: Vec<DeliveryPayload>,
334 pub revision: i64,
336}
337
338#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
340#[serde(rename_all = "camelCase")]
341#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
342pub struct ConversationSummary {
343 pub conversation_id: String,
345 pub participants: Vec<String>,
347 pub last_message: String,
349 pub last_created_at: i64,
351 pub message_count: u32,
353}
354
355#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
357#[serde(rename_all = "camelCase")]
358#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
359pub struct ConversationView {
360 pub summary: ConversationSummary,
362 pub last_message: AccountEventNode,
364 pub subject: Option<AccountProjection>,
366}
367
368#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
370#[serde(rename_all = "camelCase")]
371#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
372pub struct AccountMessage {
373 pub event_id: String,
375 pub conversation_id: String,
377 pub author_public_key: String,
379 pub created_at: i64,
381 pub content: String,
383 pub deleted: bool,
385 pub edited: bool,
387 pub reply_to_event_id: String,
389}
390
391#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
393#[serde(rename_all = "camelCase")]
394#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
395pub struct AccountProjection {
396 pub kind: ProjectionKind,
398 pub logical_event_id: String,
400 pub outer_event: SignedEvent,
402 pub rumor: Option<RumorEvent>,
404 pub author_public_key: String,
406 pub created_at: i64,
408 pub conversation_id: String,
410 pub participants: Vec<String>,
412 pub target_event_ids: Vec<String>,
414 pub content: String,
416}
417
418#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
420#[serde(rename_all = "camelCase")]
421#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
422pub struct AccountEventNode {
423 pub projection: AccountProjection,
425 pub effective_content: String,
427 pub deleted: bool,
429 pub edited: bool,
431 pub reply_count: u32,
433}
434
435#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
437#[serde(rename_all = "camelCase")]
438#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
439pub struct StickerRecord {
440 pub name: String,
442 pub url: String,
444 pub associated_emojis: Vec<String>,
446}
447
448#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
450#[serde(rename_all = "camelCase")]
451#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
452pub struct LinkPreviewRecord {
453 pub url: String,
455 pub title: Option<String>,
457 pub description: Option<String>,
459 pub image_url: Option<String>,
461 pub updated_at_millis: i64,
463}
464
465#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
471#[serde(rename_all = "camelCase")]
472#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
473pub struct LocalContactRecord {
474 pub public_key: String,
476 pub name: Option<String>,
478 pub updated_at_millis: i64,
480}
481
482pub struct AccountDatabase {
488 pub(crate) connection: Connection,
489 pub(crate) account_id: String,
490 write_ahead_logging: bool,
491 pub(crate) diagnostics: CommitDiagnostics,
492}
493
494struct RawConversationView {
495 conversation_id: String,
496 message_count: u32,
497 logical_event_id: String,
498 author_public_key: String,
499 created_at: i64,
500 content: String,
501 outer_json: String,
502 rumor_json: Option<String>,
503 participants_csv: Option<String>,
504 targets_csv: Option<String>,
505 edited_content: Option<String>,
506 reply_count: u32,
507 subject: Option<RawProjection>,
508}
509
510struct RawProjection {
511 stored_kind: String,
512 logical_event_id: String,
513 author_public_key: String,
514 created_at: i64,
515 conversation_id: String,
516 content: String,
517 outer_json: String,
518 rumor_json: Option<String>,
519 participants_csv: Option<String>,
520 targets_csv: Option<String>,
521}
522
523struct RawEventNode {
524 stored_kind: String,
525 logical_event_id: String,
526 author_public_key: String,
527 created_at: i64,
528 conversation_id: String,
529 content: String,
530 outer_json: String,
531 rumor_json: Option<String>,
532 participants_csv: Option<String>,
533 targets_csv: Option<String>,
534 deleted: bool,
535 edited_content: Option<String>,
536 reply_count: u32,
537}
538
539impl fmt::Debug for AccountDatabase {
540 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
541 formatter
542 .debug_struct("AccountDatabase")
543 .field("account_id", &self.account_id)
544 .field("write_ahead_logging", &self.write_ahead_logging)
545 .finish_non_exhaustive()
546 }
547}
548
549impl AccountDatabase {
550 pub(crate) fn next_app_data_event_timestamp(
551 &self,
552 context: &str,
553 now: i64,
554 ) -> Result<i64, SoftchatError> {
555 validate_timestamp(now)?;
556 let current = self
557 .connection
558 .query_row(
559 "SELECT event_created_at FROM app_data_effective WHERE context = ?1",
560 [context],
561 |row| row.get::<_, i64>(0),
562 )
563 .optional()
564 .map_err(map_sqlite_error)?;
565 current
566 .filter(|created_at| *created_at >= now)
567 .map_or(Ok(now), |created_at| {
568 created_at
569 .checked_add(1)
570 .ok_or(SoftchatError::InvalidAccountOperation)
571 })
572 }
573
574 pub fn open(path: impl AsRef<Path>, account_id: String) -> Result<Self, SoftchatError> {
582 AccountEngine::validate_account_id(&account_id)?;
583 let path = path.as_ref();
584 let path_text = path.to_string_lossy();
585 if path_text.is_empty()
586 || path_text.len() > MAX_DATABASE_PATH_BYTES
587 || path_text.as_bytes().contains(&0)
588 {
589 return Err(SoftchatError::InvalidPersistenceResult);
590 }
591
592 let mut connection = Connection::open(path).map_err(map_sqlite_error)?;
593 connection
594 .busy_timeout(DEFAULT_BUSY_TIMEOUT)
595 .map_err(map_sqlite_error)?;
596 connection
597 .pragma_update(None, "foreign_keys", "ON")
598 .map_err(map_sqlite_error)?;
599 connection
600 .pragma_update(None, "synchronous", "NORMAL")
601 .map_err(map_sqlite_error)?;
602 connection
603 .pragma_update(None, "temp_store", "MEMORY")
604 .map_err(map_sqlite_error)?;
605 let journal_mode: String = connection
606 .pragma_query_value(None, "journal_mode", |row| row.get(0))
607 .map_err(map_sqlite_error)?;
608 let journal_mode = if journal_mode.eq_ignore_ascii_case("wal") || path_text == ":memory:" {
609 journal_mode
610 } else {
611 connection
612 .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
613 .map_err(map_sqlite_error)?
614 };
615 let write_ahead_logging = journal_mode.eq_ignore_ascii_case("wal");
616 if path_text != ":memory:" && !write_ahead_logging {
617 return Err(SoftchatError::InvalidPersistenceResult);
618 }
619
620 match account_schema_ownership(&connection)? {
621 AccountSchemaOwnership::Foreign => {
622 return Err(SoftchatError::InvalidPersistenceResult);
623 }
624 AccountSchemaOwnership::Owned if current_schema_matches(&connection, &account_id)? => {}
625 AccountSchemaOwnership::Empty | AccountSchemaOwnership::Owned => {
626 migrate(&mut connection, &account_id)?;
627 }
628 }
629 Ok(Self {
630 connection,
631 account_id,
632 write_ahead_logging,
633 diagnostics: CommitDiagnostics::default(),
634 })
635 }
636
637 pub(crate) fn record_diagnostic(&self, diagnostic: Diagnostic) {
638 self.diagnostics
639 .record(self.connection.is_autocommit(), diagnostic);
640 }
641
642 pub(crate) fn local_state_differs<P: rusqlite::Params>(&self, sql: &str, params: P) -> bool {
644 self.diagnostics.debug_enabled()
645 && self
646 .connection
647 .query_row(sql, params, |row| row.get::<_, bool>(0))
648 .unwrap_or(false)
649 }
650
651 pub(crate) fn record_local_change(&self, changed: bool, revision: i64) {
652 if changed {
653 let mut event = Diagnostic::new(
654 AccountLogCategory::Storage,
655 "storage.local.changed",
656 "Local account state changed",
657 )
658 .debug();
659 event.direction = Some(crate::account_diagnostics::AccountLogDirection::Local);
660 event.source = Some(AccountLogSource::LocalCommand);
661 event.revision = Some(revision);
662 self.record_diagnostic(event);
663 }
664 }
665
666 pub fn info(&self) -> Result<AccountDatabaseInfo, SoftchatError> {
672 Ok(AccountDatabaseInfo {
673 account_id: self.account_id.clone(),
674 schema_version: meta_integer(&self.connection, "schema_version")?,
675 revision: self.revision()?,
676 write_ahead_logging: self.write_ahead_logging,
677 })
678 }
679
680 pub fn revision(&self) -> Result<i64, SoftchatError> {
686 meta_integer(&self.connection, "revision")
687 }
688
689 pub fn diagnostics(&self) -> Result<AccountDiagnostics, SoftchatError> {
695 let info = self.info()?;
696 let catalog = self.relay_catalog()?;
697 let active_relay_url = catalog
698 .iter()
699 .find(|entry| entry.is_active)
700 .map(|entry| entry.url.clone())
701 .unwrap_or_default();
702 let relay_count =
703 u32::try_from(catalog.len()).map_err(|_| SoftchatError::InvalidPersistenceResult)?;
704 let unfinished_delivery_count = unfinished_delivery_count(&self.connection)?;
705 let recoverable_media_count = bounded_query_count(
706 &self.connection,
707 "SELECT COUNT(*) FROM media_operations
708 WHERE state IN ('prepared', 'running', 'retryable')",
709 )?;
710 let stored_event_count =
711 bounded_query_count(&self.connection, "SELECT COUNT(*) FROM signed_events")?;
712 let quarantine_count =
713 bounded_query_count(&self.connection, "SELECT COUNT(*) FROM quarantine")?;
714 let page_count: i64 = self
715 .connection
716 .pragma_query_value(None, "page_count", |row| row.get(0))
717 .map_err(map_sqlite_error)?;
718 let page_size: i64 = self
719 .connection
720 .pragma_query_value(None, "page_size", |row| row.get(0))
721 .map_err(map_sqlite_error)?;
722 let page_count =
723 u64::try_from(page_count).map_err(|_| SoftchatError::InvalidPersistenceResult)?;
724 let page_size =
725 u64::try_from(page_size).map_err(|_| SoftchatError::InvalidPersistenceResult)?;
726 Ok(AccountDiagnostics {
727 schema_version: info.schema_version,
728 revision: info.revision,
729 active_relay_url,
730 relay_count,
731 unfinished_delivery_count,
732 recoverable_media_count,
733 stored_event_count,
734 quarantine_count,
735 database_bytes: page_count.saturating_mul(page_size),
736 })
737 }
738
739 pub fn ingest(
749 &mut self,
750 identity: &LocalIdentity,
751 events: Vec<SignedEvent>,
752 received_at: i64,
753 ) -> Result<StoredIngestion, SoftchatError> {
754 self.ingest_with_projection_policy(
755 identity,
756 events,
757 received_at,
758 ReplayProjectionPolicy::SuppressKnownRumors,
759 AccountLogSource::IncomingApi,
760 )
761 }
762
763 pub(crate) fn ingest_with_authenticated_replays(
764 &mut self,
765 identity: &LocalIdentity,
766 events: Vec<SignedEvent>,
767 received_at: i64,
768 ) -> Result<StoredIngestion, SoftchatError> {
769 self.ingest_with_projection_policy(
770 identity,
771 events,
772 received_at,
773 ReplayProjectionPolicy::IncludeKnownRumors,
774 AccountLogSource::IncomingApi,
775 )
776 }
777
778 pub(crate) fn ingest_from_relay(
779 &mut self,
780 identity: &LocalIdentity,
781 events: Vec<SignedEvent>,
782 received_at: i64,
783 historical: bool,
784 ) -> Result<StoredIngestion, SoftchatError> {
785 self.ingest_with_projection_policy(
786 identity,
787 events,
788 received_at,
789 ReplayProjectionPolicy::SuppressKnownRumors,
790 if historical {
791 AccountLogSource::RelaySync
792 } else {
793 AccountLogSource::RelayLive
794 },
795 )
796 }
797
798 fn ingest_with_projection_policy(
799 &mut self,
800 identity: &LocalIdentity,
801 events: Vec<SignedEvent>,
802 received_at: i64,
803 replay_policy: ReplayProjectionPolicy,
804 source: AccountLogSource,
805 ) -> Result<StoredIngestion, SoftchatError> {
806 validate_timestamp(received_at)?;
807 let batch = AccountEngine::prepare_incoming(identity, self.account_id.clone(), events)?;
808 let ephemeral_projections = batch
809 .events
810 .iter()
811 .filter(|event| event.projection.ephemeral)
812 .map(account_projection_from_prepared)
813 .collect();
814 let transaction = begin_write_scope(&mut self.connection)?;
815 let committed = ingest_prepared(
816 &transaction,
817 &batch,
818 received_at,
819 self.diagnostics.enabled(),
820 )?;
821 AccountEngine::validate_ingestion_receipt(&batch, &committed.receipt)?;
822 let quarantined = committed
823 .receipt
824 .quarantined_event_ids
825 .iter()
826 .cloned()
827 .collect::<BTreeSet<_>>();
828 let mut returned_logical_ids = BTreeSet::new();
829 let projections = batch
830 .events
831 .iter()
832 .filter(|event| {
833 !quarantined.contains(&event.outer_event.id)
834 && (event.projection.ephemeral
835 || replay_policy == ReplayProjectionPolicy::IncludeKnownRumors
836 || !committed
837 .suppressed_projection_outer_event_ids
838 .contains(&event.outer_event.id))
839 })
840 .filter(|event| {
841 replay_policy == ReplayProjectionPolicy::SuppressKnownRumors
842 || returned_logical_ids.insert(event.projection.logical_event_id.clone())
843 })
844 .map(account_projection_from_prepared)
845 .collect();
846 let revision = bump_revision(&transaction)?;
847 transaction.commit()?;
848 for mut diagnostic in committed.diagnostics {
849 diagnostic.revision = Some(revision);
850 diagnostic.source = Some(source);
851 diagnostic.direction = Some(crate::account_diagnostics::AccountLogDirection::Rx);
852 if source == AccountLogSource::RelaySync {
853 diagnostic.summarize = true;
854 if diagnostic.level == AccountLogLevel::Info {
855 diagnostic.category = AccountLogCategory::Synchronization;
856 diagnostic.operation = "sync.progress";
857 diagnostic.summary = "Historical events stored";
858 }
859 }
860 self.record_diagnostic(diagnostic);
861 }
862 Ok(StoredIngestion {
863 receipt: committed.receipt,
864 revision,
865 projections,
866 ephemeral_projections,
867 })
868 }
869
870 #[allow(clippy::too_many_arguments)]
881 pub fn enqueue_rumor(
882 &mut self,
883 identity: &LocalIdentity,
884 operation_id: String,
885 rumor: RumorEvent,
886 recipients: Vec<String>,
887 notification_recipients: Vec<String>,
888 relay_urls: Vec<String>,
889 kind: Nip59EnvelopeKind,
890 now: i64,
891 ) -> Result<AccountOperationResult, SoftchatError> {
892 if let Some(operation) = self.operation_optional(&operation_id)? {
893 self.record_diagnostic(Diagnostic::replay());
894 return Ok(AccountOperationResult {
895 operation,
896 revision: self.revision()?,
897 inserted: false,
898 });
899 }
900 let prepared = AccountEngine::prepare_rumor_operation(
901 identity,
902 self.account_id.clone(),
903 operation_id,
904 rumor,
905 recipients,
906 notification_recipients,
907 relay_urls,
908 kind,
909 now,
910 )?;
911 let sender_public_key = identity.public_key().to_hex();
912 self.persist_operation(identity, prepared, &sender_public_key, now, "")
913 }
914
915 pub fn enqueue_signed(
924 &mut self,
925 identity: &LocalIdentity,
926 operation_id: String,
927 events: Vec<SignedEvent>,
928 relay_urls: Vec<String>,
929 now: i64,
930 ) -> Result<AccountOperationResult, SoftchatError> {
931 if let Some(operation) = self.operation_optional(&operation_id)? {
932 self.record_diagnostic(Diagnostic::replay());
933 return Ok(AccountOperationResult {
934 operation,
935 revision: self.revision()?,
936 inserted: false,
937 });
938 }
939 let prepared = AccountEngine::prepare_signed_operation(
940 self.account_id.clone(),
941 operation_id,
942 events,
943 relay_urls,
944 )?;
945 self.persist_operation(identity, prepared, "", now, "")
946 }
947
948 #[allow(clippy::too_many_arguments)]
954 pub(crate) fn enqueue_product_rumor(
955 &mut self,
956 identity: &LocalIdentity,
957 command_id: String,
958 command_hash: String,
959 result_message_id: String,
960 rumor: RumorEvent,
961 recipients: Vec<String>,
962 notification_recipients: Vec<String>,
963 kind: Nip59EnvelopeKind,
964 now: i64,
965 draft_to_consume: Option<(&str, &crate::account_product::ProductDraftMatch)>,
966 ) -> Result<ProductAccountOperationResult, SoftchatError> {
967 if command_hash.len() != 64 || !command_hash.bytes().all(|byte| byte.is_ascii_hexdigit()) {
968 return Err(SoftchatError::InvalidAccountOperation);
969 }
970 if let Some(existing) = self.product_operation_if_committed(&command_id, &command_hash)? {
971 return Ok(existing);
972 }
973 let relay_url = self.active_relay_url()?;
974 let prepared = AccountEngine::prepare_rumor_operation(
975 identity,
976 self.account_id.clone(),
977 command_id,
978 rumor,
979 recipients,
980 notification_recipients,
981 vec![relay_url],
982 kind,
983 now,
984 )?;
985 let sender_public_key = identity.public_key().to_hex();
986 self.persist_product_operation(
987 identity,
988 prepared,
989 &sender_public_key,
990 now,
991 &command_hash,
992 &result_message_id,
993 draft_to_consume,
994 )
995 }
996
997 pub(crate) fn product_operation_if_committed(
998 &self,
999 command_id: &str,
1000 command_hash: &str,
1001 ) -> Result<Option<ProductAccountOperationResult>, SoftchatError> {
1002 let Some(existing_hash) = self.operation_command_hash(command_id)? else {
1003 return Ok(None);
1004 };
1005 if existing_hash != command_hash {
1006 return Err(SoftchatError::CommandConflict);
1007 }
1008 self.record_diagnostic(Diagnostic::replay());
1009 Ok(Some(ProductAccountOperationResult {
1010 operation: AccountOperationResult {
1011 operation: self.operation(command_id)?,
1012 revision: self.revision()?,
1013 inserted: false,
1014 },
1015 result_message_id: self.operation_result_message_id(command_id)?,
1016 }))
1017 }
1018
1019 pub(crate) fn enqueue_product_signed(
1021 &mut self,
1022 command_id: String,
1023 command_hash: String,
1024 result_message_id: String,
1025 events: Vec<SignedEvent>,
1026 now: i64,
1027 diagnostic_subject: AccountLogSubject,
1028 ) -> Result<ProductAccountOperationResult, SoftchatError> {
1029 if command_hash.len() != 64 || !command_hash.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1030 return Err(SoftchatError::InvalidAccountOperation);
1031 }
1032 if let Some(existing_hash) = self.operation_command_hash(&command_id)? {
1033 if existing_hash != command_hash {
1034 return Err(SoftchatError::CommandConflict);
1035 }
1036 self.record_diagnostic(Diagnostic::replay());
1037 return Ok(ProductAccountOperationResult {
1038 operation: AccountOperationResult {
1039 operation: self.operation(&command_id)?,
1040 revision: self.revision()?,
1041 inserted: false,
1042 },
1043 result_message_id: self.operation_result_message_id(&command_id)?,
1044 });
1045 }
1046 let relay_url = self.active_relay_url()?;
1047 let prepared = AccountEngine::prepare_signed_operation(
1048 self.account_id.clone(),
1049 command_id,
1050 events,
1051 vec![relay_url],
1052 )?;
1053 self.persist_product_operation_without_identity(
1054 prepared,
1055 now,
1056 &command_hash,
1057 &result_message_id,
1058 diagnostic_subject,
1059 )
1060 }
1061
1062 #[allow(clippy::too_many_arguments)]
1064 pub(crate) fn enqueue_product_signed_with_settings(
1065 &mut self,
1066 command_id: String,
1067 command_hash: String,
1068 events: Vec<SignedEvent>,
1069 settings_schema_version: u32,
1070 canonical_settings_json: String,
1071 wire_settings_json: String,
1072 now: i64,
1073 ) -> Result<ProductAccountOperationResult, SoftchatError> {
1074 validate_timestamp(now)?;
1075 if command_hash.len() != 64
1076 || !command_hash.bytes().all(|byte| byte.is_ascii_hexdigit())
1077 || canonical_settings_json.is_empty()
1078 || canonical_settings_json.len() > crate::MAX_ACCOUNT_SETTINGS_BYTES
1079 || wire_settings_json.is_empty()
1080 || wire_settings_json.len() > crate::MAX_ACCOUNT_SETTINGS_BYTES
1081 {
1082 return Err(SoftchatError::InvalidAccountSettings);
1083 }
1084 if let Some(existing_hash) = self.operation_command_hash(&command_id)? {
1085 if existing_hash != command_hash {
1086 return Err(SoftchatError::CommandConflict);
1087 }
1088 self.record_diagnostic(Diagnostic::replay());
1089 return Ok(ProductAccountOperationResult {
1090 operation: AccountOperationResult {
1091 operation: self.operation(&command_id)?,
1092 revision: self.revision()?,
1093 inserted: false,
1094 },
1095 result_message_id: self.operation_result_message_id(&command_id)?,
1096 });
1097 }
1098 let effective_event = events
1099 .first()
1100 .filter(|_| events.len() == 1)
1101 .ok_or(SoftchatError::InvalidAccountSettings)?;
1102 let effective_event_id = effective_event.id.clone();
1103 let effective_created_at = effective_event.created_at;
1104 let relay_url = self.active_relay_url()?;
1105 let prepared = AccountEngine::prepare_signed_operation(
1106 self.account_id.clone(),
1107 command_id,
1108 events,
1109 vec![relay_url],
1110 )?;
1111 let transaction = self
1112 .connection
1113 .transaction_with_behavior(TransactionBehavior::Immediate)
1114 .map_err(map_sqlite_error)?;
1115 transaction
1116 .execute(
1117 "UPDATE account_settings
1118 SET schema_version = ?1, canonical_json = ?2, updated_at = ?3
1119 WHERE singleton = 1",
1120 params![settings_schema_version, canonical_settings_json, now],
1121 )
1122 .map_err(map_sqlite_error)?;
1123 store_effective_app_data(
1124 &transaction,
1125 "app-settings",
1126 &wire_settings_json,
1127 effective_created_at,
1128 &effective_event_id,
1129 )?;
1130 persist_prepared_operation(&transaction, &prepared, now, 0, &command_hash, "")?;
1131 let intents = load_operation_intents(&transaction, &prepared.operation_id)?;
1132 let operation = DeliveryReducer::operation_snapshot(intents)?;
1133 let revision = bump_revision(&transaction)?;
1134 transaction.commit().map_err(map_sqlite_error)?;
1135 let mut diagnostic = Diagnostic::for_subject(AccountLogSubject::AppSettings, true)
1136 .count(Counter::TotalIntents, u64::from(operation.total_intents));
1137 diagnostic.revision = Some(revision);
1138 self.record_diagnostic(diagnostic);
1139 Ok(ProductAccountOperationResult {
1140 operation: AccountOperationResult {
1141 operation,
1142 revision,
1143 inserted: true,
1144 },
1145 result_message_id: String::new(),
1146 })
1147 }
1148
1149 pub(crate) fn enqueue_product_signed_with_read_state(
1151 &mut self,
1152 command_id: String,
1153 command_hash: String,
1154 events: Vec<SignedEvent>,
1155 state: crate::AccountReadState,
1156 canonical_read_state_json: String,
1157 now: i64,
1158 ) -> Result<ProductAccountOperationResult, SoftchatError> {
1159 validate_timestamp(now)?;
1160 if command_hash.len() != 64
1161 || !command_hash.bytes().all(|byte| byte.is_ascii_hexdigit())
1162 || canonical_read_state_json.is_empty()
1163 || canonical_read_state_json.len() > crate::MAX_ACCOUNT_READ_STATE_BYTES
1164 {
1165 return Err(SoftchatError::InvalidAccountOperation);
1166 }
1167 if let Some(existing_hash) = self.operation_command_hash(&command_id)? {
1168 if existing_hash != command_hash {
1169 return Err(SoftchatError::CommandConflict);
1170 }
1171 self.record_diagnostic(Diagnostic::replay());
1172 return Ok(ProductAccountOperationResult {
1173 operation: AccountOperationResult {
1174 operation: self.operation(&command_id)?,
1175 revision: self.revision()?,
1176 inserted: false,
1177 },
1178 result_message_id: self.operation_result_message_id(&command_id)?,
1179 });
1180 }
1181 let effective_event = events
1182 .first()
1183 .filter(|_| events.len() == 1)
1184 .ok_or(SoftchatError::InvalidAccountOperation)?;
1185 let effective_event_id = effective_event.id.clone();
1186 let effective_created_at = effective_event.created_at;
1187 let parsed = crate::account_product::parse_read_state_snapshot(
1188 &canonical_read_state_json,
1189 effective_created_at,
1190 &effective_event_id,
1191 )?;
1192 let parsed_entry = parsed
1193 .entries
1194 .into_iter()
1195 .find(|entry| entry.state.conversation_id == state.conversation_id)
1196 .ok_or(SoftchatError::InvalidAccountOperation)?;
1197 if parsed_entry.state != state {
1198 return Err(SoftchatError::InvalidAccountOperation);
1199 }
1200 let relay_url = self.active_relay_url()?;
1201 let prepared = AccountEngine::prepare_signed_operation(
1202 self.account_id.clone(),
1203 command_id,
1204 events,
1205 vec![relay_url],
1206 )?;
1207 let transaction = self
1208 .connection
1209 .transaction_with_behavior(TransactionBehavior::Immediate)
1210 .map_err(map_sqlite_error)?;
1211 let (read_at, read_message_id) = state
1212 .boundary
1213 .as_ref()
1214 .map_or((None, String::new()), |boundary| {
1215 (Some(boundary.created_at), boundary.message_id.clone())
1216 });
1217 transaction
1218 .execute(
1219 "INSERT INTO conversation_read_state
1220 (conversation_id, read_at, read_message_id, forced_unread,
1221 updated_at, update_id, unknown_json)
1222 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
1223 ON CONFLICT(conversation_id) DO UPDATE SET
1224 read_at = excluded.read_at,
1225 read_message_id = excluded.read_message_id,
1226 forced_unread = excluded.forced_unread,
1227 updated_at = excluded.updated_at,
1228 update_id = excluded.update_id,
1229 unknown_json = excluded.unknown_json",
1230 params![
1231 state.conversation_id,
1232 read_at,
1233 read_message_id,
1234 i64::from(state.forced_unread),
1235 state.updated_at,
1236 state.update_id,
1237 parsed_entry.unknown_json,
1238 ],
1239 )
1240 .map_err(map_sqlite_error)?;
1241 store_effective_app_data(
1242 &transaction,
1243 "read-state",
1244 &canonical_read_state_json,
1245 effective_created_at,
1246 &effective_event_id,
1247 )?;
1248 persist_prepared_operation(&transaction, &prepared, now, 0, &command_hash, "")?;
1249 let intents = load_operation_intents(&transaction, &prepared.operation_id)?;
1250 let operation = DeliveryReducer::operation_snapshot(intents)?;
1251 let revision = bump_revision(&transaction)?;
1252 transaction.commit().map_err(map_sqlite_error)?;
1253 let mut diagnostic = Diagnostic::for_subject(AccountLogSubject::ReadState, true)
1254 .count(Counter::TotalIntents, u64::from(operation.total_intents));
1255 diagnostic.revision = Some(revision);
1256 self.record_diagnostic(diagnostic);
1257 Ok(ProductAccountOperationResult {
1258 operation: AccountOperationResult {
1259 operation,
1260 revision,
1261 inserted: true,
1262 },
1263 result_message_id: String::new(),
1264 })
1265 }
1266
1267 pub fn claim_deliveries(
1274 &mut self,
1275 lease_id: String,
1276 relay_url: String,
1277 now: i64,
1278 lease_duration_seconds: u32,
1279 ) -> Result<Option<ClaimedDelivery>, SoftchatError> {
1280 validate_timestamp(now)?;
1281 let relay_url = if relay_url.is_empty() {
1282 String::new()
1283 } else {
1284 crate::parse_relay_endpoint(&relay_url)
1285 .map_err(|_| SoftchatError::InvalidDeliveryState)?
1286 .configured_url
1287 };
1288 if lease_duration_seconds == 0 || lease_duration_seconds > 3_600 {
1289 return Err(SoftchatError::InvalidDeliveryState);
1290 }
1291 let expires_at = now
1292 .checked_add(i64::from(lease_duration_seconds))
1293 .ok_or(SoftchatError::InvalidDeliveryState)?;
1294 let transaction = begin_write_scope(&mut self.connection)?;
1295 let expired_leases = transaction
1296 .execute(
1297 "UPDATE relay_delivery_intents
1298 SET state = 'retryable', lease_id = '', lease_expires_at = 0,
1299 last_category = 'lease_expired'
1300 WHERE state IN ('claimed', 'socket_written')
1301 AND lease_expires_at > 0 AND lease_expires_at <= ?1",
1302 [now],
1303 )
1304 .map_err(map_sqlite_error)?;
1305
1306 let rejected_oversized = transaction
1311 .execute(
1312 "UPDATE relay_delivery_intents
1313 SET state = 'rejected', lease_id = '', lease_expires_at = 0,
1314 last_category = 'event_too_large'
1315 WHERE state IN ('pending', 'retryable') AND payload_bytes > ?1",
1316 [i64::try_from(crate::session::MAX_ACCOUNT_EVENT_JSON_BYTES)
1317 .map_err(|_| SoftchatError::InternalFailure)?],
1318 )
1319 .map_err(map_sqlite_error)?;
1320
1321 let candidates = load_claimable(&transaction, &relay_url)?;
1322 if candidates.is_empty() {
1323 if expired_leases > 0 || rejected_oversized > 0 {
1324 bump_revision(&transaction)?;
1325 }
1326 transaction.commit()?;
1327 return Ok(None);
1328 }
1329 let claim = DeliveryReducer::claim(candidates, lease_id)?;
1330 for mutation in &claim.mutations {
1331 let changed = transaction
1332 .execute(
1333 "UPDATE relay_delivery_intents
1334 SET state = 'claimed', lease_id = ?1, lease_expires_at = ?2,
1335 attempt_count = ?3, last_category = ?4
1336 WHERE intent_id = ?5 AND state IN ('pending', 'retryable')
1337 AND lease_id = ''",
1338 params![
1339 mutation.lease_id,
1340 expires_at,
1341 mutation.attempt_count,
1342 mutation.category,
1343 mutation.intent_id,
1344 ],
1345 )
1346 .map_err(map_sqlite_error)?;
1347 if changed != 1 {
1348 return Err(SoftchatError::InvalidDeliveryState);
1349 }
1350 }
1351 let payloads = load_claim_payloads(&transaction, &claim)?;
1352 let revision = bump_revision(&transaction)?;
1353 transaction.commit()?;
1354 Ok(Some(ClaimedDelivery {
1355 claim,
1356 payloads,
1357 revision,
1358 }))
1359 }
1360
1361 pub fn mark_socket_written(
1369 &mut self,
1370 claim: &DeliveryClaim,
1371 intent_ids: Vec<String>,
1372 ) -> Result<AccountMutationResult, SoftchatError> {
1373 let mutations = DeliveryReducer::mark_socket_written(claim, intent_ids)?;
1374 self.apply_delivery_mutations(claim, &mutations, "claimed")
1375 }
1376
1377 pub fn apply_relay_results(
1384 &mut self,
1385 claim: &DeliveryClaim,
1386 results: Vec<RelayDeliveryResult>,
1387 ) -> Result<AccountMutationResult, SoftchatError> {
1388 let mutations = DeliveryReducer::apply_relay_results(claim, results)?;
1389 self.apply_delivery_mutations(claim, &mutations, "result")
1390 }
1391
1392 pub fn release_claim(
1398 &mut self,
1399 claim: &DeliveryClaim,
1400 ) -> Result<AccountMutationResult, SoftchatError> {
1401 let mutations = DeliveryReducer::release_claim(claim)?;
1402 self.apply_delivery_mutations(claim, &mutations, "release")
1403 }
1404
1405 pub fn operation(&self, operation_id: &str) -> Result<OperationSnapshot, SoftchatError> {
1412 self.operation_optional(operation_id)?
1413 .ok_or(SoftchatError::InvalidAccountOperation)
1414 }
1415
1416 pub fn operations(&self, limit: u32) -> Result<Vec<OperationSnapshot>, SoftchatError> {
1422 validate_page(limit)?;
1423 let mut statement = self
1424 .connection
1425 .prepare(
1426 "SELECT operation_id FROM outgoing_operations
1427 ORDER BY created_at DESC, operation_id ASC LIMIT ?1",
1428 )
1429 .map_err(map_sqlite_error)?;
1430 let rows = statement
1431 .query_map([limit], |row| row.get::<_, String>(0))
1432 .map_err(map_sqlite_error)?;
1433 let mut operations = Vec::new();
1434 for row in rows {
1435 operations.push(self.operation(&row.map_err(map_sqlite_error)?)?);
1436 }
1437 Ok(operations)
1438 }
1439
1440 pub(crate) fn stored_transport_result(
1441 &self,
1442 run_id: &str,
1443 action_id: &str,
1444 ) -> Result<Option<(String, String)>, SoftchatError> {
1445 self.connection
1446 .query_row(
1447 "SELECT result_hash, outcome_json
1448 FROM processed_transport_results
1449 WHERE run_id = ?1 AND action_id = ?2",
1450 params![run_id, action_id],
1451 |row| Ok((row.get(0)?, row.get(1)?)),
1452 )
1453 .optional()
1454 .map_err(map_sqlite_error)
1455 }
1456
1457 pub(crate) fn apply_transport_result_atomically<T>(
1458 &mut self,
1459 operation: impl FnOnce(&mut Self) -> Result<T, SoftchatError>,
1460 ) -> Result<T, SoftchatError> {
1461 if !self.connection.is_autocommit() {
1462 return Err(SoftchatError::InvalidPersistenceResult);
1463 }
1464 self.connection
1465 .execute_batch("BEGIN IMMEDIATE")
1466 .map_err(map_sqlite_error)?;
1467 self.diagnostics.finish(false);
1468 let result = operation(self);
1469 let outcome = match result {
1470 Ok(value) => match self.connection.execute_batch("COMMIT") {
1471 Ok(()) => Ok(value),
1472 Err(error) => {
1473 let _ = self.connection.execute_batch("ROLLBACK");
1474 Err(map_sqlite_error(error))
1475 }
1476 },
1477 Err(error) => {
1478 self.connection
1479 .execute_batch("ROLLBACK")
1480 .map_err(map_sqlite_error)?;
1481 Err(error)
1482 }
1483 };
1484 self.diagnostics.finish(outcome.is_ok());
1485 outcome
1486 }
1487
1488 pub(crate) fn record_transport_result(
1489 &mut self,
1490 run_id: &str,
1491 action_id: &str,
1492 generation: u64,
1493 result_hash: &str,
1494 outcome_json: &str,
1495 ) -> Result<i64, SoftchatError> {
1496 let generation =
1497 i64::try_from(generation).map_err(|_| SoftchatError::InvalidRelaySession)?;
1498 if result_hash.len() != 64 || outcome_json.len() > MAX_STORED_TRANSPORT_OUTCOME_BYTES {
1499 return Err(SoftchatError::InvalidRelaySession);
1500 }
1501 let transaction = begin_write_scope(&mut self.connection)?;
1502 transaction
1503 .execute(
1504 "INSERT INTO processed_transport_results
1505 (run_id, action_id, generation, result_hash, outcome_json)
1506 VALUES (?1, ?2, ?3, ?4, ?5)",
1507 params![run_id, action_id, generation, result_hash, outcome_json],
1508 )
1509 .map_err(map_sqlite_error)?;
1510 transaction
1511 .execute(
1512 "DELETE FROM processed_transport_results
1513 WHERE rowid NOT IN (
1514 SELECT rowid FROM processed_transport_results
1515 ORDER BY rowid DESC LIMIT 4096
1516 )",
1517 [],
1518 )
1519 .map_err(map_sqlite_error)?;
1520 let revision = bump_revision(&transaction)?;
1521 transaction.commit()?;
1522 Ok(revision)
1523 }
1524
1525 pub fn retry_operation(
1535 &mut self,
1536 operation_id: &str,
1537 ) -> Result<AccountOperationResult, SoftchatError> {
1538 let current = self.operation(operation_id)?;
1539 if current.active_intents != 0 {
1540 return Err(SoftchatError::InvalidDeliveryState);
1541 }
1542 let prior_revision = self.revision()?;
1543 let transaction = self
1544 .connection
1545 .transaction_with_behavior(TransactionBehavior::Immediate)
1546 .map_err(map_sqlite_error)?;
1547 let changed = transaction
1548 .execute(
1549 "UPDATE relay_delivery_intents
1550 SET state = 'retryable', lease_id = '', lease_expires_at = 0,
1551 last_category = 'manual_retry'
1552 WHERE operation_id = ?1 AND state = 'rejected'",
1553 [operation_id],
1554 )
1555 .map_err(map_sqlite_error)?;
1556 let revision = if changed == 0 {
1557 prior_revision
1558 } else {
1559 bump_revision(&transaction)?
1560 };
1561 let intents = load_operation_intents(&transaction, operation_id)?;
1562 let operation = DeliveryReducer::operation_snapshot(intents)?;
1563 let diagnostic = (changed > 0 && self.diagnostics.enabled())
1564 .then(|| delivery_diagnostic(&transaction, &operation, "retry", true));
1565 transaction.commit().map_err(map_sqlite_error)?;
1566 if let Some(mut diagnostic) = diagnostic {
1567 diagnostic.revision = Some(revision);
1568 self.record_diagnostic(diagnostic);
1569 }
1570 Ok(AccountOperationResult {
1571 operation,
1572 revision,
1573 inserted: false,
1574 })
1575 }
1576
1577 pub fn cancel_operation(
1587 &mut self,
1588 operation_id: &str,
1589 ) -> Result<AccountOperationResult, SoftchatError> {
1590 let current = self.operation(operation_id)?;
1591 if current.active_intents != 0 {
1592 return Err(SoftchatError::InvalidDeliveryState);
1593 }
1594 let prior_revision = self.revision()?;
1595 let transaction = self
1596 .connection
1597 .transaction_with_behavior(TransactionBehavior::Immediate)
1598 .map_err(map_sqlite_error)?;
1599 let changed = transaction
1600 .execute(
1601 "UPDATE relay_delivery_intents
1602 SET state = 'cancelled', lease_id = '', lease_expires_at = 0,
1603 last_category = 'user_cancelled'
1604 WHERE operation_id = ?1 AND state IN ('pending', 'retryable')",
1605 [operation_id],
1606 )
1607 .map_err(map_sqlite_error)?;
1608 let revision = if changed == 0 {
1609 prior_revision
1610 } else {
1611 bump_revision(&transaction)?
1612 };
1613 let intents = load_operation_intents(&transaction, operation_id)?;
1614 let operation = DeliveryReducer::operation_snapshot(intents)?;
1615 let diagnostic = (changed > 0 && self.diagnostics.enabled())
1616 .then(|| delivery_diagnostic(&transaction, &operation, "cancel", false));
1617 transaction.commit().map_err(map_sqlite_error)?;
1618 if let Some(mut diagnostic) = diagnostic {
1619 diagnostic.revision = Some(revision);
1620 self.record_diagnostic(diagnostic);
1621 }
1622 Ok(AccountOperationResult {
1623 operation,
1624 revision,
1625 inserted: false,
1626 })
1627 }
1628
1629 pub fn relay_catalog(&self) -> Result<Vec<RelayCatalogEntry>, SoftchatError> {
1635 load_relay_catalog(&self.connection)
1636 }
1637
1638 pub fn reconcile_relay_catalog(
1644 &mut self,
1645 discovered: Vec<DnsRelayRecord>,
1646 refreshed_at: i64,
1647 fallback_url: String,
1648 dns_authenticated: bool,
1649 ) -> Result<StoredRelayCatalogPlan, SoftchatError> {
1650 let plan = RelayCatalogReducer::reconcile(
1651 self.relay_catalog()?,
1652 discovered,
1653 refreshed_at,
1654 fallback_url,
1655 dns_authenticated,
1656 )?;
1657 let revision = self.persist_relay_catalog(&plan.entries, refreshed_at)?;
1658 Ok(StoredRelayCatalogPlan { plan, revision })
1659 }
1660
1661 pub fn add_custom_relay(
1667 &mut self,
1668 relay_url: String,
1669 created_at: i64,
1670 ) -> Result<RelayCatalogMutation, SoftchatError> {
1671 let entries =
1672 RelayCatalogReducer::add_custom(self.relay_catalog()?, relay_url, created_at)?;
1673 let revision = self.persist_relay_catalog(&entries, created_at)?;
1674 Ok(RelayCatalogMutation { entries, revision })
1675 }
1676
1677 pub fn exclude_relay(
1683 &mut self,
1684 relay_url: String,
1685 now: i64,
1686 ) -> Result<RelayCatalogMutation, SoftchatError> {
1687 validate_timestamp(now).map_err(|_| SoftchatError::InvalidRelayCatalog)?;
1688 let entries = RelayCatalogReducer::exclude(self.relay_catalog()?, relay_url)?;
1689 let revision = self.persist_relay_catalog(&entries, now)?;
1690 Ok(RelayCatalogMutation { entries, revision })
1691 }
1692
1693 pub fn restore_automatic_relays(
1699 &mut self,
1700 now: i64,
1701 ) -> Result<RelayCatalogMutation, SoftchatError> {
1702 validate_timestamp(now).map_err(|_| SoftchatError::InvalidRelayCatalog)?;
1703 let entries = RelayCatalogReducer::restore_automatic(self.relay_catalog()?)?;
1704 let revision = self.persist_relay_catalog(&entries, now)?;
1705 Ok(RelayCatalogMutation { entries, revision })
1706 }
1707
1708 pub fn set_active_relay(
1714 &mut self,
1715 relay_url: String,
1716 now: i64,
1717 ) -> Result<RelayCatalogMutation, SoftchatError> {
1718 validate_timestamp(now).map_err(|_| SoftchatError::InvalidRelayCatalog)?;
1719 let entries = RelayCatalogReducer::set_active(self.relay_catalog()?, relay_url)?;
1720 let revision = self.persist_relay_catalog(&entries, now)?;
1721 Ok(RelayCatalogMutation { entries, revision })
1722 }
1723
1724 pub fn relay_failover_candidates(
1730 &self,
1731 failed_url: String,
1732 ) -> Result<Vec<String>, SoftchatError> {
1733 RelayCatalogReducer::failover_candidates(self.relay_catalog()?, failed_url)
1734 }
1735
1736 pub fn rotate_failed_relay(
1742 &mut self,
1743 failed_url: String,
1744 now: i64,
1745 ) -> Result<RelayFailoverMutation, SoftchatError> {
1746 validate_timestamp(now).map_err(|_| SoftchatError::InvalidRelayCatalog)?;
1747 let failed = crate::parse_relay_endpoint(&failed_url)
1748 .map_err(|_| SoftchatError::InvalidRelayCatalog)?
1749 .configured_url;
1750 let current = self.relay_catalog()?;
1751 let replacement =
1752 RelayCatalogReducer::failover_candidates(current.clone(), failed.clone())?
1753 .into_iter()
1754 .next()
1755 .ok_or(SoftchatError::InvalidRelayCatalog)?;
1756 let entries = RelayCatalogReducer::set_active(current, replacement.clone())?;
1757 let transaction = begin_write_scope(&mut self.connection)?;
1758 replace_relay_catalog(&transaction, &entries, now)?;
1759
1760 let mut statement = transaction
1761 .prepare(
1762 "SELECT intent_id, operation_id, event_copy_id
1763 FROM relay_delivery_intents
1764 WHERE relay_url = ?1
1765 AND state IN ('pending', 'retryable', 'rejected')
1766 AND lease_id = ''
1767 ORDER BY intent_id ASC",
1768 )
1769 .map_err(map_sqlite_error)?;
1770 let rows = statement
1771 .query_map([failed], |row| {
1772 Ok((
1773 row.get::<_, String>(0)?,
1774 row.get::<_, String>(1)?,
1775 row.get::<_, String>(2)?,
1776 ))
1777 })
1778 .map_err(map_sqlite_error)?
1779 .collect::<Result<Vec<_>, _>>()
1780 .map_err(map_sqlite_error)?;
1781 drop(statement);
1782 for (old_id, operation_id, event_copy_id) in &rows {
1783 let new_id = stable_intent_id(operation_id, event_copy_id, &replacement);
1784 transaction
1785 .execute(
1786 "UPDATE relay_delivery_intents
1787 SET intent_id = ?1, relay_url = ?2, state = 'retryable',
1788 lease_id = '', lease_expires_at = 0,
1789 last_category = 'relay_rotated'
1790 WHERE intent_id = ?3",
1791 params![new_id, replacement, old_id],
1792 )
1793 .map_err(map_sqlite_error)?;
1794 }
1795 let revision = bump_revision(&transaction)?;
1796 transaction.commit()?;
1797 Ok(RelayFailoverMutation {
1798 entries,
1799 active_relay_url: replacement,
1800 rebound_intent_count: u32::try_from(rows.len())
1801 .map_err(|_| SoftchatError::InvalidDeliveryState)?,
1802 revision,
1803 })
1804 }
1805
1806 pub fn list_conversations(
1813 &self,
1814 limit: u32,
1815 offset: u32,
1816 ) -> Result<Vec<ConversationSummary>, SoftchatError> {
1817 self.list_conversation_views(limit, offset).map(|views| {
1818 views
1819 .into_iter()
1820 .map(|view| view.summary)
1821 .collect::<Vec<_>>()
1822 })
1823 }
1824
1825 pub fn list_conversation_views(
1835 &self,
1836 limit: u32,
1837 offset: u32,
1838 ) -> Result<Vec<ConversationView>, SoftchatError> {
1839 validate_page(limit)?;
1840 let mut statement = self
1841 .connection
1842 .prepare(
1843 "WITH live_messages AS (
1844 SELECT p.*,
1845 ROW_NUMBER() OVER (
1846 PARTITION BY p.conversation_id
1847 ORDER BY p.created_at DESC, p.logical_event_id ASC
1848 ) AS row_number,
1849 COUNT(*) OVER (
1850 PARTITION BY p.conversation_id
1851 ) AS message_count
1852 FROM projections p
1853 WHERE p.kind = 'chat_message' AND p.ephemeral = 0
1854 AND NOT EXISTS (
1855 SELECT 1 FROM projection_targets dt
1856 JOIN projections d
1857 ON d.logical_event_id = dt.logical_event_id
1858 WHERE dt.target_event_id = p.logical_event_id
1859 AND d.kind = 'deletion'
1860 AND d.author_public_key = p.author_public_key
1861 )
1862 ),
1863 page AS (
1864 SELECT * FROM live_messages
1865 WHERE row_number = 1
1866 ORDER BY created_at DESC, conversation_id ASC
1867 LIMIT ?1 OFFSET ?2
1868 ),
1869 latest_subjects AS (
1870 SELECT subject.*,
1871 ROW_NUMBER() OVER (
1872 PARTITION BY subject.conversation_id
1873 ORDER BY subject.created_at DESC,
1874 subject.logical_event_id ASC
1875 ) AS row_number
1876 FROM projections subject
1877 JOIN page
1878 ON page.conversation_id = subject.conversation_id
1879 WHERE subject.kind = 'subject' AND subject.ephemeral = 0
1880 )
1881 SELECT
1882 p.conversation_id,
1883 p.message_count,
1884 p.logical_event_id,
1885 p.author_public_key,
1886 p.created_at,
1887 p.content,
1888 se.canonical_json,
1889 ar.canonical_json,
1890 (
1891 SELECT group_concat(public_key, ',')
1892 FROM (
1893 SELECT public_key
1894 FROM projection_participants
1895 WHERE logical_event_id = p.logical_event_id
1896 ORDER BY public_key ASC
1897 )
1898 ),
1899 (
1900 SELECT group_concat(target_event_id, ',')
1901 FROM (
1902 SELECT target_event_id
1903 FROM projection_targets
1904 WHERE logical_event_id = p.logical_event_id
1905 ORDER BY position ASC
1906 )
1907 ),
1908 (
1909 SELECT effective.content
1910 FROM effective_message_edits effective
1911 WHERE effective.message_id = p.logical_event_id
1912 ),
1913 (
1914 SELECT COUNT(*)
1915 FROM projection_targets rt
1916 JOIN projections reply
1917 ON reply.logical_event_id = rt.logical_event_id
1918 WHERE rt.target_event_id = p.logical_event_id
1919 AND reply.kind = 'chat_message'
1920 AND NOT EXISTS (
1921 SELECT 1 FROM projection_targets dt
1922 JOIN projections deletion
1923 ON deletion.logical_event_id = dt.logical_event_id
1924 WHERE dt.target_event_id = reply.logical_event_id
1925 AND deletion.kind = 'deletion'
1926 AND deletion.author_public_key = reply.author_public_key
1927 )
1928 ),
1929 subject.kind,
1930 subject.logical_event_id,
1931 subject.author_public_key,
1932 subject.created_at,
1933 subject.conversation_id,
1934 subject.content,
1935 subject_event.canonical_json,
1936 subject_rumor.canonical_json,
1937 (
1938 SELECT group_concat(
1939 spp.public_key,
1940 ',' ORDER BY spp.public_key ASC
1941 )
1942 FROM projection_participants spp
1943 WHERE spp.logical_event_id = subject.logical_event_id
1944 ),
1945 (
1946 SELECT group_concat(
1947 spt.target_event_id,
1948 ',' ORDER BY spt.position ASC
1949 )
1950 FROM projection_targets spt
1951 WHERE spt.logical_event_id = subject.logical_event_id
1952 )
1953 FROM page p
1954 JOIN signed_events se ON se.event_id = p.outer_event_id
1955 LEFT JOIN authenticated_rumors ar
1956 ON ar.rumor_id = p.logical_event_id
1957 LEFT JOIN latest_subjects subject
1958 ON subject.conversation_id = p.conversation_id
1959 AND subject.row_number = 1
1960 LEFT JOIN signed_events subject_event
1961 ON subject_event.event_id = subject.outer_event_id
1962 LEFT JOIN authenticated_rumors subject_rumor
1963 ON subject_rumor.rumor_id = subject.logical_event_id
1964 ORDER BY p.created_at DESC, p.conversation_id ASC",
1965 )
1966 .map_err(map_sqlite_error)?;
1967 let rows = statement
1968 .query_map(params![limit, offset], |row| {
1969 let subject_id = row.get::<_, Option<String>>(13)?;
1970 let subject = if let Some(logical_event_id) = subject_id {
1971 Some(RawProjection {
1972 stored_kind: row.get(12)?,
1973 logical_event_id,
1974 author_public_key: row.get(14)?,
1975 created_at: row.get(15)?,
1976 conversation_id: row.get(16)?,
1977 content: row.get(17)?,
1978 outer_json: row.get(18)?,
1979 rumor_json: row.get(19)?,
1980 participants_csv: row.get(20)?,
1981 targets_csv: row.get(21)?,
1982 })
1983 } else {
1984 None
1985 };
1986 Ok(RawConversationView {
1987 conversation_id: row.get(0)?,
1988 message_count: row.get(1)?,
1989 logical_event_id: row.get(2)?,
1990 author_public_key: row.get(3)?,
1991 created_at: row.get(4)?,
1992 content: row.get(5)?,
1993 outer_json: row.get(6)?,
1994 rumor_json: row.get(7)?,
1995 participants_csv: row.get(8)?,
1996 targets_csv: row.get(9)?,
1997 edited_content: row.get(10)?,
1998 reply_count: row.get(11)?,
1999 subject,
2000 })
2001 })
2002 .map_err(map_sqlite_error)?;
2003 let mut views = Vec::new();
2004 for row in rows {
2005 let row = row.map_err(map_sqlite_error)?;
2006 let participants = parse_hex_list(row.participants_csv, true)?;
2007 let target_event_ids = parse_hex_list(row.targets_csv, false)?;
2008 let outer = SignedNostrEvent::from_json(&row.outer_json)?;
2009 let rumor = row
2010 .rumor_json
2011 .map(|json| {
2012 serde_json::from_str::<RumorEvent>(&json)
2013 .map_err(|_| SoftchatError::InvalidPersistenceResult)
2014 })
2015 .transpose()?;
2016 let projection = AccountProjection {
2017 kind: ProjectionKind::ChatMessage,
2018 logical_event_id: row.logical_event_id,
2019 outer_event: SignedEvent::from(&outer),
2020 rumor,
2021 author_public_key: row.author_public_key,
2022 created_at: row.created_at,
2023 conversation_id: row.conversation_id.clone(),
2024 participants: participants.clone(),
2025 target_event_ids,
2026 content: row.content.clone(),
2027 };
2028 let edited = row.edited_content.is_some();
2029 let effective_content = row.edited_content.unwrap_or(row.content);
2030 let summary = ConversationSummary {
2031 conversation_id: row.conversation_id,
2032 participants,
2033 last_message: effective_content.clone(),
2034 last_created_at: row.created_at,
2035 message_count: row.message_count,
2036 };
2037 let subject = row.subject.map(projection_from_raw).transpose()?;
2038 views.push(ConversationView {
2039 summary,
2040 last_message: AccountEventNode {
2041 projection,
2042 effective_content,
2043 deleted: false,
2044 edited,
2045 reply_count: row.reply_count,
2046 },
2047 subject,
2048 });
2049 }
2050 Ok(views)
2051 }
2052
2053 pub fn list_messages(
2063 &self,
2064 conversation_id: &str,
2065 before_created_at: Option<i64>,
2066 limit: u32,
2067 ) -> Result<Vec<AccountMessage>, SoftchatError> {
2068 if conversation_id.len() != 64
2069 || !conversation_id.bytes().all(|byte| byte.is_ascii_hexdigit())
2070 {
2071 return Err(SoftchatError::InvalidAccountOperation);
2072 }
2073 validate_page(limit)?;
2074 if let Some(timestamp) = before_created_at {
2075 validate_timestamp(timestamp)?;
2076 }
2077 let mut statement = self
2078 .connection
2079 .prepare(
2080 "SELECT
2081 p.logical_event_id,
2082 p.author_public_key,
2083 p.created_at,
2084 p.content,
2085 EXISTS(
2086 SELECT 1 FROM projection_targets dt
2087 JOIN projections deletion
2088 ON deletion.logical_event_id = dt.logical_event_id
2089 WHERE dt.target_event_id = p.logical_event_id
2090 AND deletion.kind = 'deletion'
2091 AND deletion.author_public_key = p.author_public_key
2092 ),
2093 (
2094 SELECT effective.content
2095 FROM effective_message_edits effective
2096 WHERE effective.message_id = p.logical_event_id
2097 ),
2098 COALESCE(
2099 (
2100 SELECT target_event_id
2101 FROM projection_targets
2102 WHERE logical_event_id = p.logical_event_id
2103 ORDER BY position ASC
2104 LIMIT 1
2105 ),
2106 ''
2107 )
2108 FROM projections p
2109 WHERE p.kind = 'chat_message' AND p.ephemeral = 0
2110 AND p.conversation_id = ?1
2111 AND (?2 IS NULL OR p.created_at < ?2)
2112 ORDER BY p.created_at DESC, p.logical_event_id ASC
2113 LIMIT ?3",
2114 )
2115 .map_err(map_sqlite_error)?;
2116 let rows = statement
2117 .query_map(params![conversation_id, before_created_at, limit], |row| {
2118 Ok((
2119 row.get::<_, String>(0)?,
2120 row.get::<_, String>(1)?,
2121 row.get::<_, i64>(2)?,
2122 row.get::<_, String>(3)?,
2123 row.get::<_, i64>(4)? != 0,
2124 row.get::<_, Option<String>>(5)?,
2125 row.get::<_, String>(6)?,
2126 ))
2127 })
2128 .map_err(map_sqlite_error)?;
2129 let mut messages = Vec::new();
2130 for row in rows {
2131 let (
2132 event_id,
2133 author_public_key,
2134 created_at,
2135 original_content,
2136 deleted,
2137 edited_content,
2138 reply_to_event_id,
2139 ) = row.map_err(map_sqlite_error)?;
2140 messages.push(AccountMessage {
2141 event_id,
2142 conversation_id: conversation_id.to_owned(),
2143 author_public_key,
2144 created_at,
2145 content: edited_content.clone().unwrap_or(original_content),
2146 deleted,
2147 edited: edited_content.is_some(),
2148 reply_to_event_id,
2149 });
2150 }
2151 Ok(messages)
2152 }
2153
2154 #[allow(clippy::too_many_arguments)]
2166 pub fn list_projections(
2167 &self,
2168 kind: Option<ProjectionKind>,
2169 logical_event_id: String,
2170 conversation_id: String,
2171 target_event_id: String,
2172 author_public_key: String,
2173 before_created_at: Option<i64>,
2174 limit: u32,
2175 offset: u32,
2176 ) -> Result<Vec<AccountProjection>, SoftchatError> {
2177 self.query_event_nodes(
2178 kind,
2179 logical_event_id,
2180 conversation_id,
2181 target_event_id,
2182 author_public_key,
2183 before_created_at,
2184 limit,
2185 offset,
2186 )
2187 .map(|nodes| {
2188 nodes
2189 .into_iter()
2190 .map(|node| node.projection)
2191 .collect::<Vec<_>>()
2192 })
2193 }
2194
2195 #[allow(clippy::too_many_arguments)]
2206 pub fn list_event_nodes(
2207 &self,
2208 kind: Option<ProjectionKind>,
2209 logical_event_id: String,
2210 conversation_id: String,
2211 target_event_id: String,
2212 author_public_key: String,
2213 before_created_at: Option<i64>,
2214 limit: u32,
2215 offset: u32,
2216 ) -> Result<Vec<AccountEventNode>, SoftchatError> {
2217 self.query_event_nodes(
2218 kind,
2219 logical_event_id,
2220 conversation_id,
2221 target_event_id,
2222 author_public_key,
2223 before_created_at,
2224 limit,
2225 offset,
2226 )
2227 }
2228
2229 #[allow(clippy::too_many_arguments)]
2230 fn query_event_nodes(
2231 &self,
2232 kind: Option<ProjectionKind>,
2233 logical_event_id: String,
2234 conversation_id: String,
2235 target_event_id: String,
2236 author_public_key: String,
2237 before_created_at: Option<i64>,
2238 limit: u32,
2239 offset: u32,
2240 ) -> Result<Vec<AccountEventNode>, SoftchatError> {
2241 validate_page(limit)?;
2242 if !logical_event_id.is_empty() {
2243 crate::NostrEventId::from_hex(&logical_event_id)?;
2244 }
2245 if !conversation_id.is_empty()
2246 && (conversation_id.len() != 64
2247 || !conversation_id.bytes().all(|byte| byte.is_ascii_hexdigit()))
2248 {
2249 return Err(SoftchatError::InvalidAccountOperation);
2250 }
2251 if !target_event_id.is_empty() {
2252 crate::NostrEventId::from_hex(&target_event_id)?;
2253 }
2254 if !author_public_key.is_empty() {
2255 crate::NostrPublicKey::from_hex(&author_public_key)?;
2256 }
2257 if let Some(value) = before_created_at {
2258 validate_timestamp(value)?;
2259 }
2260 let kind_name = kind.map(projection_kind_name);
2261 let mut statement = self
2262 .connection
2263 .prepare(
2264 "SELECT
2265 p.kind,
2266 p.logical_event_id,
2267 p.author_public_key,
2268 p.created_at,
2269 p.conversation_id,
2270 p.content,
2271 se.canonical_json,
2272 ar.canonical_json,
2273 (
2274 SELECT group_concat(
2275 pp.public_key,
2276 ',' ORDER BY pp.public_key ASC
2277 )
2278 FROM projection_participants pp
2279 WHERE pp.logical_event_id = p.logical_event_id
2280 ),
2281 (
2282 SELECT group_concat(
2283 pt.target_event_id,
2284 ',' ORDER BY pt.position ASC
2285 )
2286 FROM projection_targets pt
2287 WHERE pt.logical_event_id = p.logical_event_id
2288 ),
2289 EXISTS(
2290 SELECT 1 FROM projection_targets dt
2291 JOIN projections deletion
2292 ON deletion.logical_event_id = dt.logical_event_id
2293 WHERE dt.target_event_id = p.logical_event_id
2294 AND deletion.kind = 'deletion'
2295 AND deletion.author_public_key = p.author_public_key
2296 ),
2297 (
2298 SELECT effective.content
2299 FROM effective_message_edits effective
2300 WHERE effective.message_id = p.logical_event_id
2301 ),
2302 (
2303 SELECT COUNT(*)
2304 FROM projection_targets rt
2305 JOIN projections reply
2306 ON reply.logical_event_id = rt.logical_event_id
2307 WHERE rt.target_event_id = p.logical_event_id
2308 AND reply.kind = 'chat_message'
2309 AND NOT EXISTS (
2310 SELECT 1 FROM projection_targets dt
2311 JOIN projections deletion
2312 ON deletion.logical_event_id = dt.logical_event_id
2313 WHERE dt.target_event_id = reply.logical_event_id
2314 AND deletion.kind = 'deletion'
2315 AND deletion.author_public_key = reply.author_public_key
2316 )
2317 )
2318 FROM projections p
2319 JOIN signed_events se ON se.event_id = p.outer_event_id
2320 LEFT JOIN authenticated_rumors ar
2321 ON ar.rumor_id = p.logical_event_id
2322 WHERE (?1 IS NULL OR p.kind = ?1)
2323 AND (?2 = '' OR p.logical_event_id = ?2)
2324 AND (?3 = '' OR p.conversation_id = ?3)
2325 AND (?4 = '' OR EXISTS (
2326 SELECT 1 FROM projection_targets pt
2327 WHERE pt.logical_event_id = p.logical_event_id
2328 AND pt.target_event_id = ?4
2329 ))
2330 AND (?5 = '' OR p.author_public_key = ?5)
2331 AND (?6 IS NULL OR p.created_at <= ?6)
2332 ORDER BY p.created_at DESC, p.logical_event_id ASC
2333 LIMIT ?7 OFFSET ?8",
2334 )
2335 .map_err(map_sqlite_error)?;
2336 let rows = statement
2337 .query_map(
2338 params![
2339 kind_name,
2340 logical_event_id,
2341 conversation_id,
2342 target_event_id,
2343 author_public_key,
2344 before_created_at,
2345 limit,
2346 offset,
2347 ],
2348 |row| {
2349 Ok(RawEventNode {
2350 stored_kind: row.get(0)?,
2351 logical_event_id: row.get(1)?,
2352 author_public_key: row.get(2)?,
2353 created_at: row.get(3)?,
2354 conversation_id: row.get(4)?,
2355 content: row.get(5)?,
2356 outer_json: row.get(6)?,
2357 rumor_json: row.get(7)?,
2358 participants_csv: row.get(8)?,
2359 targets_csv: row.get(9)?,
2360 deleted: row.get::<_, i64>(10)? != 0,
2361 edited_content: row.get(11)?,
2362 reply_count: row.get(12)?,
2363 })
2364 },
2365 )
2366 .map_err(map_sqlite_error)?;
2367 let mut nodes = Vec::new();
2368 for row in rows {
2369 nodes.push(event_node_from_raw(row.map_err(map_sqlite_error)?)?);
2370 }
2371 Ok(nodes)
2372 }
2373
2374 pub fn list_known_public_keys(
2381 &self,
2382 limit: u32,
2383 offset: u32,
2384 ) -> Result<Vec<String>, SoftchatError> {
2385 validate_page(limit)?;
2386 let mut statement = self
2387 .connection
2388 .prepare(
2389 "SELECT public_key FROM (
2390 SELECT author_public_key AS public_key FROM projections
2391 UNION
2392 SELECT public_key FROM projection_participants
2393 UNION
2394 SELECT public_key FROM local_contacts
2395 )
2396 ORDER BY public_key ASC
2397 LIMIT ?1 OFFSET ?2",
2398 )
2399 .map_err(map_sqlite_error)?;
2400 let rows = statement
2401 .query_map(params![limit, offset], |row| row.get::<_, String>(0))
2402 .map_err(map_sqlite_error)?;
2403 let mut public_keys = Vec::new();
2404 for row in rows {
2405 let public_key = row.map_err(map_sqlite_error)?;
2406 crate::NostrPublicKey::from_hex(&public_key)
2407 .map_err(|_| SoftchatError::InvalidPersistenceResult)?;
2408 public_keys.push(public_key);
2409 }
2410 Ok(public_keys)
2411 }
2412
2413 pub fn put_local_contact(
2422 &mut self,
2423 contact: LocalContactRecord,
2424 ) -> Result<AccountMutationResult, SoftchatError> {
2425 validate_local_contact(&contact)?;
2426 let diagnostic_changed = self.local_state_differs(
2427 "SELECT NOT EXISTS(SELECT 1 FROM local_contacts
2428 WHERE public_key = ?1 AND name IS ?2 AND updated_at_millis = ?3)",
2429 params![contact.public_key, contact.name, contact.updated_at_millis],
2430 );
2431 let transaction = self
2432 .connection
2433 .transaction_with_behavior(TransactionBehavior::Immediate)
2434 .map_err(map_sqlite_error)?;
2435 transaction
2436 .execute(
2437 "INSERT INTO local_contacts(public_key, name, updated_at_millis)
2438 VALUES (?1, ?2, ?3)
2439 ON CONFLICT(public_key) DO UPDATE SET
2440 name = excluded.name,
2441 updated_at_millis = excluded.updated_at_millis",
2442 params![contact.public_key, contact.name, contact.updated_at_millis],
2443 )
2444 .map_err(map_sqlite_error)?;
2445 let revision = bump_revision(&transaction)?;
2446 transaction.commit().map_err(map_sqlite_error)?;
2447 self.record_local_change(diagnostic_changed, revision);
2448 Ok(AccountMutationResult { revision })
2449 }
2450
2451 pub fn remove_local_contact(
2458 &mut self,
2459 public_key: String,
2460 ) -> Result<AccountMutationResult, SoftchatError> {
2461 crate::NostrPublicKey::from_hex(&public_key)?;
2462 let transaction = self
2463 .connection
2464 .transaction_with_behavior(TransactionBehavior::Immediate)
2465 .map_err(map_sqlite_error)?;
2466 let changed = transaction
2467 .execute(
2468 "DELETE FROM local_contacts WHERE public_key = ?1",
2469 [public_key],
2470 )
2471 .map_err(map_sqlite_error)?;
2472 let revision = if changed == 0 {
2473 meta_integer(&transaction, "revision")?
2474 } else {
2475 bump_revision(&transaction)?
2476 };
2477 transaction.commit().map_err(map_sqlite_error)?;
2478 self.record_local_change(changed > 0, revision);
2479 Ok(AccountMutationResult { revision })
2480 }
2481
2482 pub fn local_contacts(
2489 &self,
2490 limit: u32,
2491 offset: u32,
2492 ) -> Result<Vec<LocalContactRecord>, SoftchatError> {
2493 validate_page(limit)?;
2494 let mut statement = self
2495 .connection
2496 .prepare(
2497 "SELECT public_key, name, updated_at_millis
2498 FROM local_contacts
2499 ORDER BY public_key ASC
2500 LIMIT ?1 OFFSET ?2",
2501 )
2502 .map_err(map_sqlite_error)?;
2503 let rows = statement
2504 .query_map(params![limit, offset], |row| {
2505 Ok(LocalContactRecord {
2506 public_key: row.get(0)?,
2507 name: row.get(1)?,
2508 updated_at_millis: row.get(2)?,
2509 })
2510 })
2511 .map_err(map_sqlite_error)?;
2512 let mut contacts = Vec::new();
2513 for row in rows {
2514 let contact = row.map_err(map_sqlite_error)?;
2515 validate_local_contact(&contact)
2516 .map_err(|_| SoftchatError::InvalidPersistenceResult)?;
2517 contacts.push(contact);
2518 }
2519 Ok(contacts)
2520 }
2521
2522 pub fn replace_stickers(
2533 &mut self,
2534 stickers: Vec<StickerRecord>,
2535 ) -> Result<AccountMutationResult, SoftchatError> {
2536 if stickers.len() > MAX_QUERY_PAGE as usize {
2537 return Err(SoftchatError::InvalidAccountOperation);
2538 }
2539 let mut names = BTreeSet::new();
2540 for sticker in &stickers {
2541 validate_sticker(sticker)?;
2542 if !names.insert(sticker.name.as_str()) {
2543 return Err(SoftchatError::InvalidAccountOperation);
2544 }
2545 }
2546 let diagnostic_changed = self.diagnostics.debug_enabled()
2547 && self.stickers().is_ok_and(|stored| {
2548 stored.len() != stickers.len() || stored.iter().any(|item| !stickers.contains(item))
2549 });
2550 let transaction = self
2551 .connection
2552 .transaction_with_behavior(TransactionBehavior::Immediate)
2553 .map_err(map_sqlite_error)?;
2554 transaction
2555 .execute("DELETE FROM sticker_cache", [])
2556 .map_err(map_sqlite_error)?;
2557 for sticker in stickers {
2558 let emojis = serde_json::to_string(&sticker.associated_emojis)
2559 .map_err(|_| SoftchatError::InternalFailure)?;
2560 transaction
2561 .execute(
2562 "INSERT INTO sticker_cache(name, url, associated_emojis_json)
2563 VALUES (?1, ?2, ?3)",
2564 params![sticker.name, sticker.url, emojis],
2565 )
2566 .map_err(map_sqlite_error)?;
2567 }
2568 let revision = bump_revision(&transaction)?;
2569 transaction.commit().map_err(map_sqlite_error)?;
2570 self.record_local_change(diagnostic_changed, revision);
2571 Ok(AccountMutationResult { revision })
2572 }
2573
2574 pub fn stickers(&self) -> Result<Vec<StickerRecord>, SoftchatError> {
2580 let mut statement = self
2581 .connection
2582 .prepare(
2583 "SELECT name, url, associated_emojis_json
2584 FROM sticker_cache ORDER BY name ASC LIMIT 200",
2585 )
2586 .map_err(map_sqlite_error)?;
2587 let rows = statement
2588 .query_map([], |row| {
2589 Ok((
2590 row.get::<_, String>(0)?,
2591 row.get::<_, String>(1)?,
2592 row.get::<_, String>(2)?,
2593 ))
2594 })
2595 .map_err(map_sqlite_error)?;
2596 let mut stickers = Vec::new();
2597 for row in rows {
2598 let (name, url, emojis_json) = row.map_err(map_sqlite_error)?;
2599 let associated_emojis = serde_json::from_str::<Vec<String>>(&emojis_json)
2600 .map_err(|_| SoftchatError::InvalidPersistenceResult)?;
2601 let sticker = StickerRecord {
2602 name,
2603 url,
2604 associated_emojis,
2605 };
2606 validate_sticker(&sticker).map_err(|_| SoftchatError::InvalidPersistenceResult)?;
2607 stickers.push(sticker);
2608 }
2609 Ok(stickers)
2610 }
2611
2612 pub fn put_link_preview(
2619 &mut self,
2620 preview: LinkPreviewRecord,
2621 ) -> Result<AccountMutationResult, SoftchatError> {
2622 validate_link_preview(&preview)?;
2623 let diagnostic_changed = self.local_state_differs(
2624 "SELECT NOT EXISTS(SELECT 1 FROM link_preview_cache
2625 WHERE url = ?1 AND title IS ?2 AND description IS ?3
2626 AND image_url IS ?4 AND updated_at_millis = ?5)",
2627 params![
2628 preview.url,
2629 preview.title,
2630 preview.description,
2631 preview.image_url,
2632 preview.updated_at_millis
2633 ],
2634 );
2635 let transaction = self
2636 .connection
2637 .transaction_with_behavior(TransactionBehavior::Immediate)
2638 .map_err(map_sqlite_error)?;
2639 transaction
2640 .execute(
2641 "INSERT INTO link_preview_cache
2642 (url, title, description, image_url, updated_at_millis)
2643 VALUES (?1, ?2, ?3, ?4, ?5)
2644 ON CONFLICT(url) DO UPDATE SET
2645 title = excluded.title,
2646 description = excluded.description,
2647 image_url = excluded.image_url,
2648 updated_at_millis = excluded.updated_at_millis",
2649 params![
2650 preview.url,
2651 preview.title,
2652 preview.description,
2653 preview.image_url,
2654 preview.updated_at_millis,
2655 ],
2656 )
2657 .map_err(map_sqlite_error)?;
2658 let revision = bump_revision(&transaction)?;
2659 transaction.commit().map_err(map_sqlite_error)?;
2660 self.record_local_change(diagnostic_changed, revision);
2661 Ok(AccountMutationResult { revision })
2662 }
2663
2664 pub fn link_preview(&self, url: String) -> Result<Option<LinkPreviewRecord>, SoftchatError> {
2671 validate_http_url(&url, false)?;
2672 let preview = self
2673 .connection
2674 .query_row(
2675 "SELECT url, title, description, image_url, updated_at_millis
2676 FROM link_preview_cache WHERE url = ?1",
2677 [url],
2678 |row| {
2679 Ok(LinkPreviewRecord {
2680 url: row.get(0)?,
2681 title: row.get(1)?,
2682 description: row.get(2)?,
2683 image_url: row.get(3)?,
2684 updated_at_millis: row.get(4)?,
2685 })
2686 },
2687 )
2688 .optional()
2689 .map_err(map_sqlite_error)?;
2690 if let Some(value) = &preview {
2691 validate_link_preview(value).map_err(|_| SoftchatError::InvalidPersistenceResult)?;
2692 }
2693 Ok(preview)
2694 }
2695
2696 pub fn clear(&mut self) -> Result<AccountMutationResult, SoftchatError> {
2706 let transaction = begin_write_scope(&mut self.connection)?;
2707 for table in ACCOUNT_CLEAR_TABLES {
2708 transaction
2709 .execute(&format!("DELETE FROM {table}"), [])
2710 .map_err(map_sqlite_error)?;
2711 }
2712 transaction
2713 .execute("DELETE FROM relay_catalog", [])
2714 .map_err(map_sqlite_error)?;
2715 transaction
2716 .execute(
2717 "INSERT INTO relay_catalog
2718 (relay_url, created_at, source, active, is_dns_discovered,
2719 excluded, priority, weight, expires_at, updated_at)
2720 VALUES (?1, 0, 'automatic', 1, 1, 0, 0, 0, 3600, 0)",
2721 [DEFAULT_RELAY_URL],
2722 )
2723 .map_err(map_sqlite_error)?;
2724 transaction
2725 .execute("DELETE FROM account_settings", [])
2726 .map_err(map_sqlite_error)?;
2727 transaction
2728 .execute(
2729 "INSERT INTO account_settings
2730 (singleton, schema_version, canonical_json, updated_at)
2731 VALUES (1, 1, '{}', 0)",
2732 [],
2733 )
2734 .map_err(map_sqlite_error)?;
2735 let revision = bump_revision(&transaction)?;
2736 transaction.commit()?;
2737 Ok(AccountMutationResult { revision })
2738 }
2739
2740 pub fn event_json(&self, event_id: &str) -> Result<String, SoftchatError> {
2747 crate::NostrEventId::from_hex(event_id)?;
2748 self.connection
2749 .query_row(
2750 "SELECT canonical_json FROM signed_events WHERE event_id = ?1",
2751 [event_id],
2752 |row| row.get(0),
2753 )
2754 .optional()
2755 .map_err(map_sqlite_error)?
2756 .ok_or(SoftchatError::InvalidEventId)
2757 }
2758
2759 pub fn event_jsons(
2769 &self,
2770 event_ids: Vec<String>,
2771 ) -> Result<Vec<StoredEventJson>, SoftchatError> {
2772 if event_ids.is_empty() || event_ids.len() > crate::SYNC_EVENT_REQUEST_CHUNK {
2773 return Err(SoftchatError::InvalidAccountOperation);
2774 }
2775 let mut seen = BTreeSet::new();
2776 let mut events = Vec::with_capacity(event_ids.len());
2777 for event_id in event_ids {
2778 crate::NostrEventId::from_hex(&event_id)?;
2779 if !seen.insert(event_id.clone()) {
2780 return Err(SoftchatError::InvalidAccountOperation);
2781 }
2782 events.push(StoredEventJson {
2783 event_json: self.event_json(&event_id)?,
2784 event_id,
2785 });
2786 }
2787 Ok(events)
2788 }
2789
2790 pub fn sync_fingerprints(
2797 &self,
2798 since_timestamp: Option<i64>,
2799 until_timestamp: i64,
2800 limit: u32,
2801 ) -> Result<Vec<NegentropyItem>, SoftchatError> {
2802 validate_timestamp(until_timestamp).map_err(|_| SoftchatError::InvalidSyncState)?;
2803 if limit == 0
2804 || limit > crate::SYNC_MAX_PAGE_LIMIT
2805 || since_timestamp.is_some_and(|since| since < 0 || since > until_timestamp)
2806 {
2807 return Err(SoftchatError::InvalidSyncState);
2808 }
2809 let mut statement = self
2810 .connection
2811 .prepare(
2812 "SELECT fingerprints.event_id, fingerprints.created_at
2813 FROM event_fingerprints AS fingerprints
2814 JOIN signed_events AS events ON events.event_id = fingerprints.event_id
2815 WHERE (?1 IS NULL OR fingerprints.created_at >= ?1)
2816 AND fingerprints.created_at <= ?2
2817 AND length(CAST(events.canonical_json AS BLOB)) <= ?4
2818 ORDER BY fingerprints.created_at DESC, fingerprints.event_id ASC
2819 LIMIT ?3",
2820 )
2821 .map_err(map_sqlite_error)?;
2822 let rows = statement
2823 .query_map(
2824 params![
2825 since_timestamp,
2826 until_timestamp,
2827 limit,
2828 i64::try_from(crate::session::MAX_ACCOUNT_EVENT_JSON_BYTES)
2829 .map_err(|_| SoftchatError::InternalFailure)?
2830 ],
2831 |row| {
2832 Ok(NegentropyItem {
2833 event_id: row.get(0)?,
2834 created_at: row.get(1)?,
2835 })
2836 },
2837 )
2838 .map_err(map_sqlite_error)?;
2839 let mut items = Vec::new();
2840 for row in rows {
2841 items.push(row.map_err(map_sqlite_error)?);
2842 }
2843 Ok(items)
2844 }
2845
2846 pub fn sync_checkpoint(&self, sync_id: &str) -> Result<Option<i64>, SoftchatError> {
2853 validate_sync_id(sync_id)?;
2854 self.connection
2855 .query_row(
2856 "SELECT checkpoint_timestamp FROM sync_checkpoints WHERE sync_id = ?1",
2857 [sync_id],
2858 |row| row.get(0),
2859 )
2860 .optional()
2861 .map_err(map_sqlite_error)
2862 }
2863
2864 pub fn save_sync_checkpoint(
2871 &mut self,
2872 sync_id: &str,
2873 checkpoint_timestamp: i64,
2874 now: i64,
2875 ) -> Result<AccountMutationResult, SoftchatError> {
2876 validate_sync_id(sync_id)?;
2877 validate_timestamp(checkpoint_timestamp).map_err(|_| SoftchatError::InvalidSyncState)?;
2878 validate_timestamp(now).map_err(|_| SoftchatError::InvalidSyncState)?;
2879 let transaction = begin_write_scope(&mut self.connection)?;
2880 transaction
2881 .execute(
2882 "INSERT INTO sync_checkpoints(sync_id, checkpoint_timestamp, updated_at)
2883 VALUES (?1, ?2, ?3)
2884 ON CONFLICT(sync_id) DO UPDATE SET
2885 checkpoint_timestamp = excluded.checkpoint_timestamp,
2886 updated_at = excluded.updated_at",
2887 params![sync_id, checkpoint_timestamp, now],
2888 )
2889 .map_err(map_sqlite_error)?;
2890 let revision = bump_revision(&transaction)?;
2891 transaction.commit()?;
2892 Ok(AccountMutationResult { revision })
2893 }
2894
2895 pub fn reset_sync(&mut self, now: i64) -> Result<AccountMutationResult, SoftchatError> {
2905 validate_timestamp(now).map_err(|_| SoftchatError::InvalidSyncState)?;
2906 let transaction = self
2907 .connection
2908 .transaction_with_behavior(TransactionBehavior::Immediate)
2909 .map_err(map_sqlite_error)?;
2910 transaction
2911 .execute("DELETE FROM sync_checkpoints", [])
2912 .map_err(map_sqlite_error)?;
2913 let revision = bump_revision(&transaction)?;
2914 transaction.commit().map_err(map_sqlite_error)?;
2915 Ok(AccountMutationResult { revision })
2916 }
2917
2918 fn persist_operation(
2919 &mut self,
2920 identity: &LocalIdentity,
2921 prepared: PreparedOutgoingOperation,
2922 sender_public_key: &str,
2923 now: i64,
2924 command_hash: &str,
2925 ) -> Result<AccountOperationResult, SoftchatError> {
2926 validate_timestamp(now)?;
2927 let mut queued = self
2928 .diagnostics
2929 .enabled()
2930 .then(|| Diagnostic::for_subject(AccountLogSubject::Unknown, true));
2931 let transaction = self
2932 .connection
2933 .transaction_with_behavior(TransactionBehavior::Immediate)
2934 .map_err(map_sqlite_error)?;
2935 persist_prepared_operation(&transaction, &prepared, now, 0, command_hash, "")?;
2936 if !sender_public_key.is_empty() {
2937 let sender_copy = prepared
2938 .event_copies
2939 .iter()
2940 .find(|copy| copy.recipient_public_key == sender_public_key)
2941 .ok_or(SoftchatError::InvalidAccountOperation)?;
2942 let local_batch = AccountEngine::prepare_incoming(
2943 identity,
2944 self.account_id.clone(),
2945 vec![sender_copy.event.clone()],
2946 )?;
2947 if queued.is_some()
2948 && let Some(event) = local_batch.events.first()
2949 {
2950 queued = Some(Diagnostic::queued_from(event));
2951 }
2952 let committed = ingest_prepared(&transaction, &local_batch, now, false)?;
2953 AccountEngine::validate_ingestion_receipt(&local_batch, &committed.receipt)?;
2954 }
2955 let intents = load_operation_intents(&transaction, &prepared.operation_id)?;
2956 let operation = DeliveryReducer::operation_snapshot(intents)?;
2957 let revision = bump_revision(&transaction)?;
2958 transaction.commit().map_err(map_sqlite_error)?;
2959 if let Some(mut queued) = queued {
2960 queued.revision = Some(revision);
2961 self.record_diagnostic(
2962 queued.count(Counter::TotalIntents, u64::from(operation.total_intents)),
2963 );
2964 }
2965 Ok(AccountOperationResult {
2966 operation,
2967 revision,
2968 inserted: true,
2969 })
2970 }
2971
2972 #[allow(clippy::too_many_arguments)]
2973 fn persist_product_operation(
2974 &mut self,
2975 identity: &LocalIdentity,
2976 prepared: PreparedOutgoingOperation,
2977 sender_public_key: &str,
2978 now: i64,
2979 command_hash: &str,
2980 result_message_id: &str,
2981 draft_to_consume: Option<(&str, &crate::account_product::ProductDraftMatch)>,
2982 ) -> Result<ProductAccountOperationResult, SoftchatError> {
2983 validate_timestamp(now)?;
2984 let mut queued = self
2985 .diagnostics
2986 .enabled()
2987 .then(|| Diagnostic::for_subject(AccountLogSubject::Unknown, true));
2988 let transaction = self
2989 .connection
2990 .transaction_with_behavior(TransactionBehavior::Immediate)
2991 .map_err(map_sqlite_error)?;
2992 persist_prepared_operation(
2993 &transaction,
2994 &prepared,
2995 now,
2996 0,
2997 command_hash,
2998 result_message_id,
2999 )?;
3000 if !sender_public_key.is_empty() {
3001 let sender_copy = prepared
3002 .event_copies
3003 .iter()
3004 .find(|copy| copy.recipient_public_key == sender_public_key)
3005 .ok_or(SoftchatError::InvalidAccountOperation)?;
3006 let local_batch = AccountEngine::prepare_incoming(
3007 identity,
3008 self.account_id.clone(),
3009 vec![sender_copy.event.clone()],
3010 )?;
3011 if queued.is_some()
3012 && let Some(event) = local_batch.events.first()
3013 {
3014 queued = Some(Diagnostic::queued_from(event));
3015 }
3016 let committed = ingest_prepared(&transaction, &local_batch, now, false)?;
3017 AccountEngine::validate_ingestion_receipt(&local_batch, &committed.receipt)?;
3018 }
3019 if let Some((conversation_id, draft_match)) = draft_to_consume {
3020 crate::account_product::validate_conversation_id(conversation_id)?;
3021 transaction
3022 .execute(
3023 "UPDATE conversation_state
3024 SET draft_json = NULL, draft_reply_to = '', draft_updated_at = NULL
3025 WHERE conversation_id = ?1 AND draft_json = ?2
3026 AND COALESCE(draft_reply_to, '') = ?3",
3027 params![
3028 conversation_id,
3029 draft_match.draft_json,
3030 draft_match.reply_to_message_id,
3031 ],
3032 )
3033 .map_err(map_sqlite_error)?;
3034 }
3035 let intents = load_operation_intents(&transaction, &prepared.operation_id)?;
3036 let operation = DeliveryReducer::operation_snapshot(intents)?;
3037 let revision = bump_revision(&transaction)?;
3038 transaction.commit().map_err(map_sqlite_error)?;
3039 if let Some(mut queued) = queued {
3040 queued.revision = Some(revision);
3041 self.record_diagnostic(
3042 queued.count(Counter::TotalIntents, u64::from(operation.total_intents)),
3043 );
3044 }
3045 Ok(ProductAccountOperationResult {
3046 operation: AccountOperationResult {
3047 operation,
3048 revision,
3049 inserted: true,
3050 },
3051 result_message_id: result_message_id.to_owned(),
3052 })
3053 }
3054
3055 fn persist_product_operation_without_identity(
3056 &mut self,
3057 prepared: PreparedOutgoingOperation,
3058 now: i64,
3059 command_hash: &str,
3060 result_message_id: &str,
3061 diagnostic_subject: AccountLogSubject,
3062 ) -> Result<ProductAccountOperationResult, SoftchatError> {
3063 validate_timestamp(now)?;
3064 let transaction = self
3065 .connection
3066 .transaction_with_behavior(TransactionBehavior::Immediate)
3067 .map_err(map_sqlite_error)?;
3068 persist_prepared_operation(
3069 &transaction,
3070 &prepared,
3071 now,
3072 0,
3073 command_hash,
3074 result_message_id,
3075 )?;
3076 let intents = load_operation_intents(&transaction, &prepared.operation_id)?;
3077 let operation = DeliveryReducer::operation_snapshot(intents)?;
3078 let revision = bump_revision(&transaction)?;
3079 transaction.commit().map_err(map_sqlite_error)?;
3080 let mut diagnostic = Diagnostic::for_subject(diagnostic_subject, true)
3081 .count(Counter::TotalIntents, u64::from(operation.total_intents));
3082 if diagnostic_subject == AccountLogSubject::Unknown {
3083 diagnostic.category = AccountLogCategory::Recovery;
3084 diagnostic.operation = "recovery.completed";
3085 diagnostic.summary = "Signed outbox batch recovered";
3086 diagnostic.source = Some(AccountLogSource::Recovery);
3087 diagnostic.summarize = true;
3088 diagnostic = diagnostic.count(Counter::RecoveredItems, 1);
3089 }
3090 diagnostic.revision = Some(revision);
3091 self.record_diagnostic(diagnostic);
3092 Ok(ProductAccountOperationResult {
3093 operation: AccountOperationResult {
3094 operation,
3095 revision,
3096 inserted: true,
3097 },
3098 result_message_id: result_message_id.to_owned(),
3099 })
3100 }
3101
3102 fn operation_command_hash(&self, operation_id: &str) -> Result<Option<String>, SoftchatError> {
3103 self.connection
3104 .query_row(
3105 "SELECT command_hash FROM outgoing_operations WHERE operation_id = ?1",
3106 [operation_id],
3107 |row| row.get(0),
3108 )
3109 .optional()
3110 .map_err(map_sqlite_error)
3111 }
3112
3113 fn operation_result_message_id(&self, operation_id: &str) -> Result<String, SoftchatError> {
3114 self.connection
3115 .query_row(
3116 "SELECT result_message_id FROM outgoing_operations WHERE operation_id = ?1",
3117 [operation_id],
3118 |row| row.get(0),
3119 )
3120 .map_err(map_sqlite_error)
3121 }
3122
3123 pub(crate) fn active_relay_url(&self) -> Result<String, SoftchatError> {
3124 self.connection
3125 .query_row(
3126 "SELECT relay_url FROM relay_catalog
3127 WHERE active = 1 AND excluded = 0
3128 ORDER BY relay_url ASC LIMIT 1",
3129 [],
3130 |row| row.get(0),
3131 )
3132 .optional()
3133 .map_err(map_sqlite_error)?
3134 .ok_or(SoftchatError::InvalidRelayCatalog)
3135 }
3136
3137 fn operation_optional(
3138 &self,
3139 operation_id: &str,
3140 ) -> Result<Option<OperationSnapshot>, SoftchatError> {
3141 let exists = self
3142 .connection
3143 .query_row(
3144 "SELECT 1 FROM outgoing_operations WHERE operation_id = ?1",
3145 [operation_id],
3146 |_| Ok(()),
3147 )
3148 .optional()
3149 .map_err(map_sqlite_error)?
3150 .is_some();
3151 if !exists {
3152 return Ok(None);
3153 }
3154 let intents = load_operation_intents(&self.connection, operation_id)?;
3155 DeliveryReducer::operation_snapshot(intents).map(Some)
3156 }
3157
3158 fn apply_delivery_mutations(
3159 &mut self,
3160 claim: &DeliveryClaim,
3161 mutations: &[crate::DeliveryStateMutation],
3162 transition: &str,
3163 ) -> Result<AccountMutationResult, SoftchatError> {
3164 let transaction = begin_write_scope(&mut self.connection)?;
3165 for mutation in mutations {
3166 let expected_state = match transition {
3167 "claimed" => "claimed",
3168 "result" => "claimed_or_written",
3169 "release" => "claimed_or_written",
3170 _ => return Err(SoftchatError::InvalidDeliveryState),
3171 };
3172 let changed = if expected_state == "claimed" {
3173 transaction.execute(
3174 "UPDATE relay_delivery_intents
3175 SET state = ?1, lease_id = ?2, lease_expires_at = 0,
3176 attempt_count = ?3, last_category = ?4
3177 WHERE intent_id = ?5 AND lease_id = ?6 AND state = 'claimed'",
3178 params![
3179 intent_state_name(mutation.state),
3180 mutation.lease_id,
3181 mutation.attempt_count,
3182 mutation.category,
3183 mutation.intent_id,
3184 claim.lease_id,
3185 ],
3186 )
3187 } else {
3188 transaction.execute(
3189 "UPDATE relay_delivery_intents
3190 SET state = ?1, lease_id = ?2, lease_expires_at = 0,
3191 attempt_count = ?3, last_category = ?4
3192 WHERE intent_id = ?5 AND lease_id = ?6
3193 AND state IN ('claimed', 'socket_written')",
3194 params![
3195 intent_state_name(mutation.state),
3196 mutation.lease_id,
3197 mutation.attempt_count,
3198 mutation.category,
3199 mutation.intent_id,
3200 claim.lease_id,
3201 ],
3202 )
3203 }
3204 .map_err(map_sqlite_error)?;
3205 if changed != 1 {
3206 return Err(SoftchatError::InvalidDeliveryState);
3207 }
3208 }
3209 let mut diagnostics = Vec::new();
3210 if self.diagnostics.enabled() && !mutations.is_empty() {
3211 let changed_ids = mutations
3212 .iter()
3213 .map(|value| &value.intent_id)
3214 .collect::<BTreeSet<_>>();
3215 let operation_ids = claim
3216 .intents
3217 .iter()
3218 .filter(|intent| changed_ids.contains(&intent.intent_id))
3219 .map(|intent| &intent.operation_id)
3220 .collect::<BTreeSet<_>>();
3221 for operation_id in operation_ids {
3222 if let Ok(snapshot) = load_operation_intents(&transaction, operation_id)
3224 .and_then(DeliveryReducer::operation_snapshot)
3225 {
3226 let retryable = mutations.iter().any(|mutation| {
3227 mutation.state == DeliveryIntentState::Retryable
3228 && claim.intents.iter().any(|intent| {
3229 &intent.operation_id == operation_id
3230 && intent.intent_id == mutation.intent_id
3231 })
3232 });
3233 diagnostics.push(delivery_diagnostic(
3234 &transaction,
3235 &snapshot,
3236 transition,
3237 retryable,
3238 ));
3239 }
3240 }
3241 }
3242 let revision = bump_revision(&transaction)?;
3243 transaction.commit()?;
3244 for mut diagnostic in diagnostics {
3245 diagnostic.revision = Some(revision);
3246 self.record_diagnostic(diagnostic);
3247 }
3248 Ok(AccountMutationResult { revision })
3249 }
3250
3251 fn persist_relay_catalog(
3252 &mut self,
3253 entries: &[RelayCatalogEntry],
3254 updated_at: i64,
3255 ) -> Result<i64, SoftchatError> {
3256 validate_timestamp(updated_at).map_err(|_| SoftchatError::InvalidRelayCatalog)?;
3257 let transaction = self
3258 .connection
3259 .transaction_with_behavior(TransactionBehavior::Immediate)
3260 .map_err(map_sqlite_error)?;
3261 replace_relay_catalog(&transaction, entries, updated_at)?;
3262 let revision = bump_revision(&transaction)?;
3263 transaction.commit().map_err(map_sqlite_error)?;
3264 Ok(revision)
3265 }
3266}
3267
3268fn delivery_diagnostic(
3270 connection: &Connection,
3271 snapshot: &OperationSnapshot,
3272 transition: &str,
3273 retryable: bool,
3274) -> Diagnostic {
3275 let mut diagnostic = crate::account_diagnostics::delivery(snapshot, transition, retryable);
3276 let kind = connection
3277 .query_row(
3278 "SELECT projection.kind FROM outgoing_event_copies copy
3279 JOIN authenticated_rumor_wrappers wrapper ON wrapper.outer_event_id = copy.event_copy_id
3280 JOIN projections projection ON projection.logical_event_id = wrapper.rumor_id
3281 WHERE copy.operation_id = ?1 LIMIT 1",
3282 [&snapshot.operation_id],
3283 |row| row.get::<_, String>(0),
3284 )
3285 .ok()
3286 .and_then(|value| projection_kind_from_name(&value));
3287 if let Some(kind) = kind {
3288 let subject = crate::account_diagnostics::projection_subject(kind, "");
3289 diagnostic.subject = Some(subject);
3290 diagnostic.category = Diagnostic::for_subject(subject, true).category;
3291 }
3292 let ephemeral = connection.query_row(
3295 "SELECT COUNT(*) > 0 AND SUM(COALESCE(json_extract(event_json, '$.kind') = ?2, 0)) = COUNT(*)
3296 FROM outgoing_event_copies WHERE operation_id = ?1",
3297 params![snapshot.operation_id, crate::NostrEventKind::EPHEMERAL_GIFT_WRAP.as_u16()],
3298 |row| row.get::<_, bool>(0),
3299 ).unwrap_or(false);
3300 if ephemeral && diagnostic.level == AccountLogLevel::Info {
3301 diagnostic = diagnostic.debug();
3302 }
3303 diagnostic
3304}
3305
3306fn replace_relay_catalog(
3307 transaction: &Connection,
3308 entries: &[RelayCatalogEntry],
3309 updated_at: i64,
3310) -> Result<(), SoftchatError> {
3311 transaction
3312 .execute("DELETE FROM relay_catalog", [])
3313 .map_err(map_sqlite_error)?;
3314 for entry in entries {
3315 transaction
3316 .execute(
3317 "INSERT INTO relay_catalog
3318 (relay_url, created_at, source, active, is_dns_discovered,
3319 excluded, priority, weight, expires_at, updated_at)
3320 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
3321 params![
3322 entry.url,
3323 entry.created_at,
3324 relay_source_name(entry.source),
3325 i64::from(entry.is_active),
3326 i64::from(entry.is_dns_discovered),
3327 i64::from(entry.is_user_excluded),
3328 entry.dns_priority,
3329 entry.dns_weight,
3330 entry.dns_expires_at,
3331 updated_at,
3332 ],
3333 )
3334 .map_err(map_sqlite_error)?;
3335 }
3336 Ok(())
3337}
3338
3339fn migrate(connection: &mut Connection, account_id: &str) -> Result<(), SoftchatError> {
3340 let transaction = connection
3341 .transaction_with_behavior(TransactionBehavior::Immediate)
3342 .map_err(map_sqlite_error)?;
3343 transaction
3344 .execute_batch(
3345 "
3346 CREATE TABLE IF NOT EXISTS softchat_meta (
3347 key TEXT PRIMARY KEY NOT NULL,
3348 integer_value INTEGER,
3349 text_value TEXT
3350 );
3351 CREATE TABLE IF NOT EXISTS signed_events (
3352 event_id TEXT PRIMARY KEY NOT NULL,
3353 canonical_json TEXT NOT NULL,
3354 author_public_key TEXT NOT NULL,
3355 kind INTEGER NOT NULL,
3356 created_at INTEGER NOT NULL,
3357 received_at INTEGER NOT NULL
3358 );
3359 CREATE INDEX IF NOT EXISTS signed_events_created
3360 ON signed_events(created_at DESC, event_id ASC);
3361 CREATE TABLE IF NOT EXISTS authenticated_rumors (
3362 rumor_id TEXT PRIMARY KEY NOT NULL,
3363 outer_event_id TEXT NOT NULL REFERENCES signed_events(event_id),
3364 canonical_json TEXT NOT NULL,
3365 author_public_key TEXT NOT NULL,
3366 kind INTEGER NOT NULL,
3367 created_at INTEGER NOT NULL
3368 );
3369 CREATE TABLE IF NOT EXISTS authenticated_rumor_wrappers (
3370 outer_event_id TEXT PRIMARY KEY NOT NULL
3371 REFERENCES signed_events(event_id) ON DELETE CASCADE,
3372 rumor_id TEXT NOT NULL
3373 REFERENCES authenticated_rumors(rumor_id) ON DELETE CASCADE
3374 );
3375 CREATE INDEX IF NOT EXISTS rumor_wrappers_rumor
3376 ON authenticated_rumor_wrappers(rumor_id, outer_event_id);
3377 CREATE TABLE IF NOT EXISTS projections (
3378 logical_event_id TEXT PRIMARY KEY NOT NULL,
3379 outer_event_id TEXT NOT NULL REFERENCES signed_events(event_id),
3380 kind TEXT NOT NULL,
3381 author_public_key TEXT NOT NULL,
3382 created_at INTEGER NOT NULL,
3383 conversation_id TEXT NOT NULL,
3384 content TEXT NOT NULL,
3385 ephemeral INTEGER NOT NULL CHECK(ephemeral IN (0, 1))
3386 );
3387 CREATE INDEX IF NOT EXISTS projections_conversation
3388 ON projections(conversation_id, created_at DESC, logical_event_id ASC);
3389 CREATE INDEX IF NOT EXISTS projections_author_created
3390 ON projections(author_public_key, ephemeral, created_at ASC);
3391 CREATE INDEX IF NOT EXISTS projections_kind
3392 ON projections(kind, created_at DESC, logical_event_id ASC);
3393 CREATE INDEX IF NOT EXISTS projections_kind_conversation
3394 ON projections(
3395 kind, conversation_id, created_at DESC, logical_event_id ASC
3396 );
3397 CREATE INDEX IF NOT EXISTS projections_kind_author
3398 ON projections(
3399 kind, author_public_key, created_at DESC, logical_event_id ASC
3400 );
3401 CREATE TABLE IF NOT EXISTS projection_participants (
3402 logical_event_id TEXT NOT NULL REFERENCES projections(logical_event_id)
3403 ON DELETE CASCADE,
3404 public_key TEXT NOT NULL,
3405 PRIMARY KEY(logical_event_id, public_key)
3406 );
3407 CREATE INDEX IF NOT EXISTS participants_public_key
3408 ON projection_participants(public_key, logical_event_id);
3409 CREATE TABLE IF NOT EXISTS projection_targets (
3410 logical_event_id TEXT NOT NULL REFERENCES projections(logical_event_id)
3411 ON DELETE CASCADE,
3412 target_event_id TEXT NOT NULL,
3413 position INTEGER NOT NULL,
3414 PRIMARY KEY(logical_event_id, position)
3415 );
3416 CREATE INDEX IF NOT EXISTS projection_target
3417 ON projection_targets(target_event_id, logical_event_id);
3418 CREATE TABLE IF NOT EXISTS event_fingerprints (
3419 event_id TEXT PRIMARY KEY NOT NULL REFERENCES signed_events(event_id)
3420 ON DELETE CASCADE,
3421 created_at INTEGER NOT NULL
3422 );
3423 CREATE INDEX IF NOT EXISTS fingerprints_created
3424 ON event_fingerprints(created_at ASC, event_id ASC);
3425 CREATE TABLE IF NOT EXISTS outgoing_operations (
3426 operation_id TEXT PRIMARY KEY NOT NULL,
3427 account_id TEXT NOT NULL,
3428 created_at INTEGER NOT NULL,
3429 command_hash TEXT NOT NULL DEFAULT '',
3430 result_message_id TEXT NOT NULL DEFAULT ''
3431 );
3432 CREATE TABLE IF NOT EXISTS outgoing_event_copies (
3433 event_copy_id TEXT PRIMARY KEY NOT NULL,
3434 operation_id TEXT NOT NULL REFERENCES outgoing_operations(operation_id)
3435 ON DELETE CASCADE,
3436 recipient_public_key TEXT NOT NULL,
3437 event_json TEXT NOT NULL
3438 );
3439 CREATE INDEX IF NOT EXISTS event_copies_operation
3440 ON outgoing_event_copies(operation_id, event_copy_id);
3441 CREATE TABLE IF NOT EXISTS relay_delivery_intents (
3442 intent_id TEXT PRIMARY KEY NOT NULL,
3443 operation_id TEXT NOT NULL REFERENCES outgoing_operations(operation_id)
3444 ON DELETE CASCADE,
3445 event_copy_id TEXT NOT NULL REFERENCES outgoing_event_copies(event_copy_id)
3446 ON DELETE CASCADE,
3447 relay_url TEXT NOT NULL,
3448 payload_bytes INTEGER NOT NULL,
3449 state TEXT NOT NULL,
3450 lease_id TEXT NOT NULL DEFAULT '',
3451 lease_expires_at INTEGER NOT NULL DEFAULT 0,
3452 attempt_count INTEGER NOT NULL DEFAULT 0,
3453 last_category TEXT NOT NULL DEFAULT 'queued',
3454 created_at INTEGER NOT NULL,
3455 UNIQUE(event_copy_id, relay_url)
3456 );
3457 CREATE INDEX IF NOT EXISTS delivery_claimable
3458 ON relay_delivery_intents(state, created_at, intent_id);
3459 CREATE INDEX IF NOT EXISTS delivery_operation
3460 ON relay_delivery_intents(operation_id, intent_id);
3461 CREATE TABLE IF NOT EXISTS relay_catalog (
3462 relay_url TEXT PRIMARY KEY NOT NULL,
3463 created_at INTEGER NOT NULL,
3464 source TEXT NOT NULL,
3465 active INTEGER NOT NULL,
3466 is_dns_discovered INTEGER NOT NULL,
3467 excluded INTEGER NOT NULL,
3468 priority INTEGER NOT NULL,
3469 weight INTEGER NOT NULL,
3470 expires_at INTEGER NOT NULL,
3471 updated_at INTEGER NOT NULL
3472 );
3473 CREATE TABLE IF NOT EXISTS sync_checkpoints (
3474 sync_id TEXT PRIMARY KEY NOT NULL,
3475 checkpoint_timestamp INTEGER NOT NULL,
3476 updated_at INTEGER NOT NULL
3477 );
3478 DROP TABLE IF EXISTS attachment_jobs;
3479 CREATE TABLE IF NOT EXISTS local_contacts (
3480 public_key TEXT PRIMARY KEY NOT NULL,
3481 name TEXT,
3482 updated_at_millis INTEGER NOT NULL
3483 );
3484 CREATE TABLE IF NOT EXISTS sticker_cache (
3485 name TEXT PRIMARY KEY NOT NULL,
3486 url TEXT NOT NULL,
3487 associated_emojis_json TEXT NOT NULL
3488 );
3489 CREATE TABLE IF NOT EXISTS link_preview_cache (
3490 url TEXT PRIMARY KEY NOT NULL,
3491 title TEXT,
3492 description TEXT,
3493 image_url TEXT,
3494 updated_at_millis INTEGER NOT NULL
3495 );
3496 CREATE TABLE IF NOT EXISTS quarantine (
3497 id INTEGER PRIMARY KEY AUTOINCREMENT,
3498 source TEXT NOT NULL,
3499 source_id TEXT NOT NULL,
3500 category TEXT NOT NULL,
3501 observed_at INTEGER NOT NULL
3502 );
3503 CREATE TABLE IF NOT EXISTS conversation_state (
3504 conversation_id TEXT PRIMARY KEY NOT NULL,
3505 archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0, 1)),
3506 pinned INTEGER NOT NULL DEFAULT 0 CHECK(pinned IN (0, 1)),
3507 read_at INTEGER,
3508 unread_override INTEGER NOT NULL DEFAULT 0
3509 CHECK(unread_override IN (0, 1)),
3510 draft_json TEXT,
3511 draft_reply_to TEXT,
3512 draft_updated_at INTEGER,
3513 created_at INTEGER NOT NULL,
3514 updated_at INTEGER NOT NULL
3515 );
3516 CREATE TABLE IF NOT EXISTS conversation_members (
3517 conversation_id TEXT NOT NULL
3518 REFERENCES conversation_state(conversation_id) ON DELETE CASCADE,
3519 public_key TEXT NOT NULL,
3520 PRIMARY KEY(conversation_id, public_key)
3521 );
3522 CREATE TABLE IF NOT EXISTS conversation_subject_icons (
3523 conversation_id TEXT PRIMARY KEY NOT NULL
3524 REFERENCES conversation_state(conversation_id) ON DELETE CASCADE,
3525 subject_event_id TEXT NOT NULL,
3526 subject_created_at INTEGER NOT NULL,
3527 attachment_json TEXT NOT NULL
3528 );
3529 CREATE INDEX IF NOT EXISTS conversation_state_order
3530 ON conversation_state(pinned DESC, updated_at DESC, conversation_id ASC);
3531 CREATE TABLE IF NOT EXISTS conversation_read_state (
3532 conversation_id TEXT PRIMARY KEY NOT NULL,
3533 read_at INTEGER,
3534 read_message_id TEXT NOT NULL DEFAULT '',
3535 forced_unread INTEGER NOT NULL DEFAULT 0
3536 CHECK(forced_unread IN (0, 1)),
3537 updated_at INTEGER NOT NULL,
3538 update_id TEXT NOT NULL,
3539 unknown_json TEXT NOT NULL DEFAULT '{}'
3540 );
3541 CREATE TABLE IF NOT EXISTS account_settings (
3542 singleton INTEGER PRIMARY KEY NOT NULL CHECK(singleton = 1),
3543 schema_version INTEGER NOT NULL,
3544 canonical_json TEXT NOT NULL,
3545 updated_at INTEGER NOT NULL
3546 );
3547 CREATE TABLE IF NOT EXISTS app_data_effective (
3548 context TEXT PRIMARY KEY NOT NULL,
3549 canonical_json TEXT NOT NULL,
3550 event_created_at INTEGER NOT NULL,
3551 event_id TEXT NOT NULL
3552 );
3553 CREATE TABLE IF NOT EXISTS media_operations (
3554 operation_id TEXT PRIMARY KEY NOT NULL,
3555 command_id TEXT NOT NULL UNIQUE,
3556 conversation_id TEXT,
3557 source_message_id TEXT,
3558 operation_kind TEXT NOT NULL DEFAULT 'transfer'
3559 CHECK(operation_kind IN ('transfer', 'conversation_icon')),
3560 direction TEXT NOT NULL,
3561 protection TEXT NOT NULL DEFAULT 'encrypted'
3562 CHECK(protection IN ('encrypted', 'public')),
3563 state TEXT NOT NULL,
3564 source_fingerprint TEXT NOT NULL,
3565 attachment_json TEXT,
3566 byte_count INTEGER,
3567 attempt_count INTEGER NOT NULL DEFAULT 0,
3568 last_error_category TEXT NOT NULL DEFAULT '',
3569 lease_id TEXT NOT NULL DEFAULT '',
3570 lease_expires_at INTEGER NOT NULL DEFAULT 0,
3571 created_at INTEGER NOT NULL,
3572 updated_at INTEGER NOT NULL
3573 );
3574 CREATE INDEX IF NOT EXISTS media_operations_state
3575 ON media_operations(state, updated_at, operation_id);
3576 CREATE TABLE IF NOT EXISTS pending_media_messages (
3577 command_id TEXT PRIMARY KEY NOT NULL,
3578 conversation_id TEXT NOT NULL,
3579 text_content TEXT NOT NULL,
3580 emoji_tags_json TEXT NOT NULL,
3581 reply_to_message_id TEXT NOT NULL DEFAULT '',
3582 command_hash TEXT NOT NULL,
3583 state TEXT NOT NULL,
3584 publication_claimed INTEGER NOT NULL DEFAULT 0
3585 CHECK(publication_claimed IN (0, 1)),
3586 result_message_id TEXT NOT NULL DEFAULT '',
3587 created_at INTEGER NOT NULL,
3588 updated_at INTEGER NOT NULL
3589 );
3590 CREATE INDEX IF NOT EXISTS pending_media_messages_state
3591 ON pending_media_messages(state, updated_at, command_id);
3592 CREATE TABLE IF NOT EXISTS pending_message_attachments (
3593 command_id TEXT NOT NULL
3594 REFERENCES pending_media_messages(command_id) ON DELETE CASCADE,
3595 attachment_index INTEGER NOT NULL,
3596 operation_id TEXT NOT NULL UNIQUE
3597 REFERENCES media_operations(operation_id) ON DELETE CASCADE,
3598 PRIMARY KEY(command_id, attachment_index)
3599 );
3600 CREATE TABLE IF NOT EXISTS pending_asset_replacements (
3601 command_id TEXT PRIMARY KEY NOT NULL,
3602 target TEXT NOT NULL
3603 CHECK(target IN (
3604 'profile_picture', 'profile_banner', 'conversation_icon'
3605 )),
3606 conversation_id TEXT NOT NULL DEFAULT '',
3607 operation_id TEXT NOT NULL UNIQUE
3608 REFERENCES media_operations(operation_id) ON DELETE CASCADE,
3609 subject TEXT NOT NULL DEFAULT '',
3610 emoji_tags_json TEXT NOT NULL DEFAULT '[]',
3611 command_hash TEXT NOT NULL,
3612 state TEXT NOT NULL,
3613 result_operation_id TEXT NOT NULL DEFAULT '',
3614 created_at INTEGER NOT NULL,
3615 updated_at INTEGER NOT NULL
3616 );
3617 CREATE INDEX IF NOT EXISTS pending_asset_replacements_state
3618 ON pending_asset_replacements(state, updated_at, command_id);
3619 CREATE TABLE IF NOT EXISTS message_local_extras (
3620 message_id TEXT PRIMARY KEY NOT NULL,
3621 transcript TEXT,
3622 waveform BLOB,
3623 layout_width INTEGER,
3624 layout_height INTEGER,
3625 updated_at INTEGER NOT NULL
3626 );
3627 CREATE TABLE IF NOT EXISTS processed_transport_results (
3628 run_id TEXT NOT NULL,
3629 action_id TEXT NOT NULL,
3630 generation INTEGER NOT NULL,
3631 result_hash TEXT NOT NULL,
3632 outcome_json TEXT NOT NULL DEFAULT '',
3633 PRIMARY KEY(run_id, action_id)
3634 );
3635 CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts5(
3636 message_id UNINDEXED,
3637 conversation_id UNINDEXED,
3638 content,
3639 tokenize = 'unicode61 remove_diacritics 2'
3640 );
3641 ",
3642 )
3643 .map_err(map_sqlite_error)?;
3644
3645 let version = meta_integer_optional(&transaction, "schema_version")?.unwrap_or(0);
3646 if version > ACCOUNT_SCHEMA_VERSION {
3647 return Err(SoftchatError::InvalidPersistenceResult);
3648 }
3649 if version == 1 {
3650 transaction
3651 .execute_batch(
3652 "
3653 ALTER TABLE relay_catalog
3654 ADD COLUMN created_at INTEGER NOT NULL DEFAULT 0;
3655 ALTER TABLE relay_catalog
3656 ADD COLUMN is_dns_discovered INTEGER NOT NULL DEFAULT 0;
3657 ",
3658 )
3659 .map_err(map_sqlite_error)?;
3660 }
3661 if (1..5).contains(&version) {
3662 transaction
3663 .execute_batch(
3664 "ALTER TABLE outgoing_operations
3665 ADD COLUMN command_hash TEXT NOT NULL DEFAULT '';
3666 ALTER TABLE outgoing_operations
3667 ADD COLUMN result_message_id TEXT NOT NULL DEFAULT '';",
3668 )
3669 .map_err(map_sqlite_error)?;
3670 }
3671 if (1..6).contains(&version) {
3672 transaction
3673 .execute_batch(
3674 "ALTER TABLE processed_transport_results
3675 ADD COLUMN outcome_json TEXT NOT NULL DEFAULT '';",
3676 )
3677 .map_err(map_sqlite_error)?;
3678 }
3679 if (1..9).contains(&version) {
3680 transaction
3681 .execute_batch(
3682 "ALTER TABLE media_operations
3683 ADD COLUMN source_message_id TEXT;",
3684 )
3685 .map_err(map_sqlite_error)?;
3686 }
3687 if (1..10).contains(&version) {
3688 transaction
3689 .execute_batch(
3690 "DROP TABLE IF EXISTS legacy_import_journal;
3691 DELETE FROM softchat_meta
3692 WHERE key = 'legacy_local_contacts_imported';",
3693 )
3694 .map_err(map_sqlite_error)?;
3695 }
3696 if (1..11).contains(&version) {
3697 transaction
3698 .execute_batch(
3699 "ALTER TABLE media_operations
3700 ADD COLUMN protection TEXT NOT NULL DEFAULT 'encrypted'
3701 CHECK(protection IN ('encrypted', 'public'));",
3702 )
3703 .map_err(map_sqlite_error)?;
3704 }
3705 if (1..12).contains(&version) {
3706 transaction
3707 .execute(
3708 "INSERT OR IGNORE INTO authenticated_rumor_wrappers
3709 (outer_event_id, rumor_id)
3710 SELECT outer_event_id, rumor_id FROM authenticated_rumors",
3711 [],
3712 )
3713 .map_err(map_sqlite_error)?;
3714 }
3715 if (1..14).contains(&version) {
3716 transaction
3717 .execute_batch(
3718 "ALTER TABLE media_operations
3719 ADD COLUMN operation_kind TEXT NOT NULL DEFAULT 'transfer'
3720 CHECK(operation_kind IN ('transfer', 'conversation_icon'));",
3721 )
3722 .map_err(map_sqlite_error)?;
3723 }
3724 if version < 15 {
3725 let has_publication_claim = transaction
3726 .query_row(
3727 "SELECT EXISTS(
3728 SELECT 1 FROM pragma_table_info('pending_media_messages')
3729 WHERE name = 'publication_claimed'
3730 )",
3731 [],
3732 |row| row.get::<_, bool>(0),
3733 )
3734 .map_err(map_sqlite_error)?;
3735 if !has_publication_claim {
3736 transaction
3737 .execute_batch(
3738 "ALTER TABLE pending_media_messages
3739 ADD COLUMN publication_claimed INTEGER NOT NULL DEFAULT 0
3740 CHECK(publication_claimed IN (0, 1));",
3741 )
3742 .map_err(map_sqlite_error)?;
3743 }
3744 }
3745 if version < 7 {
3746 transaction
3747 .execute(
3748 "INSERT OR IGNORE INTO conversation_read_state
3749 (conversation_id, read_at, read_message_id, forced_unread,
3750 updated_at, update_id, unknown_json)
3751 SELECT
3752 cs.conversation_id,
3753 cs.read_at,
3754 COALESCE(
3755 (
3756 SELECT p.logical_event_id
3757 FROM projections p
3758 WHERE p.conversation_id = cs.conversation_id
3759 AND p.kind = 'chat_message'
3760 AND p.created_at = cs.read_at
3761 ORDER BY p.logical_event_id ASC LIMIT 1
3762 ),
3763 ''
3764 ),
3765 cs.unread_override,
3766 cs.updated_at,
3767 'legacy-local-' || cs.conversation_id,
3768 '{}'
3769 FROM conversation_state cs
3770 WHERE cs.read_at IS NOT NULL OR cs.unread_override = 1",
3771 [],
3772 )
3773 .map_err(map_sqlite_error)?;
3774 }
3775 transaction
3776 .execute(
3777 "INSERT INTO relay_catalog
3778 (relay_url, created_at, source, active, is_dns_discovered,
3779 excluded, priority, weight, expires_at, updated_at)
3780 SELECT
3781 ?1, 0, 'automatic', 1, 1,
3782 0, 0, 0, 3600, 0
3783 WHERE NOT EXISTS (SELECT 1 FROM relay_catalog)",
3784 [DEFAULT_RELAY_URL],
3785 )
3786 .map_err(map_sqlite_error)?;
3787 transaction
3788 .execute(
3789 "INSERT OR IGNORE INTO account_settings
3790 (singleton, schema_version, canonical_json, updated_at)
3791 VALUES (1, 1, '{}', 0)",
3792 [],
3793 )
3794 .map_err(map_sqlite_error)?;
3795 if (1..13).contains(&version) {
3796 reconcile_effective_app_data(&transaction)?;
3797 }
3798 transaction
3799 .execute(
3800 "INSERT OR IGNORE INTO conversation_state
3801 (conversation_id, created_at, updated_at)
3802 SELECT DISTINCT conversation_id, MIN(created_at), MAX(created_at)
3803 FROM projections
3804 WHERE conversation_id <> ''
3805 GROUP BY conversation_id",
3806 [],
3807 )
3808 .map_err(map_sqlite_error)?;
3809 transaction
3810 .execute(
3811 "INSERT OR IGNORE INTO conversation_members(conversation_id, public_key)
3812 SELECT DISTINCT p.conversation_id, pp.public_key
3813 FROM projections p
3814 JOIN projection_participants pp
3815 ON pp.logical_event_id = p.logical_event_id
3816 WHERE p.conversation_id <> ''",
3817 [],
3818 )
3819 .map_err(map_sqlite_error)?;
3820 if version < 14 {
3821 reconcile_effective_subject_icons(&transaction)?;
3822 reconcile_media_operation_kinds(&transaction)?;
3823 }
3824 if version < 16 {
3825 transaction
3826 .execute_batch(EFFECTIVE_MESSAGE_EDITS_SCHEMA)
3827 .map_err(map_sqlite_error)?;
3828 transaction
3829 .execute("DELETE FROM message_fts", [])
3830 .map_err(map_sqlite_error)?;
3831 insert_message_fts(&transaction, None)?;
3832 }
3833 transaction
3834 .execute(
3835 "INSERT INTO softchat_meta(key, integer_value)
3836 VALUES ('schema_version', ?1)
3837 ON CONFLICT(key) DO UPDATE SET integer_value = excluded.integer_value",
3838 [ACCOUNT_SCHEMA_VERSION],
3839 )
3840 .map_err(map_sqlite_error)?;
3841 transaction
3842 .execute(
3843 "INSERT OR IGNORE INTO softchat_meta(key, integer_value)
3844 VALUES ('revision', 0)",
3845 [],
3846 )
3847 .map_err(map_sqlite_error)?;
3848 let existing_account = transaction
3849 .query_row(
3850 "SELECT text_value FROM softchat_meta WHERE key = 'account_id'",
3851 [],
3852 |row| row.get::<_, Option<String>>(0),
3853 )
3854 .optional()
3855 .map_err(map_sqlite_error)?
3856 .flatten();
3857 if existing_account
3858 .as_deref()
3859 .is_some_and(|value| value != account_id)
3860 {
3861 return Err(SoftchatError::InvalidPersistenceResult);
3862 }
3863 transaction
3864 .execute(
3865 "INSERT OR IGNORE INTO softchat_meta(key, text_value)
3866 VALUES ('account_id', ?1)",
3867 [account_id],
3868 )
3869 .map_err(map_sqlite_error)?;
3870 transaction.commit().map_err(map_sqlite_error)
3871}
3872
3873struct PreparedIngestionCommit {
3874 receipt: IngestionReceipt,
3875 suppressed_projection_outer_event_ids: BTreeSet<String>,
3876 diagnostics: Vec<Diagnostic>,
3877}
3878
3879fn ingest_prepared(
3880 transaction: &Connection,
3881 batch: &PreparedIncomingBatch,
3882 received_at: i64,
3883 diagnostics_enabled: bool,
3884) -> Result<PreparedIngestionCommit, SoftchatError> {
3885 let mut inserted_event_ids = Vec::new();
3886 let mut duplicate_event_ids = Vec::new();
3887 let mut quarantined_event_ids = Vec::new();
3888 let mut suppressed_projection_outer_event_ids = BTreeSet::new();
3889 let mut diagnostics = Vec::new();
3890 for prepared in &batch.events {
3891 let event = SignedNostrEvent::try_from(prepared.outer_event.clone())?;
3892 let event_id = event.id().to_hex();
3893 if prepared.projection.ephemeral {
3894 if diagnostics_enabled {
3895 crate::account_diagnostics::accumulate(
3896 &mut diagnostics,
3897 Diagnostic::received(prepared).count(Counter::EphemeralEvents, 1),
3898 );
3899 }
3900 inserted_event_ids.push(event_id);
3901 continue;
3902 }
3903 let canonical_json = event.to_json()?;
3904 let existing = transaction
3905 .query_row(
3906 "SELECT canonical_json FROM signed_events WHERE event_id = ?1",
3907 [&event_id],
3908 |row| row.get::<_, String>(0),
3909 )
3910 .optional()
3911 .map_err(map_sqlite_error)?;
3912 if let Some(existing) = existing {
3913 if existing == canonical_json {
3914 duplicate_event_ids.push(event_id);
3915 if diagnostics_enabled {
3916 crate::account_diagnostics::accumulate(
3917 &mut diagnostics,
3918 Diagnostic::new(
3919 AccountLogCategory::Storage,
3920 "ingestion.duplicate",
3921 "Repeated event ignored",
3922 )
3923 .debug()
3924 .count(Counter::Duplicates, 1),
3925 );
3926 }
3927 } else {
3928 transaction
3929 .execute(
3930 "INSERT INTO quarantine(source, source_id, category, observed_at)
3931 VALUES ('ingestion', ?1, 'event_id_conflict', ?2)",
3932 params![event_id, received_at],
3933 )
3934 .map_err(map_sqlite_error)?;
3935 quarantined_event_ids.push(event_id);
3936 if diagnostics_enabled {
3937 let mut diagnostic = Diagnostic::new(
3938 AccountLogCategory::Storage,
3939 "ingestion.quarantined",
3940 "Conflicting event quarantined",
3941 )
3942 .count(Counter::QuarantinedEvents, 1);
3943 diagnostic.level = AccountLogLevel::Warn;
3944 diagnostic.reason = "event_id_conflict";
3945 diagnostic.summarize = true;
3946 crate::account_diagnostics::accumulate(&mut diagnostics, diagnostic);
3947 }
3948 }
3949 continue;
3950 }
3951 transaction
3952 .execute(
3953 "INSERT INTO signed_events
3954 (event_id, canonical_json, author_public_key, kind, created_at, received_at)
3955 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
3956 params![
3957 event_id,
3958 canonical_json,
3959 event.public_key().to_hex(),
3960 i64::from(event.kind().as_u16()),
3961 i64::try_from(event.created_at())
3962 .map_err(|_| SoftchatError::InvalidEventTimestamp)?,
3963 received_at,
3964 ],
3965 )
3966 .map_err(map_sqlite_error)?;
3967 transaction
3968 .execute(
3969 "INSERT INTO event_fingerprints(event_id, created_at) VALUES (?1, ?2)",
3970 params![
3971 event_id,
3972 i64::try_from(event.created_at())
3973 .map_err(|_| SoftchatError::InvalidEventTimestamp)?,
3974 ],
3975 )
3976 .map_err(map_sqlite_error)?;
3977 let mut insert_logical_projection = true;
3978 if let Some(rumor) = &prepared.rumor {
3979 let canonical_rumor =
3980 serde_json::to_string(rumor).map_err(|_| SoftchatError::InternalFailure)?;
3981 let existing = transaction
3982 .query_row(
3983 "SELECT canonical_json, author_public_key, kind, created_at
3984 FROM authenticated_rumors WHERE rumor_id = ?1",
3985 [&rumor.id],
3986 |row| {
3987 Ok((
3988 row.get::<_, String>(0)?,
3989 row.get::<_, String>(1)?,
3990 row.get::<_, i64>(2)?,
3991 row.get::<_, i64>(3)?,
3992 ))
3993 },
3994 )
3995 .optional()
3996 .map_err(map_sqlite_error)?;
3997 if let Some((stored_json, stored_author, stored_kind, stored_created_at)) = existing {
3998 if stored_json != canonical_rumor
3999 || stored_author != rumor.public_key
4000 || stored_kind != i64::from(rumor.kind)
4001 || stored_created_at != rumor.created_at
4002 {
4003 return Err(SoftchatError::InvalidPersistenceResult);
4004 }
4005 insert_logical_projection = false;
4006 suppressed_projection_outer_event_ids.insert(event_id.clone());
4007 } else {
4008 transaction
4009 .execute(
4010 "INSERT INTO authenticated_rumors
4011 (rumor_id, outer_event_id, canonical_json, author_public_key, kind,
4012 created_at)
4013 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
4014 params![
4015 rumor.id,
4016 event_id,
4017 canonical_rumor,
4018 rumor.public_key,
4019 i64::from(rumor.kind),
4020 rumor.created_at,
4021 ],
4022 )
4023 .map_err(map_sqlite_error)?;
4024 }
4025 transaction
4026 .execute(
4027 "INSERT INTO authenticated_rumor_wrappers
4028 (outer_event_id, rumor_id) VALUES (?1, ?2)",
4029 params![event_id, rumor.id],
4030 )
4031 .map_err(map_sqlite_error)?;
4032 }
4033 if insert_logical_projection {
4034 insert_projection(
4035 transaction,
4036 &event_id,
4037 &prepared.projection,
4038 prepared.rumor.as_ref(),
4039 )?;
4040 }
4041 if diagnostics_enabled {
4042 let diagnostic = if insert_logical_projection {
4043 Diagnostic::received(prepared).count(Counter::LogicalEvents, 1)
4044 } else {
4045 Diagnostic::new(
4046 AccountLogCategory::Storage,
4047 "ingestion.known_copy",
4048 "Known logical event copy stored",
4049 )
4050 .debug()
4051 .count(Counter::KnownCopies, 1)
4052 }
4053 .count(Counter::DurableEvents, 1);
4054 crate::account_diagnostics::accumulate(&mut diagnostics, diagnostic);
4055 }
4056 inserted_event_ids.push(event_id);
4057 }
4058 Ok(PreparedIngestionCommit {
4059 receipt: IngestionReceipt {
4060 account_id: batch.account_id.clone(),
4061 inserted_event_ids,
4062 duplicate_event_ids,
4063 quarantined_event_ids,
4064 },
4065 suppressed_projection_outer_event_ids,
4066 diagnostics,
4067 })
4068}
4069
4070fn account_projection_from_prepared(prepared: &crate::PreparedIncomingEvent) -> AccountProjection {
4071 AccountProjection {
4072 kind: prepared.projection.kind,
4073 logical_event_id: prepared.projection.logical_event_id.clone(),
4074 outer_event: prepared.outer_event.clone(),
4075 rumor: prepared.rumor.clone(),
4076 author_public_key: prepared.projection.author_public_key.clone(),
4077 created_at: prepared.projection.created_at,
4078 conversation_id: prepared.projection.conversation_id.clone(),
4079 participants: prepared.projection.participants.clone(),
4080 target_event_ids: prepared.projection.target_event_ids.clone(),
4081 content: prepared.projection.content.clone(),
4082 }
4083}
4084
4085fn insert_projection(
4086 transaction: &Connection,
4087 outer_event_id: &str,
4088 projection: &crate::ProjectionMutation,
4089 rumor: Option<&RumorEvent>,
4090) -> Result<(), SoftchatError> {
4091 transaction
4092 .execute(
4093 "INSERT INTO projections
4094 (logical_event_id, outer_event_id, kind, author_public_key, created_at,
4095 conversation_id, content, ephemeral)
4096 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
4097 params![
4098 projection.logical_event_id,
4099 outer_event_id,
4100 projection_kind_name(projection.kind),
4101 projection.author_public_key,
4102 projection.created_at,
4103 projection.conversation_id,
4104 projection.content,
4105 i64::from(projection.ephemeral),
4106 ],
4107 )
4108 .map_err(map_sqlite_error)?;
4109 for participant in &projection.participants {
4110 transaction
4111 .execute(
4112 "INSERT INTO projection_participants(logical_event_id, public_key)
4113 VALUES (?1, ?2)",
4114 params![projection.logical_event_id, participant],
4115 )
4116 .map_err(map_sqlite_error)?;
4117 }
4118 for (position, target) in projection.target_event_ids.iter().enumerate() {
4119 transaction
4120 .execute(
4121 "INSERT INTO projection_targets(logical_event_id, target_event_id, position)
4122 VALUES (?1, ?2, ?3)",
4123 params![
4124 projection.logical_event_id,
4125 target,
4126 i64::try_from(position).map_err(|_| SoftchatError::InternalFailure)?,
4127 ],
4128 )
4129 .map_err(map_sqlite_error)?;
4130 }
4131 if !projection.conversation_id.is_empty() {
4132 transaction
4133 .execute(
4134 "INSERT INTO conversation_state
4135 (conversation_id, created_at, updated_at)
4136 VALUES (?1, ?2, ?2)
4137 ON CONFLICT(conversation_id) DO UPDATE SET
4138 updated_at = MAX(updated_at, excluded.updated_at)",
4139 params![projection.conversation_id, projection.created_at],
4140 )
4141 .map_err(map_sqlite_error)?;
4142 for participant in &projection.participants {
4143 transaction
4144 .execute(
4145 "INSERT OR IGNORE INTO conversation_members
4146 (conversation_id, public_key)
4147 VALUES (?1, ?2)",
4148 params![projection.conversation_id, participant],
4149 )
4150 .map_err(map_sqlite_error)?;
4151 }
4152 }
4153 match projection.kind {
4154 ProjectionKind::ChatMessage => {
4155 refresh_message_fts(transaction, &projection.logical_event_id)?;
4156 }
4157 ProjectionKind::Edit | ProjectionKind::Deletion => {
4158 let mut affected_messages = projection
4159 .target_event_ids
4160 .iter()
4161 .cloned()
4162 .collect::<BTreeSet<_>>();
4163 if projection.kind == ProjectionKind::Deletion {
4164 let mut statement = transaction
4165 .prepare(
4166 "SELECT target.target_event_id
4167 FROM projection_targets target
4168 JOIN projections edit
4169 ON edit.logical_event_id = target.logical_event_id
4170 WHERE edit.logical_event_id = ?1 AND edit.kind = 'edit'",
4171 )
4172 .map_err(map_sqlite_error)?;
4173 for target in &projection.target_event_ids {
4174 let rows = statement
4175 .query_map([target], |row| row.get::<_, String>(0))
4176 .map_err(map_sqlite_error)?;
4177 for row in rows {
4178 affected_messages.insert(row.map_err(map_sqlite_error)?);
4179 }
4180 }
4181 }
4182 for message_id in affected_messages {
4183 refresh_message_fts(transaction, &message_id)?;
4184 }
4185 }
4186 ProjectionKind::AppDataSync => {
4187 apply_app_data_projection(transaction, projection)?;
4188 }
4189 ProjectionKind::Subject => {
4190 let rumor = rumor.ok_or(SoftchatError::InvalidPersistenceResult)?;
4191 apply_subject_icon_projection(
4192 transaction,
4193 &projection.conversation_id,
4194 &projection.logical_event_id,
4195 projection.created_at,
4196 rumor,
4197 )?;
4198 }
4199 ProjectionKind::Reaction
4200 | ProjectionKind::Typing
4201 | ProjectionKind::UserMetadata
4202 | ProjectionKind::FollowList
4203 | ProjectionKind::ApplicationData
4204 | ProjectionKind::GenericRepost
4205 | ProjectionKind::Unknown => {}
4206 }
4207 Ok(())
4208}
4209
4210fn apply_subject_icon_projection(
4211 connection: &Connection,
4212 conversation_id: &str,
4213 subject_event_id: &str,
4214 subject_created_at: i64,
4215 rumor: &RumorEvent,
4216) -> Result<(), SoftchatError> {
4217 let Some(attachment) =
4218 subject_icon_metadata(rumor).map_err(|_| SoftchatError::InvalidPersistenceResult)?
4219 else {
4220 return Ok(());
4222 };
4223 let attachment_json =
4224 serde_json::to_string(&attachment).map_err(|_| SoftchatError::InternalFailure)?;
4225 connection
4226 .execute(
4227 "INSERT INTO conversation_subject_icons
4228 (conversation_id, subject_event_id, subject_created_at, attachment_json)
4229 VALUES (?1, ?2, ?3, ?4)
4230 ON CONFLICT(conversation_id) DO UPDATE SET
4231 subject_event_id = excluded.subject_event_id,
4232 subject_created_at = excluded.subject_created_at,
4233 attachment_json = excluded.attachment_json
4234 WHERE excluded.subject_created_at > conversation_subject_icons.subject_created_at
4235 OR (
4236 excluded.subject_created_at = conversation_subject_icons.subject_created_at
4237 AND excluded.subject_event_id < conversation_subject_icons.subject_event_id
4238 )",
4239 params![
4240 conversation_id,
4241 subject_event_id,
4242 subject_created_at,
4243 attachment_json,
4244 ],
4245 )
4246 .map_err(map_sqlite_error)?;
4247 Ok(())
4248}
4249
4250fn reconcile_effective_subject_icons(connection: &Connection) -> Result<(), SoftchatError> {
4251 connection
4252 .execute("DELETE FROM conversation_subject_icons", [])
4253 .map_err(map_sqlite_error)?;
4254 let candidates = {
4255 let mut statement = connection
4256 .prepare(
4257 "SELECT p.conversation_id, p.logical_event_id, p.created_at,
4258 ar.canonical_json
4259 FROM projections p
4260 LEFT JOIN authenticated_rumors ar
4261 ON ar.rumor_id = p.logical_event_id
4262 WHERE p.kind = 'subject' AND p.ephemeral = 0",
4263 )
4264 .map_err(map_sqlite_error)?;
4265 let rows = statement
4266 .query_map([], |row| {
4267 Ok((
4268 row.get::<_, String>(0)?,
4269 row.get::<_, String>(1)?,
4270 row.get::<_, i64>(2)?,
4271 row.get::<_, Option<String>>(3)?,
4272 ))
4273 })
4274 .map_err(map_sqlite_error)?;
4275 rows.collect::<Result<Vec<_>, _>>()
4276 .map_err(map_sqlite_error)?
4277 };
4278 for (conversation_id, subject_event_id, subject_created_at, rumor_json) in candidates {
4279 let rumor_json = rumor_json.ok_or(SoftchatError::InvalidPersistenceResult)?;
4280 let rumor = serde_json::from_str::<RumorEvent>(&rumor_json)
4281 .map_err(|_| SoftchatError::InvalidPersistenceResult)?;
4282 apply_subject_icon_projection(
4283 connection,
4284 &conversation_id,
4285 &subject_event_id,
4286 subject_created_at,
4287 &rumor,
4288 )?;
4289 }
4290 Ok(())
4291}
4292
4293fn reconcile_media_operation_kinds(connection: &Connection) -> Result<(), SoftchatError> {
4294 let known_icons = {
4295 let mut statement = connection
4296 .prepare(
4297 "SELECT p.conversation_id, p.logical_event_id, ar.canonical_json
4298 FROM projections p
4299 JOIN authenticated_rumors ar
4300 ON ar.rumor_id = p.logical_event_id
4301 WHERE p.kind = 'subject' AND p.ephemeral = 0",
4302 )
4303 .map_err(map_sqlite_error)?;
4304 let rows = statement
4305 .query_map([], |row| {
4306 Ok((
4307 row.get::<_, String>(0)?,
4308 row.get::<_, String>(1)?,
4309 row.get::<_, String>(2)?,
4310 ))
4311 })
4312 .map_err(map_sqlite_error)?;
4313 let mut known_icons = BTreeSet::new();
4314 for row in rows {
4315 let (conversation_id, subject_event_id, rumor_json) = row.map_err(map_sqlite_error)?;
4316 let rumor = serde_json::from_str::<RumorEvent>(&rumor_json)
4317 .map_err(|_| SoftchatError::InvalidPersistenceResult)?;
4318 let Some(attachment) = subject_icon_metadata(&rumor)
4319 .map_err(|_| SoftchatError::InvalidPersistenceResult)?
4320 else {
4321 continue;
4322 };
4323 let source_fingerprint = crate::account_product::conversation_icon_source_fingerprint(
4324 &conversation_id,
4325 &subject_event_id,
4326 &attachment,
4327 )?;
4328 let attachment_json =
4329 serde_json::to_string(&attachment).map_err(|_| SoftchatError::InternalFailure)?;
4330 known_icons.insert((conversation_id, source_fingerprint, attachment_json));
4331 }
4332 known_icons
4333 };
4334 let candidates = {
4335 let mut statement = connection
4336 .prepare(
4337 "SELECT operation_id, conversation_id, source_fingerprint, attachment_json
4338 FROM media_operations
4339 WHERE direction = 'download'
4340 AND COALESCE(source_message_id, '') = ''
4341 AND conversation_id IS NOT NULL
4342 AND attachment_json IS NOT NULL",
4343 )
4344 .map_err(map_sqlite_error)?;
4345 let rows = statement
4346 .query_map([], |row| {
4347 Ok((
4348 row.get::<_, String>(0)?,
4349 row.get::<_, String>(1)?,
4350 row.get::<_, String>(2)?,
4351 row.get::<_, String>(3)?,
4352 ))
4353 })
4354 .map_err(map_sqlite_error)?;
4355 rows.collect::<Result<Vec<_>, _>>()
4356 .map_err(map_sqlite_error)?
4357 };
4358 for (operation_id, conversation_id, source_fingerprint, attachment_json) in candidates {
4359 let attachment = serde_json::from_str::<crate::AttachmentMetadata>(&attachment_json)
4360 .map_err(|_| SoftchatError::InvalidPersistenceResult)?;
4361 attachment
4362 .to_tag()
4363 .map_err(|_| SoftchatError::InvalidPersistenceResult)?;
4364 let canonical_attachment =
4365 serde_json::to_string(&attachment).map_err(|_| SoftchatError::InternalFailure)?;
4366 if known_icons.contains(&(conversation_id, source_fingerprint, canonical_attachment)) {
4367 connection
4368 .execute(
4369 "UPDATE media_operations SET operation_kind = 'conversation_icon'
4370 WHERE operation_id = ?1",
4371 [&operation_id],
4372 )
4373 .map_err(map_sqlite_error)?;
4374 }
4375 }
4376 Ok(())
4377}
4378
4379fn reconcile_effective_app_data(transaction: &Connection) -> Result<(), SoftchatError> {
4380 let candidates = {
4381 let mut statement = transaction
4382 .prepare(
4383 "SELECT logical_event_id, author_public_key, created_at, content
4384 FROM projections
4385 WHERE kind = 'app_data_sync'
4386 ORDER BY created_at ASC, logical_event_id DESC",
4387 )
4388 .map_err(map_sqlite_error)?;
4389 let rows = statement
4390 .query_map([], |row| {
4391 Ok(crate::ProjectionMutation {
4392 kind: ProjectionKind::AppDataSync,
4393 logical_event_id: row.get(0)?,
4394 author_public_key: row.get(1)?,
4395 created_at: row.get(2)?,
4396 conversation_id: String::new(),
4397 participants: Vec::new(),
4398 target_event_ids: Vec::new(),
4399 content: row.get(3)?,
4400 ephemeral: false,
4401 })
4402 })
4403 .map_err(map_sqlite_error)?;
4404 rows.collect::<Result<Vec<_>, _>>()
4405 .map_err(map_sqlite_error)?
4406 };
4407
4408 for candidate in candidates {
4409 let context = candidate
4410 .content
4411 .split_once(':')
4412 .map(|(context, _)| context)
4413 .ok_or(SoftchatError::InvalidApplicationData)?;
4414 if replaceable_is_newer(
4415 transaction,
4416 context,
4417 candidate.created_at,
4418 &candidate.logical_event_id,
4419 )? {
4420 apply_app_data_projection(transaction, &candidate)?;
4421 }
4422 }
4423 Ok(())
4424}
4425
4426fn apply_app_data_projection(
4427 transaction: &Connection,
4428 projection: &crate::ProjectionMutation,
4429) -> Result<(), SoftchatError> {
4430 let (context, json) = projection
4431 .content
4432 .split_once(':')
4433 .ok_or(SoftchatError::InvalidApplicationData)?;
4434 match context {
4435 "app-settings" => {
4436 let canonical = crate::account_product::canonical_settings_json(json)?;
4437 let settings = crate::account_product::settings_from_json(&canonical)?;
4438 if replaceable_is_newer(
4439 transaction,
4440 context,
4441 projection.created_at,
4442 &projection.logical_event_id,
4443 )? {
4444 transaction
4445 .execute(
4446 "UPDATE account_settings
4447 SET schema_version = ?1, canonical_json = ?2, updated_at = ?3
4448 WHERE singleton = 1",
4449 params![settings.schema_version, canonical, projection.created_at],
4450 )
4451 .map_err(map_sqlite_error)?;
4452 store_effective_app_data(
4453 transaction,
4454 context,
4455 json,
4456 projection.created_at,
4457 &projection.logical_event_id,
4458 )?;
4459 }
4460 }
4461 "read-state" => {
4462 let snapshot = crate::account_product::parse_read_state_snapshot(
4463 json,
4464 projection.created_at,
4465 &projection.logical_event_id,
4466 )?;
4467 for entry in snapshot.entries {
4468 let current = transaction
4469 .query_row(
4470 "SELECT updated_at, update_id
4471 FROM conversation_read_state WHERE conversation_id = ?1",
4472 [&entry.state.conversation_id],
4473 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
4474 )
4475 .optional()
4476 .map_err(map_sqlite_error)?;
4477 if current.as_ref().is_some_and(|(updated_at, update_id)| {
4478 (*updated_at, update_id.as_str())
4479 >= (entry.state.updated_at, entry.state.update_id.as_str())
4480 }) {
4481 continue;
4482 }
4483 let (read_at, read_message_id) = entry
4484 .state
4485 .boundary
4486 .as_ref()
4487 .map_or((None, String::new()), |boundary| {
4488 (Some(boundary.created_at), boundary.message_id.clone())
4489 });
4490 transaction
4491 .execute(
4492 "INSERT INTO conversation_read_state
4493 (conversation_id, read_at, read_message_id, forced_unread,
4494 updated_at, update_id, unknown_json)
4495 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
4496 ON CONFLICT(conversation_id) DO UPDATE SET
4497 read_at = excluded.read_at,
4498 read_message_id = excluded.read_message_id,
4499 forced_unread = excluded.forced_unread,
4500 updated_at = excluded.updated_at,
4501 update_id = excluded.update_id,
4502 unknown_json = excluded.unknown_json",
4503 params![
4504 entry.state.conversation_id,
4505 read_at,
4506 read_message_id,
4507 i64::from(entry.state.forced_unread),
4508 entry.state.updated_at,
4509 entry.state.update_id,
4510 entry.unknown_json,
4511 ],
4512 )
4513 .map_err(map_sqlite_error)?;
4514 }
4515 if replaceable_is_newer(
4516 transaction,
4517 context,
4518 projection.created_at,
4519 &projection.logical_event_id,
4520 )? {
4521 let mut root: serde_json::Map<String, serde_json::Value> =
4522 serde_json::from_str(&snapshot.unknown_json)
4523 .map_err(|_| SoftchatError::InvalidApplicationData)?;
4524 root.insert(
4525 "schemaVersion".to_owned(),
4526 serde_json::json!(crate::ACCOUNT_READ_STATE_SCHEMA_VERSION),
4527 );
4528 root.insert(
4529 "chats".to_owned(),
4530 serde_json::from_str::<serde_json::Value>(json)
4531 .ok()
4532 .and_then(|value| value.get("chats").cloned())
4533 .unwrap_or_else(|| serde_json::Value::Array(Vec::new())),
4534 );
4535 let canonical = serde_json::to_string(&root)
4536 .map_err(|_| SoftchatError::InvalidApplicationData)?;
4537 store_effective_app_data(
4538 transaction,
4539 context,
4540 &canonical,
4541 projection.created_at,
4542 &projection.logical_event_id,
4543 )?;
4544 }
4545 }
4546 _ => return Err(SoftchatError::InvalidApplicationData),
4547 }
4548 Ok(())
4549}
4550
4551fn replaceable_is_newer(
4552 transaction: &Connection,
4553 context: &str,
4554 created_at: i64,
4555 event_id: &str,
4556) -> Result<bool, SoftchatError> {
4557 let current = transaction
4558 .query_row(
4559 "SELECT event_created_at, event_id
4560 FROM app_data_effective WHERE context = ?1",
4561 [context],
4562 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
4563 )
4564 .optional()
4565 .map_err(map_sqlite_error)?;
4566 Ok(current.as_ref().is_none_or(|(current_at, current_id)| {
4567 crate::replacement::is_newer(&created_at, event_id, current_at, current_id.as_str())
4568 }))
4569}
4570
4571fn store_effective_app_data(
4572 transaction: &Connection,
4573 context: &str,
4574 canonical_json: &str,
4575 event_created_at: i64,
4576 event_id: &str,
4577) -> Result<(), SoftchatError> {
4578 transaction
4579 .execute(
4580 "INSERT INTO app_data_effective
4581 (context, canonical_json, event_created_at, event_id)
4582 VALUES (?1, ?2, ?3, ?4)
4583 ON CONFLICT(context) DO UPDATE SET
4584 canonical_json = excluded.canonical_json,
4585 event_created_at = excluded.event_created_at,
4586 event_id = excluded.event_id",
4587 params![context, canonical_json, event_created_at, event_id],
4588 )
4589 .map_err(map_sqlite_error)?;
4590 Ok(())
4591}
4592
4593fn refresh_message_fts(transaction: &Connection, message_id: &str) -> Result<(), SoftchatError> {
4594 transaction
4595 .execute(
4596 "DELETE FROM message_fts WHERE message_id = ?1",
4597 [message_id],
4598 )
4599 .map_err(map_sqlite_error)?;
4600 insert_message_fts(transaction, Some(message_id))
4601}
4602
4603fn insert_message_fts(
4604 transaction: &Connection,
4605 message_id: Option<&str>,
4606) -> Result<(), SoftchatError> {
4607 let predicate = if message_id.is_some() {
4610 "p.logical_event_id = ?1"
4611 } else {
4612 "?1 IS NULL"
4613 };
4614 transaction
4615 .execute(
4616 &format!(
4617 "INSERT INTO message_fts(message_id, conversation_id, content)
4618 SELECT p.logical_event_id, p.conversation_id,
4619 COALESCE(json_extract(edit.canonical_json, '$.content'),
4620 json_extract(original.canonical_json, '$.content'))
4621 FROM projections p
4622 JOIN authenticated_rumors original
4623 ON original.rumor_id = p.logical_event_id
4624 LEFT JOIN authenticated_rumors edit
4625 ON edit.rumor_id = (
4626 SELECT effective.edit_id FROM effective_message_edits effective
4627 WHERE effective.message_id = p.logical_event_id
4628 )
4629 WHERE {predicate}
4630 AND p.kind = 'chat_message' AND p.ephemeral = 0
4631 AND NOT EXISTS (
4632 SELECT 1
4633 FROM projection_targets dt
4634 JOIN projections d ON d.logical_event_id = dt.logical_event_id
4635 WHERE dt.target_event_id = p.logical_event_id
4636 AND d.kind = 'deletion'
4637 AND d.author_public_key = p.author_public_key
4638 )"
4639 ),
4640 [message_id],
4641 )
4642 .map_err(map_sqlite_error)?;
4643 Ok(())
4644}
4645
4646fn persist_prepared_operation(
4647 transaction: &Connection,
4648 operation: &PreparedOutgoingOperation,
4649 now: i64,
4650 initial_attempt_count: u32,
4651 command_hash: &str,
4652 result_message_id: &str,
4653) -> Result<(), SoftchatError> {
4654 transaction
4655 .execute(
4656 "INSERT INTO outgoing_operations
4657 (operation_id, account_id, created_at, command_hash, result_message_id)
4658 VALUES (?1, ?2, ?3, ?4, ?5)",
4659 params![
4660 operation.operation_id,
4661 operation.account_id,
4662 now,
4663 command_hash,
4664 result_message_id
4665 ],
4666 )
4667 .map_err(map_sqlite_error)?;
4668 for copy in &operation.event_copies {
4669 let event = SignedNostrEvent::try_from(copy.event.clone())?;
4670 transaction
4671 .execute(
4672 "INSERT INTO outgoing_event_copies
4673 (event_copy_id, operation_id, recipient_public_key, event_json)
4674 VALUES (?1, ?2, ?3, ?4)",
4675 params![
4676 copy.event_copy_id,
4677 operation.operation_id,
4678 copy.recipient_public_key,
4679 event.to_json()?,
4680 ],
4681 )
4682 .map_err(map_sqlite_error)?;
4683 }
4684 for intent in &operation.relay_intents {
4685 let payload_bytes = i64::try_from(intent.payload_bytes)
4686 .map_err(|_| SoftchatError::InvalidAccountOperation)?;
4687 transaction
4688 .execute(
4689 "INSERT INTO relay_delivery_intents
4690 (intent_id, operation_id, event_copy_id, relay_url, payload_bytes,
4691 state, lease_id, lease_expires_at, attempt_count, last_category, created_at)
4692 VALUES (?1, ?2, ?3, ?4, ?5, 'pending', '', 0, ?6, 'queued', ?7)",
4693 params![
4694 intent.intent_id,
4695 intent.operation_id,
4696 intent.event_copy_id,
4697 intent.relay_url,
4698 payload_bytes,
4699 initial_attempt_count,
4700 now,
4701 ],
4702 )
4703 .map_err(map_sqlite_error)?;
4704 }
4705 Ok(())
4706}
4707
4708fn load_relay_catalog(connection: &Connection) -> Result<Vec<RelayCatalogEntry>, SoftchatError> {
4709 let mut statement = connection
4710 .prepare(
4711 "SELECT relay_url, created_at, source, active, is_dns_discovered,
4712 excluded, priority, weight, expires_at
4713 FROM relay_catalog
4714 ORDER BY relay_url ASC",
4715 )
4716 .map_err(map_sqlite_error)?;
4717 let rows = statement
4718 .query_map([], |row| {
4719 let source = match row.get::<_, String>(2)?.as_str() {
4720 "automatic" => RelayCatalogSource::Automatic,
4721 "custom" => RelayCatalogSource::Custom,
4722 _ => {
4723 return Err(rusqlite::Error::FromSqlConversionFailure(
4724 2,
4725 rusqlite::types::Type::Text,
4726 "invalid relay source".into(),
4727 ));
4728 }
4729 };
4730 Ok(RelayCatalogEntry {
4731 url: row.get(0)?,
4732 created_at: row.get(1)?,
4733 source,
4734 is_active: row.get::<_, i64>(3)? != 0,
4735 is_dns_discovered: row.get::<_, i64>(4)? != 0,
4736 is_user_excluded: row.get::<_, i64>(5)? != 0,
4737 dns_priority: row.get(6)?,
4738 dns_weight: row.get(7)?,
4739 dns_expires_at: row.get(8)?,
4740 })
4741 })
4742 .map_err(map_sqlite_error)?;
4743 let mut entries = Vec::new();
4744 for row in rows {
4745 entries.push(row.map_err(map_sqlite_error)?);
4746 }
4747 if entries.len() > crate::MAX_RELAY_CATALOG_ENTRIES {
4748 return Err(SoftchatError::InvalidRelayCatalog);
4749 }
4750 Ok(entries)
4751}
4752
4753fn load_claimable(
4754 connection: &Connection,
4755 relay_url: &str,
4756) -> Result<Vec<DeliveryIntentSnapshot>, SoftchatError> {
4757 let mut statement = connection
4758 .prepare(
4759 "SELECT intent_id, operation_id, event_copy_id, relay_url, payload_bytes,
4760 state, lease_id, attempt_count
4761 FROM relay_delivery_intents
4762 WHERE state IN ('pending', 'retryable') AND lease_id = ''
4763 AND (?1 = '' OR relay_url = ?1)
4764 ORDER BY created_at ASC, intent_id ASC
4765 LIMIT 100",
4766 )
4767 .map_err(map_sqlite_error)?;
4768 let rows = statement
4769 .query_map([relay_url], row_to_intent)
4770 .map_err(map_sqlite_error)?;
4771 let mut intents = Vec::new();
4772 for row in rows {
4773 intents.push(row.map_err(map_sqlite_error)?);
4774 }
4775 Ok(intents)
4776}
4777
4778fn load_operation_intents(
4779 connection: &Connection,
4780 operation_id: &str,
4781) -> Result<Vec<DeliveryIntentSnapshot>, SoftchatError> {
4782 let mut statement = connection
4783 .prepare(
4784 "SELECT intent_id, operation_id, event_copy_id, relay_url, payload_bytes,
4785 state, lease_id, attempt_count
4786 FROM relay_delivery_intents
4787 WHERE operation_id = ?1
4788 ORDER BY intent_id ASC",
4789 )
4790 .map_err(map_sqlite_error)?;
4791 let rows = statement
4792 .query_map([operation_id], row_to_intent)
4793 .map_err(map_sqlite_error)?;
4794 let mut intents = Vec::new();
4795 for row in rows {
4796 intents.push(row.map_err(map_sqlite_error)?);
4797 }
4798 Ok(intents)
4799}
4800
4801fn row_to_intent(row: &rusqlite::Row<'_>) -> rusqlite::Result<DeliveryIntentSnapshot> {
4802 let state_text = row.get::<_, String>(5)?;
4803 let state = intent_state_from_name(&state_text).ok_or_else(|| {
4804 rusqlite::Error::FromSqlConversionFailure(
4805 5,
4806 rusqlite::types::Type::Text,
4807 "invalid durable delivery state".into(),
4808 )
4809 })?;
4810 let raw_payload_bytes = row.get::<_, i64>(4)?;
4811 let payload_bytes = u64::try_from(raw_payload_bytes).map_err(|_| {
4812 rusqlite::Error::FromSqlConversionFailure(
4813 4,
4814 rusqlite::types::Type::Integer,
4815 "negative durable payload size".into(),
4816 )
4817 })?;
4818 Ok(DeliveryIntentSnapshot {
4819 intent_id: row.get(0)?,
4820 operation_id: row.get(1)?,
4821 event_copy_id: row.get(2)?,
4822 relay_url: row.get(3)?,
4823 payload_bytes,
4824 state,
4825 lease_id: row.get(6)?,
4826 attempt_count: row.get(7)?,
4827 })
4828}
4829
4830fn load_claim_payloads(
4831 connection: &Connection,
4832 claim: &DeliveryClaim,
4833) -> Result<Vec<DeliveryPayload>, SoftchatError> {
4834 let mut payloads = Vec::with_capacity(claim.intents.len());
4835 for intent in &claim.intents {
4836 let event_json = connection
4837 .query_row(
4838 "SELECT event_json FROM outgoing_event_copies WHERE event_copy_id = ?1",
4839 [&intent.event_copy_id],
4840 |row| row.get::<_, String>(0),
4841 )
4842 .map_err(map_sqlite_error)?;
4843 payloads.push(DeliveryPayload {
4844 intent_id: intent.intent_id.clone(),
4845 relay_url: intent.relay_url.clone(),
4846 event_json,
4847 });
4848 }
4849 Ok(payloads)
4850}
4851
4852fn validate_sticker(sticker: &StickerRecord) -> Result<(), SoftchatError> {
4853 if sticker.name.is_empty()
4854 || sticker.name.len() > MAX_STICKER_NAME_BYTES
4855 || sticker
4856 .name
4857 .chars()
4858 .any(|character| character.is_control() || character == ':')
4859 || sticker.associated_emojis.len() > MAX_STICKER_EMOJIS
4860 || sticker.associated_emojis.iter().any(|emoji| {
4861 emoji.is_empty()
4862 || emoji.len() > MAX_STICKER_EMOJI_BYTES
4863 || emoji.chars().any(char::is_control)
4864 })
4865 {
4866 return Err(SoftchatError::InvalidAccountOperation);
4867 }
4868 validate_http_url(&sticker.url, true)
4869}
4870
4871fn validate_local_contact(contact: &LocalContactRecord) -> Result<(), SoftchatError> {
4872 crate::NostrPublicKey::from_hex(&contact.public_key)?;
4873 if contact.updated_at_millis < 0
4874 || contact.name.as_ref().is_some_and(|name| {
4875 name.is_empty()
4876 || name.len() > MAX_LOCAL_CONTACT_NAME_BYTES
4877 || name.chars().any(char::is_control)
4878 })
4879 {
4880 return Err(SoftchatError::InvalidAccountOperation);
4881 }
4882 Ok(())
4883}
4884
4885fn validate_link_preview(preview: &LinkPreviewRecord) -> Result<(), SoftchatError> {
4886 validate_http_url(&preview.url, false)?;
4887 if preview.updated_at_millis < 0
4888 || preview
4889 .title
4890 .as_ref()
4891 .is_some_and(|value| value.len() > MAX_LINK_PREVIEW_TITLE_BYTES)
4892 || preview
4893 .description
4894 .as_ref()
4895 .is_some_and(|value| value.len() > MAX_LINK_PREVIEW_DESCRIPTION_BYTES)
4896 {
4897 return Err(SoftchatError::InvalidAccountOperation);
4898 }
4899 if let Some(image_url) = &preview.image_url {
4900 validate_http_url(image_url, false)?;
4901 }
4902 Ok(())
4903}
4904
4905fn validate_http_url(value: &str, https_only: bool) -> Result<(), SoftchatError> {
4906 if value.is_empty() || value.len() > MAX_CACHE_URL_BYTES {
4907 return Err(SoftchatError::InvalidAccountOperation);
4908 }
4909 let parsed = Url::parse(value).map_err(|_| SoftchatError::InvalidAccountOperation)?;
4910 if parsed.host_str().is_none()
4911 || parsed.username() != ""
4912 || parsed.password().is_some()
4913 || (https_only && parsed.scheme() != "https")
4914 || (!https_only && !matches!(parsed.scheme(), "http" | "https"))
4915 {
4916 return Err(SoftchatError::InvalidAccountOperation);
4917 }
4918 Ok(())
4919}
4920
4921fn projection_from_raw(row: RawProjection) -> Result<AccountProjection, SoftchatError> {
4922 let outer = SignedNostrEvent::from_json(&row.outer_json)?;
4923 let rumor = row
4924 .rumor_json
4925 .map(|json| {
4926 serde_json::from_str::<RumorEvent>(&json)
4927 .map_err(|_| SoftchatError::InvalidPersistenceResult)
4928 })
4929 .transpose()?;
4930 Ok(AccountProjection {
4931 kind: projection_kind_from_name(&row.stored_kind)
4932 .ok_or(SoftchatError::InvalidPersistenceResult)?,
4933 logical_event_id: row.logical_event_id,
4934 outer_event: SignedEvent::from(&outer),
4935 rumor,
4936 author_public_key: row.author_public_key,
4937 created_at: row.created_at,
4938 conversation_id: row.conversation_id,
4939 participants: parse_hex_list(row.participants_csv, true)?,
4940 target_event_ids: parse_hex_list(row.targets_csv, false)?,
4941 content: row.content,
4942 })
4943}
4944
4945fn event_node_from_raw(row: RawEventNode) -> Result<AccountEventNode, SoftchatError> {
4946 let edited = row.edited_content.is_some();
4947 let effective_content = row
4948 .edited_content
4949 .clone()
4950 .unwrap_or_else(|| row.content.clone());
4951 let projection = projection_from_raw(RawProjection {
4952 stored_kind: row.stored_kind,
4953 logical_event_id: row.logical_event_id,
4954 author_public_key: row.author_public_key,
4955 created_at: row.created_at,
4956 conversation_id: row.conversation_id,
4957 content: row.content,
4958 outer_json: row.outer_json,
4959 rumor_json: row.rumor_json,
4960 participants_csv: row.participants_csv,
4961 targets_csv: row.targets_csv,
4962 })?;
4963 Ok(AccountEventNode {
4964 projection,
4965 effective_content,
4966 deleted: row.deleted,
4967 edited,
4968 reply_count: row.reply_count,
4969 })
4970}
4971
4972fn parse_hex_list(
4973 value: Option<String>,
4974 contains_public_keys: bool,
4975) -> Result<Vec<String>, SoftchatError> {
4976 let Some(value) = value else {
4977 return Ok(Vec::new());
4978 };
4979 let mut values = value.split(',').map(str::to_owned).collect::<Vec<String>>();
4980 for item in &values {
4981 if contains_public_keys {
4982 crate::NostrPublicKey::from_hex(item)?;
4983 } else {
4984 crate::NostrEventId::from_hex(item)?;
4985 }
4986 }
4987 if contains_public_keys {
4988 values.sort_unstable();
4989 values.dedup();
4990 }
4991 Ok(values)
4992}
4993
4994fn table_exists(connection: &Connection, name: &str) -> Result<bool, SoftchatError> {
4995 connection
4996 .query_row(
4997 "SELECT EXISTS(
4998 SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1
4999 )",
5000 [name],
5001 |row| row.get(0),
5002 )
5003 .map_err(map_sqlite_error)
5004}
5005
5006#[derive(Clone, Copy, Debug, Eq, PartialEq)]
5007enum AccountSchemaOwnership {
5008 Empty,
5009 Owned,
5010 Foreign,
5011}
5012
5013fn account_schema_ownership(
5014 connection: &Connection,
5015) -> Result<AccountSchemaOwnership, SoftchatError> {
5016 if table_exists(connection, "softchat_meta")? {
5017 return Ok(AccountSchemaOwnership::Owned);
5018 }
5019 let has_application_tables = connection
5020 .query_row(
5021 "SELECT EXISTS(
5022 SELECT 1 FROM sqlite_master
5023 WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
5024 )",
5025 [],
5026 |row| row.get(0),
5027 )
5028 .map_err(map_sqlite_error)?;
5029 Ok(if has_application_tables {
5030 AccountSchemaOwnership::Foreign
5031 } else {
5032 AccountSchemaOwnership::Empty
5033 })
5034}
5035
5036fn current_schema_matches(
5037 connection: &Connection,
5038 account_id: &str,
5039) -> Result<bool, SoftchatError> {
5040 let (schema_version, stored_account_id) = connection
5041 .query_row(
5042 "SELECT
5043 MAX(CASE WHEN key = 'schema_version' THEN integer_value END),
5044 MAX(CASE WHEN key = 'account_id' THEN text_value END)
5045 FROM softchat_meta
5046 WHERE key IN ('schema_version', 'account_id')",
5047 [],
5048 |row| {
5049 Ok((
5050 row.get::<_, Option<i64>>(0)?,
5051 row.get::<_, Option<String>>(1)?,
5052 ))
5053 },
5054 )
5055 .map_err(map_sqlite_error)?;
5056 Ok(schema_version == Some(ACCOUNT_SCHEMA_VERSION)
5057 && stored_account_id.as_deref() == Some(account_id))
5058}
5059
5060pub(crate) fn bump_revision(transaction: &Connection) -> Result<i64, SoftchatError> {
5061 transaction
5062 .query_row(
5063 "UPDATE softchat_meta SET integer_value = integer_value + 1
5064 WHERE key = 'revision'
5065 RETURNING integer_value",
5066 [],
5067 |row| row.get(0),
5068 )
5069 .map_err(map_sqlite_error)
5070}
5071
5072fn meta_integer(connection: &Connection, key: &str) -> Result<i64, SoftchatError> {
5073 meta_integer_optional(connection, key)?.ok_or(SoftchatError::InvalidPersistenceResult)
5074}
5075
5076fn bounded_query_count(connection: &Connection, query: &str) -> Result<u32, SoftchatError> {
5077 let value = connection
5078 .query_row(query, [], |row| row.get::<_, i64>(0))
5079 .map_err(map_sqlite_error)?;
5080 u32::try_from(value).map_err(|_| SoftchatError::InvalidPersistenceResult)
5081}
5082
5083fn meta_integer_optional(connection: &Connection, key: &str) -> Result<Option<i64>, SoftchatError> {
5084 connection
5085 .query_row(
5086 "SELECT integer_value FROM softchat_meta WHERE key = ?1",
5087 [key],
5088 |row| row.get(0),
5089 )
5090 .optional()
5091 .map_err(map_sqlite_error)
5092}
5093
5094fn validate_timestamp(value: i64) -> Result<(), SoftchatError> {
5095 let unsigned = u64::try_from(value).map_err(|_| SoftchatError::InvalidEventTimestamp)?;
5096 if unsigned > crate::MAX_PORTABLE_TIMESTAMP_SECONDS {
5097 return Err(SoftchatError::InvalidEventTimestamp);
5098 }
5099 Ok(())
5100}
5101
5102fn validate_page(limit: u32) -> Result<(), SoftchatError> {
5103 if limit == 0 || limit > MAX_QUERY_PAGE {
5104 Err(SoftchatError::InvalidAccountOperation)
5105 } else {
5106 Ok(())
5107 }
5108}
5109
5110fn validate_sync_id(value: &str) -> Result<(), SoftchatError> {
5111 if value.is_empty()
5112 || value.len() > 128
5113 || !value
5114 .bytes()
5115 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
5116 {
5117 Err(SoftchatError::InvalidSyncState)
5118 } else {
5119 Ok(())
5120 }
5121}
5122
5123const fn projection_kind_name(kind: ProjectionKind) -> &'static str {
5124 match kind {
5125 ProjectionKind::ChatMessage => "chat_message",
5126 ProjectionKind::Subject => "subject",
5127 ProjectionKind::Reaction => "reaction",
5128 ProjectionKind::Deletion => "deletion",
5129 ProjectionKind::Edit => "edit",
5130 ProjectionKind::Typing => "typing",
5131 ProjectionKind::UserMetadata => "user_metadata",
5132 ProjectionKind::FollowList => "follow_list",
5133 ProjectionKind::ApplicationData => "application_data",
5134 ProjectionKind::AppDataSync => "app_data_sync",
5135 ProjectionKind::GenericRepost => "generic_repost",
5136 ProjectionKind::Unknown => "unknown",
5137 }
5138}
5139
5140fn projection_kind_from_name(value: &str) -> Option<ProjectionKind> {
5141 match value {
5142 "chat_message" => Some(ProjectionKind::ChatMessage),
5143 "subject" => Some(ProjectionKind::Subject),
5144 "reaction" => Some(ProjectionKind::Reaction),
5145 "deletion" => Some(ProjectionKind::Deletion),
5146 "edit" => Some(ProjectionKind::Edit),
5147 "typing" => Some(ProjectionKind::Typing),
5148 "user_metadata" => Some(ProjectionKind::UserMetadata),
5149 "follow_list" => Some(ProjectionKind::FollowList),
5150 "application_data" => Some(ProjectionKind::ApplicationData),
5151 "app_data_sync" => Some(ProjectionKind::AppDataSync),
5152 "generic_repost" => Some(ProjectionKind::GenericRepost),
5153 "unknown" => Some(ProjectionKind::Unknown),
5154 _ => None,
5155 }
5156}
5157
5158const fn intent_state_name(state: DeliveryIntentState) -> &'static str {
5159 match state {
5160 DeliveryIntentState::Pending => "pending",
5161 DeliveryIntentState::Claimed => "claimed",
5162 DeliveryIntentState::SocketWritten => "socket_written",
5163 DeliveryIntentState::Accepted => "accepted",
5164 DeliveryIntentState::Rejected => "rejected",
5165 DeliveryIntentState::Retryable => "retryable",
5166 DeliveryIntentState::Cancelled => "cancelled",
5167 }
5168}
5169
5170const UNFINISHED_DELIVERY_STATES: [DeliveryIntentState; 4] = [
5171 DeliveryIntentState::Pending,
5172 DeliveryIntentState::Claimed,
5173 DeliveryIntentState::SocketWritten,
5174 DeliveryIntentState::Retryable,
5175];
5176
5177fn unfinished_delivery_count(connection: &Connection) -> Result<u32, SoftchatError> {
5178 let [pending, claimed, socket_written, retryable] =
5179 UNFINISHED_DELIVERY_STATES.map(intent_state_name);
5180 let value = connection
5181 .query_row(
5182 "SELECT COUNT(*) FROM relay_delivery_intents
5183 WHERE state IN (?1, ?2, ?3, ?4)",
5184 params![pending, claimed, socket_written, retryable],
5185 |row| row.get::<_, i64>(0),
5186 )
5187 .map_err(map_sqlite_error)?;
5188 u32::try_from(value).map_err(|_| SoftchatError::InvalidPersistenceResult)
5189}
5190
5191fn intent_state_from_name(value: &str) -> Option<DeliveryIntentState> {
5192 match value {
5193 "pending" => Some(DeliveryIntentState::Pending),
5194 "claimed" => Some(DeliveryIntentState::Claimed),
5195 "socket_written" => Some(DeliveryIntentState::SocketWritten),
5196 "accepted" => Some(DeliveryIntentState::Accepted),
5197 "rejected" => Some(DeliveryIntentState::Rejected),
5198 "retryable" => Some(DeliveryIntentState::Retryable),
5199 "cancelled" => Some(DeliveryIntentState::Cancelled),
5200 _ => None,
5201 }
5202}
5203
5204const fn relay_source_name(source: RelayCatalogSource) -> &'static str {
5205 match source {
5206 RelayCatalogSource::Automatic => "automatic",
5207 RelayCatalogSource::Custom => "custom",
5208 }
5209}
5210
5211pub(crate) fn map_sqlite_error(error: rusqlite::Error) -> SoftchatError {
5212 let _ = error;
5213 SoftchatError::InvalidPersistenceResult
5214}
5215
5216#[cfg(test)]
5217mod tests {
5218 #![allow(clippy::expect_used)]
5219
5220 use super::*;
5221 use crate::{AttachmentMetadata, ChatMessageDraft, ChatRelation, SubjectDraft};
5222
5223 const ALICE_SECRET: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
5224 const BOB_SECRET: &str = "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d";
5225
5226 fn database(account_id: &str) -> AccountDatabase {
5227 AccountDatabase::open(":memory:", account_id.to_owned()).expect("open database")
5228 }
5229
5230 fn legacy_oversized_event(identity: &LocalIdentity) -> Result<SignedNostrEvent, SoftchatError> {
5231 let sign = |content: String| {
5232 identity.sign_event(crate::NostrEventDraft::new(
5233 1_700_000_002,
5234 crate::NostrEventKind::SHORT_TEXT_NOTE,
5235 Vec::new(),
5236 content,
5237 )?)
5238 };
5239 let content_bytes = crate::MAX_RELAY_FRAME_BYTES - "[\"EVENT\",]".len() + 1
5240 - sign(String::new())?.to_json()?.len();
5241 sign(format!(
5242 "{}{}",
5243 "🦀".repeat(content_bytes / 4),
5244 "x".repeat(content_bytes % 4)
5245 ))
5246 }
5247
5248 #[test]
5249 fn legacy_wire_oversized_events_do_not_hide_sync_fingerprints()
5250 -> Result<(), Box<dyn std::error::Error>> {
5251 let identity = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
5252 let mut database = database("legacy-sync-frame");
5253 let valid = identity.sign_event(crate::NostrEventDraft::new(
5254 1_700_000_001,
5255 crate::NostrEventKind::SHORT_TEXT_NOTE,
5256 Vec::new(),
5257 "valid older event",
5258 )?)?;
5259 database.ingest(&identity, vec![SignedEvent::from(&valid)], 1_700_000_003)?;
5260 let oversized = legacy_oversized_event(&identity)?;
5261 let json = oversized.to_json()?;
5262 database.connection.execute(
5264 "INSERT INTO signed_events (event_id, canonical_json, author_public_key, kind, created_at, received_at)
5265 VALUES (?1, ?2, ?3, 1, 1700000002, 1700000003)",
5266 params![oversized.id().to_hex(), json, identity.public_key().to_hex()],
5267 )?;
5268 database.connection.execute(
5269 "INSERT INTO event_fingerprints(event_id, created_at) VALUES (?1, 1700000002)",
5270 [oversized.id().to_hex()],
5271 )?;
5272 let page = database.sync_fingerprints(None, 1_700_000_003, 1)?;
5273 assert_eq!(page.len(), 1);
5274 assert_eq!(page[0].event_id, valid.id().to_hex());
5275 assert_eq!(database.event_json(&oversized.id().to_hex())?, json);
5276 assert!(SignedNostrEvent::from_json(&json).is_ok());
5277 Ok(())
5278 }
5279
5280 #[test]
5281 fn legacy_wire_oversized_deliveries_fail_locally_without_starving_valid_work()
5282 -> Result<(), Box<dyn std::error::Error>> {
5283 let identity = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
5284 let mut database = database("legacy-delivery-frame");
5285 let relay = database.active_relay_url()?;
5286 let oversized = legacy_oversized_event(&identity)?;
5287 let json = oversized.to_json()?;
5288 database.connection.execute("INSERT INTO outgoing_operations(operation_id, account_id, created_at) VALUES ('legacy-large', 'legacy-delivery-frame', 1)", [])?;
5289 database.connection.execute("INSERT INTO outgoing_event_copies(event_copy_id, operation_id, recipient_public_key, event_json) VALUES (?1, 'legacy-large', '', ?2)", params![oversized.id().to_hex(), json])?;
5290 for (index, state, expires) in [
5291 (0, "pending", 0),
5292 (1, "claimed", 1),
5293 (2, "accepted", 0),
5294 (3, "rejected", 0),
5295 ] {
5296 database.connection.execute(
5297 "INSERT INTO relay_delivery_intents(intent_id, operation_id, event_copy_id, relay_url, payload_bytes, state, lease_id, lease_expires_at, attempt_count, last_category, created_at)
5298 VALUES (?1, 'legacy-large', ?2, ?3, ?4, ?5, ?6, ?7, 1, 'previous', 1)",
5299 params![format!("{index:064x}"), oversized.id().to_hex(), if index == 0 { relay.clone() } else { format!("wss://legacy-{index}.example/") }, i64::try_from(json.len())?, state, if expires == 0 { "" } else { "old-lease" }, expires],
5300 )?;
5301 }
5302 let valid = identity.sign_event(crate::NostrEventDraft::new(
5303 1_700_000_001,
5304 crate::NostrEventKind::SHORT_TEXT_NOTE,
5305 Vec::new(),
5306 "valid work",
5307 )?)?;
5308 database.enqueue_signed(
5309 &identity,
5310 "valid-work".to_owned(),
5311 vec![SignedEvent::from(&valid)],
5312 vec![relay.clone()],
5313 1_700_000_003,
5314 )?;
5315 let before = database.info()?.revision;
5316 let claim = database
5317 .claim_deliveries("valid-lease".to_owned(), relay.clone(), 1_700_000_004, 30)?
5318 .ok_or("valid work was starved")?;
5319 assert_eq!(claim.payloads.len(), 1);
5320 assert_eq!(
5321 SignedNostrEvent::from_json(&claim.payloads[0].event_json)?.id(),
5322 valid.id()
5323 );
5324 assert_eq!(database.info()?.revision, before + 1);
5325 for (index, expected, category) in [
5326 (0, "rejected", "event_too_large"),
5327 (1, "rejected", "event_too_large"),
5328 (2, "accepted", "previous"),
5329 (3, "rejected", "previous"),
5330 ] {
5331 let actual = database.connection.query_row("SELECT state, last_category, lease_id, lease_expires_at FROM relay_delivery_intents WHERE intent_id = ?1", [format!("{index:064x}")], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, i64>(3)?)))?;
5332 assert_eq!(
5333 actual,
5334 (expected.to_owned(), category.to_owned(), String::new(), 0)
5335 );
5336 }
5337 assert_eq!(
5338 database.connection.query_row(
5339 "SELECT event_json FROM outgoing_event_copies WHERE event_copy_id = ?1",
5340 [oversized.id().to_hex()],
5341 |row| row.get::<_, String>(0)
5342 )?,
5343 json
5344 );
5345 database.connection.execute(
5347 "UPDATE relay_delivery_intents SET state = 'retryable' WHERE intent_id = ?1",
5348 [format!("{:064x}", 0)],
5349 )?;
5350 let before_cleanup = database.info()?.revision;
5351 assert!(
5352 database
5353 .claim_deliveries("cleanup-lease".to_owned(), relay, 1_700_000_005, 30)?
5354 .is_none()
5355 );
5356 assert_eq!(database.info()?.revision, before_cleanup + 1);
5357 Ok(())
5358 }
5359
5360 #[test]
5361 fn delivery_expiration_on_another_relay_advances_revision_without_a_new_claim()
5362 -> Result<(), Box<dyn std::error::Error>> {
5363 let identity = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
5364 let mut database = database("expiration-revision");
5365 let other_relay = "wss://other-relay.example/".to_owned();
5366 let event = identity.sign_event(crate::NostrEventDraft::new(
5367 1_700_000_000,
5368 crate::NostrEventKind::SHORT_TEXT_NOTE,
5369 Vec::new(),
5370 "leased elsewhere",
5371 )?)?;
5372 database.enqueue_signed(
5373 &identity,
5374 "expired-elsewhere".to_owned(),
5375 vec![SignedEvent::from(&event)],
5376 vec![other_relay.clone()],
5377 1_700_000_000,
5378 )?;
5379 assert!(
5380 database
5381 .claim_deliveries("other-lease".to_owned(), other_relay, 1_700_000_000, 1)?
5382 .is_some()
5383 );
5384 let before = database.info()?.revision;
5385 let active_relay = database.active_relay_url()?;
5386 assert!(
5387 database
5388 .claim_deliveries(
5389 "active-lease".to_owned(),
5390 active_relay.clone(),
5391 1_700_000_002,
5392 30
5393 )?
5394 .is_none()
5395 );
5396 assert_eq!(database.info()?.revision, before + 1);
5397 let operation = database.operation("expired-elsewhere")?;
5398 assert_eq!((operation.queued_intents, operation.active_intents), (1, 0));
5399 assert!(
5400 database
5401 .claim_deliveries(
5402 "active-next-lease".to_owned(),
5403 active_relay,
5404 1_700_000_002,
5405 30
5406 )?
5407 .is_none()
5408 );
5409 assert_eq!(database.info()?.revision, before + 1);
5410 Ok(())
5411 }
5412
5413 #[test]
5414 fn ingest_is_atomic_idempotent_and_queryable() {
5415 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET).expect("alice");
5416 let bob = LocalIdentity::from_secret_hex(BOB_SECRET).expect("bob");
5417 let message = alice
5418 .create_chat_message(ChatMessageDraft {
5419 created_at: 1_700_000_000,
5420 participants: vec![bob.public_key()],
5421 content: "hello".to_owned(),
5422 relation: ChatRelation::Reply {
5423 event_id: crate::NostrEventId::from_hex(&hex::encode([9_u8; 32]))
5424 .expect("reply target"),
5425 relay_hint: None,
5426 legacy_unmarked: false,
5427 },
5428 attachments: Vec::new(),
5429 emoji_tags: Vec::new(),
5430 extension_tags: Vec::new(),
5431 })
5432 .expect("message");
5433 let rumor = crate::NostrRumor::try_from(message.rumor).expect("rumor");
5434 let wrap = alice
5435 .gift_wrap(
5436 &alice.public_key(),
5437 &rumor,
5438 Nip59EnvelopeKind::Durable,
5439 1_700_000_100,
5440 )
5441 .expect("wrap");
5442
5443 let mut database = database("alice");
5444 let logs = std::sync::Arc::new(crate::account_diagnostics::AccountLogBuffer::new(
5445 AccountLogLevel::Debug,
5446 ));
5447 database.diagnostics.buffer = Some(logs.clone());
5448 let failed: Result<(), SoftchatError> =
5449 database.apply_transport_result_atomically(|database| {
5450 database.ingest(&alice, vec![SignedEvent::from(&wrap)], 1_700_000_199)?;
5451 assert!(
5452 logs.drain(64)?.events.is_empty(),
5453 "A released savepoint is not a commit"
5454 );
5455 Err(SoftchatError::InvalidPersistenceResult)
5456 });
5457 assert!(failed.is_err());
5458 assert!(
5459 logs.drain(64)
5460 .expect("rollback diagnostics")
5461 .events
5462 .is_empty()
5463 );
5464 let first = database
5465 .ingest(&alice, vec![SignedEvent::from(&wrap)], 1_700_000_200)
5466 .expect("ingest");
5467 assert_eq!(first.revision, 1);
5468 assert_eq!(first.receipt.inserted_event_ids.len(), 1);
5469 assert_eq!(first.projections.len(), 1);
5470 assert_eq!(first.projections[0].content, "hello");
5471 assert!(first.ephemeral_projections.is_empty());
5472 let duplicate = database
5473 .ingest(&alice, vec![SignedEvent::from(&wrap)], 1_700_000_201)
5474 .expect("duplicate");
5475 assert_eq!(duplicate.receipt.duplicate_event_ids.len(), 1);
5476 assert_eq!(duplicate.projections.len(), 1);
5477 let conversations = database.list_conversations(20, 0).expect("conversations");
5478 assert_eq!(conversations.len(), 1);
5479 assert_eq!(conversations[0].last_message, "hello");
5480 let conversation_views = database
5481 .list_conversation_views(20, 0)
5482 .expect("conversation views");
5483 assert_eq!(conversation_views.len(), 1);
5484 assert_eq!(
5485 conversation_views[0].last_message.projection.content,
5486 "hello"
5487 );
5488 assert!(conversation_views[0].subject.is_none());
5489 let messages = database
5490 .list_messages(&conversations[0].conversation_id, None, 20)
5491 .expect("messages");
5492 assert_eq!(messages.len(), 1);
5493 assert_eq!(messages[0].content, "hello");
5494 let projections = database
5495 .list_projections(
5496 Some(ProjectionKind::ChatMessage),
5497 String::new(),
5498 conversations[0].conversation_id.clone(),
5499 String::new(),
5500 alice.public_key().to_hex(),
5501 None,
5502 20,
5503 0,
5504 )
5505 .expect("projections");
5506 assert_eq!(projections.len(), 1);
5507 assert_eq!(projections[0].logical_event_id, rumor.id().to_hex());
5508 assert_eq!(projections[0].content, "hello");
5509 assert_eq!(
5510 projections[0].participants,
5511 vec![alice.public_key().to_hex(), bob.public_key().to_hex()]
5512 );
5513 assert_eq!(
5514 projections[0]
5515 .rumor
5516 .as_ref()
5517 .expect("authenticated rumor")
5518 .id,
5519 rumor.id().to_hex()
5520 );
5521 let events = logs.drain(64).expect("diagnostics").events;
5522 assert_eq!(
5523 events
5524 .iter()
5525 .filter(|event| event.operation == "message.received")
5526 .count(),
5527 1
5528 );
5529 let received = &events[0];
5530 assert_eq!(received.source, Some(AccountLogSource::IncomingApi));
5531 assert_eq!(received.subject, Some(AccountLogSubject::ChatMessage));
5532 assert_eq!(
5533 received.relation,
5534 Some(crate::account_diagnostics::AccountLogRelation::Reply)
5535 );
5536 assert!(
5537 received
5538 .counters
5539 .iter()
5540 .any(|counter| counter.kind == Counter::LogicalEvents && counter.value == 1)
5541 );
5542 assert!(
5543 events
5544 .iter()
5545 .any(|event| event.operation == "ingestion.duplicate")
5546 );
5547 assert!(
5548 events
5549 .iter()
5550 .all(|event| !format!("{event:?}").contains("hello"))
5551 );
5552 }
5553
5554 #[test]
5555 fn diagnostics_count_every_and_only_unfinished_delivery_state() {
5556 let database = database("diagnostics");
5557 database
5558 .connection
5559 .execute(
5560 "INSERT INTO outgoing_operations
5561 (operation_id, account_id, created_at, command_hash, result_message_id)
5562 VALUES ('diagnostics-operation', 'diagnostics', 1, '', '')",
5563 [],
5564 )
5565 .expect("operation");
5566 let states = [
5567 DeliveryIntentState::Pending,
5568 DeliveryIntentState::Claimed,
5569 DeliveryIntentState::SocketWritten,
5570 DeliveryIntentState::Accepted,
5571 DeliveryIntentState::Rejected,
5572 DeliveryIntentState::Retryable,
5573 DeliveryIntentState::Cancelled,
5574 ];
5575 for (index, state) in states.into_iter().enumerate() {
5576 let event_copy_id = format!("diagnostics-copy-{index}");
5577 database
5578 .connection
5579 .execute(
5580 "INSERT INTO outgoing_event_copies
5581 (event_copy_id, operation_id, recipient_public_key, event_json)
5582 VALUES (?1, 'diagnostics-operation', ?2, '{}')",
5583 params![event_copy_id, format!("recipient-{index}")],
5584 )
5585 .expect("event copy");
5586 database
5587 .connection
5588 .execute(
5589 "INSERT INTO relay_delivery_intents
5590 (intent_id, operation_id, event_copy_id, relay_url, payload_bytes,
5591 state, lease_id, lease_expires_at, attempt_count, last_category, created_at)
5592 VALUES (?1, 'diagnostics-operation', ?2, ?3, 2, ?4, '', 0, 0, '', 1)",
5593 params![
5594 format!("diagnostics-intent-{index}"),
5595 event_copy_id,
5596 format!("wss://relay-{index}.example"),
5597 intent_state_name(state),
5598 ],
5599 )
5600 .expect("delivery intent");
5601 assert_eq!(
5602 intent_state_from_name(intent_state_name(state)),
5603 Some(state),
5604 );
5605 }
5606
5607 assert_eq!(
5608 database
5609 .diagnostics()
5610 .expect("diagnostics")
5611 .unfinished_delivery_count,
5612 u32::try_from(UNFINISHED_DELIVERY_STATES.len()).expect("bounded states"),
5613 );
5614 }
5615
5616 #[test]
5617 fn fresh_wrappers_share_one_logical_rumor_and_retain_every_provenance_edge() {
5618 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET).expect("alice");
5619 let bob = LocalIdentity::from_secret_hex(BOB_SECRET).expect("bob");
5620 let message = alice
5621 .create_chat_message(ChatMessageDraft {
5622 created_at: 1_700_000_000,
5623 participants: vec![bob.public_key()],
5624 content: "one rumor, three wrappers".to_owned(),
5625 relation: ChatRelation::None,
5626 attachments: Vec::new(),
5627 emoji_tags: Vec::new(),
5628 extension_tags: Vec::new(),
5629 })
5630 .expect("message");
5631 let rumor = crate::NostrRumor::try_from(message.rumor).expect("rumor");
5632 let first_wrap = alice
5633 .gift_wrap(
5634 &alice.public_key(),
5635 &rumor,
5636 Nip59EnvelopeKind::Durable,
5637 1_700_000_100,
5638 )
5639 .expect("first wrap");
5640 let second_wrap = alice
5641 .gift_wrap(
5642 &alice.public_key(),
5643 &rumor,
5644 Nip59EnvelopeKind::Durable,
5645 1_700_000_101,
5646 )
5647 .expect("second wrap");
5648 let notification_wrap = alice
5649 .gift_wrap(
5650 &alice.public_key(),
5651 &rumor,
5652 Nip59EnvelopeKind::Durable,
5653 1_700_000_102,
5654 )
5655 .expect("notification wrap");
5656 assert_ne!(first_wrap.id(), second_wrap.id());
5657 assert_ne!(second_wrap.id(), notification_wrap.id());
5658
5659 let mut database = database("alice");
5660 let logs = std::sync::Arc::new(crate::account_diagnostics::AccountLogBuffer::new(
5661 AccountLogLevel::Debug,
5662 ));
5663 database.diagnostics.buffer = Some(logs.clone());
5664 let first = database
5665 .ingest(&alice, vec![SignedEvent::from(&first_wrap)], 1_700_000_200)
5666 .expect("first ingest");
5667 let second = database
5668 .ingest(&alice, vec![SignedEvent::from(&second_wrap)], 1_700_000_201)
5669 .expect("second ingest");
5670 let notification = database
5671 .ingest_with_authenticated_replays(
5672 &alice,
5673 vec![SignedEvent::from(¬ification_wrap)],
5674 1_700_000_202,
5675 )
5676 .expect("notification ingest");
5677
5678 assert_eq!(first.projections.len(), 1);
5679 assert!(second.projections.is_empty());
5680 assert_eq!(notification.projections.len(), 1);
5681 assert_eq!(
5682 notification.projections[0].logical_event_id,
5683 rumor.id().to_hex()
5684 );
5685 assert_eq!(
5686 second.receipt.inserted_event_ids,
5687 vec![second_wrap.id().to_hex()]
5688 );
5689 assert_eq!(
5690 notification.receipt.inserted_event_ids,
5691 vec![notification_wrap.id().to_hex()]
5692 );
5693 assert_eq!(
5694 database
5695 .connection
5696 .query_row("SELECT COUNT(*) FROM signed_events", [], |row| {
5697 row.get::<_, i64>(0)
5698 })
5699 .expect("signed events"),
5700 3
5701 );
5702 assert_eq!(
5703 database
5704 .connection
5705 .query_row("SELECT COUNT(*) FROM authenticated_rumors", [], |row| {
5706 row.get::<_, i64>(0)
5707 })
5708 .expect("rumors"),
5709 1
5710 );
5711 assert_eq!(
5712 database
5713 .connection
5714 .query_row(
5715 "SELECT COUNT(*) FROM authenticated_rumor_wrappers WHERE rumor_id = ?1",
5716 [rumor.id().to_hex()],
5717 |row| row.get::<_, i64>(0),
5718 )
5719 .expect("wrapper provenance"),
5720 3
5721 );
5722 assert_eq!(
5723 database
5724 .connection
5725 .query_row("SELECT COUNT(*) FROM projections", [], |row| {
5726 row.get::<_, i64>(0)
5727 })
5728 .expect("projections"),
5729 1
5730 );
5731 logs.flush();
5732 let events = logs.drain(64).expect("diagnostics").events;
5733 assert_eq!(
5734 events
5735 .iter()
5736 .filter(|event| event.operation == "message.received")
5737 .count(),
5738 1
5739 );
5740 let copies: u64 = events
5741 .iter()
5742 .flat_map(|event| &event.counters)
5743 .filter(|counter| counter.kind == Counter::KnownCopies)
5744 .map(|counter| counter.value)
5745 .sum();
5746 assert_eq!(
5747 copies, 2,
5748 "Notification replays must not announce a new logical message"
5749 );
5750 }
5751
5752 #[test]
5753 fn version_eleven_rumors_gain_wrapper_provenance() {
5754 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET).expect("alice");
5755 let bob = LocalIdentity::from_secret_hex(BOB_SECRET).expect("bob");
5756 let message = alice
5757 .create_chat_message(ChatMessageDraft {
5758 created_at: 1_700_000_000,
5759 participants: vec![bob.public_key()],
5760 content: "migrate provenance".to_owned(),
5761 relation: ChatRelation::None,
5762 attachments: Vec::new(),
5763 emoji_tags: Vec::new(),
5764 extension_tags: Vec::new(),
5765 })
5766 .expect("message");
5767 let rumor = crate::NostrRumor::try_from(message.rumor).expect("rumor");
5768 let wrap = alice
5769 .gift_wrap(
5770 &alice.public_key(),
5771 &rumor,
5772 Nip59EnvelopeKind::Durable,
5773 1_700_000_100,
5774 )
5775 .expect("wrap");
5776 let nonce = std::time::SystemTime::now()
5777 .duration_since(std::time::UNIX_EPOCH)
5778 .expect("clock")
5779 .as_nanos();
5780 let path = std::env::temp_dir().join(format!(
5781 "softchat-provenance-migration-{}-{nonce}.sqlite",
5782 std::process::id()
5783 ));
5784 {
5785 let mut database = AccountDatabase::open(&path, "alice".to_owned()).expect("create");
5786 database
5787 .ingest(&alice, vec![SignedEvent::from(&wrap)], 1_700_000_200)
5788 .expect("ingest");
5789 }
5790 {
5791 let legacy = Connection::open(&path).expect("legacy database");
5792 legacy
5793 .execute_batch(
5794 "DROP TABLE authenticated_rumor_wrappers;
5795 DROP TABLE conversation_subject_icons;
5796 ALTER TABLE media_operations DROP COLUMN operation_kind;
5797 UPDATE softchat_meta SET integer_value = 11
5798 WHERE key = 'schema_version';",
5799 )
5800 .expect("downgrade fixture");
5801 }
5802
5803 let migrated = AccountDatabase::open(&path, "alice".to_owned()).expect("migrate");
5804 assert_eq!(migrated.info().expect("info").schema_version, 16);
5805 assert_eq!(
5806 migrated
5807 .connection
5808 .query_row(
5809 "SELECT outer_event_id FROM authenticated_rumor_wrappers
5810 WHERE rumor_id = ?1",
5811 [rumor.id().to_hex()],
5812 |row| row.get::<_, String>(0),
5813 )
5814 .expect("backfilled wrapper"),
5815 wrap.id().to_hex()
5816 );
5817 drop(migrated);
5818 let _ = std::fs::remove_file(path);
5819 }
5820
5821 #[test]
5822 fn version_twelve_reconciles_equal_time_app_data_to_the_lowest_event_id() {
5823 let nonce = std::time::SystemTime::now()
5824 .duration_since(std::time::UNIX_EPOCH)
5825 .expect("clock")
5826 .as_nanos();
5827 let path = std::env::temp_dir().join(format!(
5828 "softchat-app-data-order-migration-{}-{nonce}.sqlite",
5829 std::process::id()
5830 ));
5831 let low_id = "00".repeat(32);
5832 let high_id = "ff".repeat(32);
5833 {
5834 let database = AccountDatabase::open(&path, "alice".to_owned()).expect("create");
5835 for (event_id, value) in [(&low_id, "low"), (&high_id, "high")] {
5836 database
5837 .connection
5838 .execute(
5839 "INSERT INTO signed_events
5840 (event_id, canonical_json, author_public_key, kind, created_at,
5841 received_at)
5842 VALUES (?1, '{}', 'author', 30079, 1700000000, 1700000001)",
5843 [event_id],
5844 )
5845 .expect("signed source");
5846 database
5847 .connection
5848 .execute(
5849 "INSERT INTO projections
5850 (logical_event_id, outer_event_id, kind, author_public_key, created_at,
5851 conversation_id, content, ephemeral)
5852 VALUES (?1, ?1, 'app_data_sync', 'author', 1700000000, '', ?2, 0)",
5853 params![
5854 event_id,
5855 format!("app-settings:{{\"quickReaction\":\"{value}\"}}")
5856 ],
5857 )
5858 .expect("app-data projection");
5859 }
5860 database
5861 .connection
5862 .execute(
5863 "INSERT INTO app_data_effective
5864 (context, canonical_json, event_created_at, event_id)
5865 VALUES ('app-settings', '{\"quickReaction\":\"high\"}', 1700000000, ?1)",
5866 [&high_id],
5867 )
5868 .expect("legacy high-id winner");
5869 database
5870 .connection
5871 .execute(
5872 "UPDATE account_settings
5873 SET canonical_json = '{\"quickReaction\":\"high\"}',
5874 updated_at = 1700000000
5875 WHERE singleton = 1",
5876 [],
5877 )
5878 .expect("legacy effective settings");
5879 database
5880 .connection
5881 .execute_batch(
5882 "DROP TABLE conversation_subject_icons;
5883 ALTER TABLE media_operations DROP COLUMN operation_kind;
5884 UPDATE softchat_meta SET integer_value = 12
5885 WHERE key = 'schema_version';",
5886 )
5887 .expect("downgrade fixture");
5888 }
5889
5890 let migrated = AccountDatabase::open(&path, "alice".to_owned()).expect("migrate");
5891 assert_eq!(migrated.info().expect("info").schema_version, 16);
5892 assert_eq!(
5893 migrated
5894 .connection
5895 .query_row(
5896 "SELECT event_id FROM app_data_effective WHERE context = 'app-settings'",
5897 [],
5898 |row| row.get::<_, String>(0),
5899 )
5900 .expect("effective event"),
5901 low_id
5902 );
5903 assert_eq!(
5904 migrated
5905 .product_settings()
5906 .expect("effective settings")
5907 .quick_reaction
5908 .as_deref(),
5909 Some("low")
5910 );
5911 drop(migrated);
5912 let _ = std::fs::remove_file(path);
5913 }
5914
5915 #[test]
5916 fn version_thirteen_rebuilds_the_effective_inherited_subject_icon() {
5917 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET).expect("alice");
5918 let bob = LocalIdentity::from_secret_hex(BOB_SECRET).expect("bob");
5919 let icon = AttachmentMetadata {
5920 url: "https://media.example/inherited.png".to_owned(),
5921 sha256: Some(hex::encode([7_u8; 32])),
5922 dimensions: Some("256x256".to_owned()),
5923 ..AttachmentMetadata::default()
5924 };
5925 let icon_subject = alice
5926 .create_subject(SubjectDraft {
5927 created_at: 1_700_000_000,
5928 participants: vec![bob.public_key()],
5929 subject: "Original".to_owned(),
5930 icon: Some(icon.clone()),
5931 emoji_tags: Vec::new(),
5932 extension_tags: Vec::new(),
5933 })
5934 .expect("icon subject");
5935 let text_subject = alice
5936 .create_subject(SubjectDraft {
5937 created_at: 1_700_000_001,
5938 participants: vec![bob.public_key()],
5939 subject: "Renamed".to_owned(),
5940 icon: None,
5941 emoji_tags: Vec::new(),
5942 extension_tags: Vec::new(),
5943 })
5944 .expect("text subject");
5945 let wrap = |rumor: RumorEvent, now| {
5946 let rumor = crate::NostrRumor::try_from(rumor).expect("rumor");
5947 SignedEvent::from(
5948 &alice
5949 .gift_wrap(&alice.public_key(), &rumor, Nip59EnvelopeKind::Durable, now)
5950 .expect("wrap"),
5951 )
5952 };
5953 let icon_subject_id = icon_subject.rumor.id.clone();
5954 let events = vec![
5955 wrap(icon_subject.rumor, 1_700_172_800),
5956 wrap(text_subject.rumor, 1_700_172_801),
5957 ];
5958 let conversation_id =
5959 crate::conversation_id(vec![alice.public_key().to_hex(), bob.public_key().to_hex()])
5960 .expect("conversation id");
5961 let nonce = std::time::SystemTime::now()
5962 .duration_since(std::time::UNIX_EPOCH)
5963 .expect("clock")
5964 .as_nanos();
5965 let path = std::env::temp_dir().join(format!(
5966 "softchat-subject-icon-migration-{}-{nonce}.sqlite",
5967 std::process::id()
5968 ));
5969 let icon_operation_id;
5970 let caller_prefixed_operation_id;
5971 {
5972 let mut database = AccountDatabase::open(&path, "alice".to_owned()).expect("create");
5973 database
5974 .ingest(&alice, events, 1_700_172_802)
5975 .expect("ingest subjects");
5976 assert_eq!(
5977 database
5978 .product_conversation(&conversation_id, &alice.public_key().to_hex())
5979 .expect("conversation")
5980 .expect("stored conversation")
5981 .subject_icon,
5982 Some(icon.clone())
5983 );
5984 icon_operation_id = database
5985 .prepare_product_conversation_icon_download(&conversation_id, 1_700_172_803)
5986 .expect("prepare icon download")
5987 .id;
5988 caller_prefixed_operation_id = database
5989 .prepare_product_media(
5990 "conversation-icon-download-caller-owned",
5991 Some(conversation_id.clone()),
5992 "download",
5993 "caller-owned-fingerprint",
5994 1_700_172_804,
5995 )
5996 .expect("prepare caller-named transfer")
5997 .id;
5998 }
5999 {
6000 let legacy = Connection::open(&path).expect("legacy database");
6001 legacy
6002 .execute_batch(
6003 "DROP TABLE conversation_subject_icons;
6004 ALTER TABLE media_operations DROP COLUMN operation_kind;
6005 ALTER TABLE pending_media_messages DROP COLUMN publication_claimed;
6006 UPDATE softchat_meta SET integer_value = 13
6007 WHERE key = 'schema_version';",
6008 )
6009 .expect("downgrade fixture");
6010 }
6011
6012 let migrated = AccountDatabase::open(&path, "alice".to_owned()).expect("migrate");
6013 assert_eq!(migrated.info().expect("info").schema_version, 16);
6014 assert_eq!(
6015 migrated
6016 .connection
6017 .query_row(
6018 "SELECT subject_event_id FROM conversation_subject_icons
6019 WHERE conversation_id = ?1",
6020 [&conversation_id],
6021 |row| row.get::<_, String>(0),
6022 )
6023 .expect("effective icon source"),
6024 icon_subject_id
6025 );
6026 let conversation = migrated
6027 .product_conversation(&conversation_id, &alice.public_key().to_hex())
6028 .expect("conversation")
6029 .expect("stored conversation");
6030 assert_eq!(conversation.subject, "Renamed");
6031 assert_eq!(conversation.subject_icon, Some(icon));
6032 assert_eq!(
6033 migrated
6034 .connection
6035 .query_row(
6036 "SELECT operation_kind FROM media_operations WHERE operation_id = ?1",
6037 [&icon_operation_id],
6038 |row| row.get::<_, String>(0),
6039 )
6040 .expect("migrated icon operation kind"),
6041 "conversation_icon"
6042 );
6043 assert_eq!(
6044 migrated
6045 .connection
6046 .query_row(
6047 "SELECT operation_kind FROM media_operations WHERE operation_id = ?1",
6048 [&caller_prefixed_operation_id],
6049 |row| row.get::<_, String>(0),
6050 )
6051 .expect("migrated caller operation kind"),
6052 "transfer"
6053 );
6054 assert_eq!(
6055 migrated
6056 .connection
6057 .query_row(
6058 "SELECT publication_claimed FROM pending_media_messages LIMIT 1",
6059 [],
6060 |row| row.get::<_, bool>(0),
6061 )
6062 .optional()
6063 .expect("publication claim column"),
6064 None
6065 );
6066 drop(migrated);
6067 let _ = std::fs::remove_file(path);
6068 }
6069
6070 #[test]
6071 fn sql_edit_projection_uses_nip01_equal_time_order_in_every_arrival_order() {
6072 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET).expect("alice");
6073 let bob = LocalIdentity::from_secret_hex(BOB_SECRET).expect("bob");
6074 let original = alice
6075 .create_chat_message(ChatMessageDraft {
6076 created_at: 1_700_000_000,
6077 participants: vec![bob.public_key()],
6078 content: "original".to_owned(),
6079 relation: ChatRelation::None,
6080 attachments: Vec::new(),
6081 emoji_tags: Vec::new(),
6082 extension_tags: Vec::new(),
6083 })
6084 .expect("original");
6085 let original_id = crate::NostrEventId::from_hex(&original.rumor.id).expect("original id");
6086 let first = alice
6087 .create_edit(
6088 vec![bob.public_key()],
6089 original_id,
6090 "alpha".to_owned(),
6091 Vec::new(),
6092 1_700_000_001,
6093 )
6094 .expect("first edit");
6095 let second = alice
6096 .create_edit(
6097 vec![bob.public_key()],
6098 original_id,
6099 "beta".to_owned(),
6100 Vec::new(),
6101 1_700_000_001,
6102 )
6103 .expect("second edit");
6104 let expected = if first.rumor.id < second.rumor.id {
6105 "alpha"
6106 } else {
6107 "beta"
6108 };
6109
6110 let wrap = |rumor: RumorEvent, now| {
6111 let rumor = crate::NostrRumor::try_from(rumor).expect("rumor");
6112 SignedEvent::from(
6113 &alice
6114 .gift_wrap(&alice.public_key(), &rumor, Nip59EnvelopeKind::Durable, now)
6115 .expect("wrap"),
6116 )
6117 };
6118 let original_wrap = wrap(original.rumor, 1_700_172_800);
6119 let first_wrap = wrap(first.rumor, 1_700_172_801);
6120 let second_wrap = wrap(second.rumor, 1_700_172_802);
6121 for (index, edits) in [
6122 vec![first_wrap.clone(), second_wrap.clone()],
6123 vec![second_wrap.clone(), first_wrap.clone()],
6124 ]
6125 .into_iter()
6126 .enumerate()
6127 {
6128 let mut database = database(&format!("alice-{index}"));
6129 database
6130 .ingest(&alice, vec![original_wrap.clone()], 1_700_200_000)
6131 .expect("ingest original");
6132 for edit in edits {
6133 database
6134 .ingest(&alice, vec![edit], 1_700_200_001)
6135 .expect("ingest edit");
6136 }
6137 let conversation = database
6138 .list_conversations(1, 0)
6139 .expect("conversation")
6140 .remove(0);
6141 let message = database
6142 .list_messages(&conversation.conversation_id, None, 1)
6143 .expect("message")
6144 .remove(0);
6145 assert_eq!(message.content, expected);
6146 }
6147 }
6148
6149 #[test]
6150 fn enqueue_claim_and_accept_are_durable_transitions() {
6151 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET).expect("alice");
6152 let bob = LocalIdentity::from_secret_hex(BOB_SECRET).expect("bob");
6153 let message = alice
6154 .create_chat_message(ChatMessageDraft {
6155 created_at: 1_700_000_000,
6156 participants: vec![bob.public_key()],
6157 content: "hello".to_owned(),
6158 relation: ChatRelation::None,
6159 attachments: Vec::new(),
6160 emoji_tags: Vec::new(),
6161 extension_tags: Vec::new(),
6162 })
6163 .expect("message");
6164 let mut database = database("alice");
6165 let logs = std::sync::Arc::new(crate::account_diagnostics::AccountLogBuffer::new(
6166 AccountLogLevel::Debug,
6167 ));
6168 database.diagnostics.buffer = Some(logs.clone());
6169 let queued = database
6170 .enqueue_rumor(
6171 &alice,
6172 "operation-1".to_owned(),
6173 message.rumor,
6174 vec![bob.public_key().to_hex()],
6175 vec![bob.public_key().to_hex()],
6176 vec!["wss://relay.example".to_owned()],
6177 Nip59EnvelopeKind::Durable,
6178 1_700_000_100,
6179 )
6180 .expect("enqueue");
6181 assert_eq!(queued.operation.total_intents, 2);
6182 assert_eq!(queued.operation.state, crate::OperationState::Queued);
6183
6184 let claimed = database
6185 .claim_deliveries(
6186 "lease-1".to_owned(),
6187 "wss://relay.example".to_owned(),
6188 1_700_000_200,
6189 30,
6190 )
6191 .expect("claim")
6192 .expect("work");
6193 assert_eq!(claimed.payloads.len(), 2);
6194 database
6195 .mark_socket_written(
6196 &claimed.claim,
6197 claimed
6198 .claim
6199 .intents
6200 .iter()
6201 .map(|intent| intent.intent_id.clone())
6202 .collect(),
6203 )
6204 .expect("socket written");
6205 let before_ack = logs.drain(64).expect("diagnostics").events;
6206 assert_eq!(
6207 before_ack
6208 .iter()
6209 .filter(|event| event.operation == "message.queued")
6210 .count(),
6211 1
6212 );
6213 assert!(
6214 !before_ack
6215 .iter()
6216 .any(|event| event.operation == "message.received"
6217 || event.operation == "delivery.accepted")
6218 );
6219 let first_ack = RelayDeliveryResult {
6220 intent_id: claimed.claim.intents[0].intent_id.clone(),
6221 kind: crate::RelayDeliveryResultKind::Accepted,
6222 category: "accepted".to_owned(),
6223 };
6224 database
6225 .apply_relay_results(&claimed.claim, vec![first_ack.clone()])
6226 .expect("partial ACK");
6227 let partial = logs.drain(64).expect("partial diagnostic").events;
6228 assert_eq!(partial.len(), 1);
6229 assert_eq!(partial[0].operation, "delivery.partial");
6230 assert!(
6231 partial[0]
6232 .counters
6233 .iter()
6234 .any(|value| value.kind == Counter::AcceptedIntents && value.value == 1)
6235 );
6236 assert_eq!(
6237 database.apply_relay_results(&claimed.claim, vec![first_ack]),
6238 Err(SoftchatError::InvalidDeliveryState)
6239 );
6240 assert!(
6241 logs.drain(64)
6242 .expect("duplicate diagnostic")
6243 .events
6244 .is_empty()
6245 );
6246 database
6247 .apply_relay_results(
6248 &claimed.claim,
6249 claimed
6250 .claim
6251 .intents
6252 .iter()
6253 .skip(1)
6254 .map(|intent| RelayDeliveryResult {
6255 intent_id: intent.intent_id.clone(),
6256 kind: crate::RelayDeliveryResultKind::Accepted,
6257 category: "accepted".to_owned(),
6258 })
6259 .collect(),
6260 )
6261 .expect("accepted");
6262 assert_eq!(
6263 database.operation("operation-1").expect("operation").state,
6264 crate::OperationState::Sent
6265 );
6266 let accepted = logs.drain(64).expect("diagnostics").events;
6267 assert_eq!(accepted.len(), 1);
6268 assert_eq!(accepted[0].operation, "delivery.accepted");
6269 assert!(
6270 accepted[0]
6271 .counters
6272 .iter()
6273 .any(|counter| counter.kind == Counter::AcceptedIntents && counter.value == 2)
6274 );
6275 }
6276
6277 #[test]
6278 fn typing_delivery_does_not_emit_info_even_without_a_durable_projection()
6279 -> Result<(), Box<dyn std::error::Error>> {
6280 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
6281 let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
6282 let typing = alice.create_typing(vec![bob.public_key()], 1_700_000_000)?;
6283 let mut database = database("typing-diagnostics");
6284 let logs = std::sync::Arc::new(crate::account_diagnostics::AccountLogBuffer::new(
6285 AccountLogLevel::Info,
6286 ));
6287 database.diagnostics.buffer = Some(logs.clone());
6288 database.enqueue_rumor(
6289 &alice,
6290 "typing-operation".to_owned(),
6291 typing.rumor,
6292 vec![bob.public_key().to_hex()],
6293 vec![bob.public_key().to_hex()],
6294 vec!["wss://relay.example".to_owned()],
6295 Nip59EnvelopeKind::Ephemeral,
6296 1_700_000_100,
6297 )?;
6298 let claimed = database
6299 .claim_deliveries(
6300 "typing-lease".to_owned(),
6301 "wss://relay.example".to_owned(),
6302 1_700_000_101,
6303 30,
6304 )?
6305 .expect("typing publication");
6306 database.mark_socket_written(
6307 &claimed.claim,
6308 claimed
6309 .claim
6310 .intents
6311 .iter()
6312 .map(|intent| intent.intent_id.clone())
6313 .collect(),
6314 )?;
6315 database.apply_relay_results(
6316 &claimed.claim,
6317 claimed
6318 .claim
6319 .intents
6320 .iter()
6321 .map(|intent| RelayDeliveryResult {
6322 intent_id: intent.intent_id.clone(),
6323 kind: crate::RelayDeliveryResultKind::Accepted,
6324 category: "accepted".to_owned(),
6325 })
6326 .collect(),
6327 )?;
6328 assert_eq!(
6329 database.operation("typing-operation")?.state,
6330 crate::OperationState::Sent
6331 );
6332 assert!(
6333 logs.drain(64)?.events.is_empty(),
6334 "Routine typing must stay quiet at INFO"
6335 );
6336 Ok(())
6337 }
6338
6339 #[test]
6340 fn boundary_frame_outcomes_and_correlated_mutations_commit_as_one_transition() {
6341 let mut database = database("alice");
6342 for (index, (contact_marker, frame_bytes)) in [
6343 (0x42, crate::MAX_RELAY_FRAME_BYTES - 1),
6344 (0x43, crate::MAX_RELAY_FRAME_BYTES),
6345 ]
6346 .into_iter()
6347 .enumerate()
6348 {
6349 let run_id = format!("boundary-run-{index}");
6350 let action_id = format!("boundary-action-{index}");
6351 let outcome = crate::account_transport::StoredAccountTransportOutcome {
6352 actions: vec![crate::AccountTransportAction {
6353 run_id: run_id.clone(),
6354 action_id: action_id.clone(),
6355 generation: 1,
6356 kind: crate::AccountTransportActionKind::SendText,
6357 configured_url: String::new(),
6358 network_url: String::new(),
6359 noise_remote_static_key: Vec::new(),
6360 text_frame: "x".repeat(frame_bytes),
6361 delay_ms: 0,
6362 }],
6363 connection_state: crate::RelayConnectionState::Ready,
6364 idle: false,
6365 revision: i64::try_from(index + 1).expect("bounded revision"),
6366 typing_indicators: Vec::new(),
6367 sync: None,
6368 };
6369 let outcome_json = serde_json::to_string(&outcome).expect("outcome json");
6370 assert!(outcome_json.len() > frame_bytes);
6371 if frame_bytes == crate::MAX_RELAY_FRAME_BYTES {
6372 assert!(outcome_json.len() > crate::MAX_RELAY_FRAME_BYTES);
6373 }
6374
6375 let revision = database
6376 .apply_transport_result_atomically(|database| {
6377 database
6378 .connection
6379 .execute(
6380 "INSERT INTO local_contacts
6381 (public_key, name, updated_at_millis) VALUES (?1, ?2, 1)",
6382 params![hex::encode([contact_marker; 32]), &run_id],
6383 )
6384 .map_err(map_sqlite_error)?;
6385 database.record_transport_result(
6386 &run_id,
6387 &action_id,
6388 1,
6389 &"ab".repeat(32),
6390 &outcome_json,
6391 )
6392 })
6393 .expect("atomic transition");
6394 assert_eq!(
6395 revision,
6396 i64::try_from(index + 1).expect("bounded revision")
6397 );
6398 assert!(
6399 database
6400 .stored_transport_result(&run_id, &action_id)
6401 .expect("stored outcome")
6402 .is_some()
6403 );
6404 }
6405 assert_eq!(
6406 database
6407 .connection
6408 .query_row("SELECT COUNT(*) FROM local_contacts", [], |row| {
6409 row.get::<_, i64>(0)
6410 })
6411 .expect("correlated mutation"),
6412 2
6413 );
6414
6415 let before_failure = database.revision().expect("revision before failure");
6416 assert_eq!(
6417 database.apply_transport_result_atomically(|database| {
6418 database
6419 .connection
6420 .execute(
6421 "INSERT INTO local_contacts
6422 (public_key, name, updated_at_millis) VALUES (?1, 'rollback', 2)",
6423 [hex::encode([0x44; 32])],
6424 )
6425 .map_err(map_sqlite_error)?;
6426 database.record_transport_result(
6427 "rollback-run",
6428 "rollback-action",
6429 1,
6430 "invalid-hash",
6431 "{}",
6432 )
6433 }),
6434 Err(SoftchatError::InvalidRelaySession)
6435 );
6436 assert_eq!(
6437 database.revision().expect("revision after rollback"),
6438 before_failure
6439 );
6440 assert_eq!(
6441 database
6442 .connection
6443 .query_row(
6444 "SELECT COUNT(*) FROM local_contacts WHERE name = 'rollback'",
6445 [],
6446 |row| row.get::<_, i64>(0),
6447 )
6448 .expect("rolled back mutation"),
6449 0
6450 );
6451 }
6452
6453 #[test]
6454 fn account_reopen_recovers_views_and_expired_delivery_leases() {
6455 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET).expect("alice");
6456 let bob = LocalIdentity::from_secret_hex(BOB_SECRET).expect("bob");
6457 let message = alice
6458 .create_chat_message(ChatMessageDraft {
6459 created_at: 1_700_000_000,
6460 participants: vec![bob.public_key()],
6461 content: "durable restart".to_owned(),
6462 relation: ChatRelation::None,
6463 attachments: Vec::new(),
6464 emoji_tags: Vec::new(),
6465 extension_tags: Vec::new(),
6466 })
6467 .expect("message");
6468 let nonce = std::time::SystemTime::now()
6469 .duration_since(std::time::UNIX_EPOCH)
6470 .expect("clock")
6471 .as_nanos();
6472 let path = std::env::temp_dir().join(format!(
6473 "softchat-reopen-{}-{nonce}.sqlite",
6474 std::process::id()
6475 ));
6476
6477 let committed_revision = {
6478 let mut database =
6479 AccountDatabase::open(&path, "alice".to_owned()).expect("open first");
6480 database
6481 .enqueue_rumor(
6482 &alice,
6483 "operation-reopen".to_owned(),
6484 message.rumor,
6485 vec![bob.public_key().to_hex()],
6486 Vec::new(),
6487 vec!["wss://relay.example".to_owned()],
6488 Nip59EnvelopeKind::Durable,
6489 1_700_172_800,
6490 )
6491 .expect("enqueue");
6492 let claimed = database
6493 .claim_deliveries(
6494 "lease-before-reopen".to_owned(),
6495 "wss://relay.example".to_owned(),
6496 1_700_172_801,
6497 5,
6498 )
6499 .expect("claim")
6500 .expect("work");
6501 assert!(
6502 claimed
6503 .claim
6504 .intents
6505 .iter()
6506 .all(|intent| intent.attempt_count == 1)
6507 );
6508 database.revision().expect("revision")
6509 };
6510
6511 let mut reopened =
6512 AccountDatabase::open(&path, "alice".to_owned()).expect("reopen account");
6513 assert_eq!(
6514 reopened.revision().expect("reopened revision"),
6515 committed_revision
6516 );
6517 let views = reopened.list_conversation_views(20, 0).expect("views");
6518 assert_eq!(
6519 views
6520 .first()
6521 .expect("one view")
6522 .last_message
6523 .effective_content,
6524 "durable restart"
6525 );
6526 let recovered = reopened
6527 .claim_deliveries(
6528 "lease-after-reopen".to_owned(),
6529 "wss://relay.example".to_owned(),
6530 1_700_172_807,
6531 30,
6532 )
6533 .expect("recover")
6534 .expect("expired work");
6535 assert!(
6536 recovered
6537 .claim
6538 .intents
6539 .iter()
6540 .all(|intent| intent.attempt_count == 2)
6541 );
6542 drop(reopened);
6543 let _ = std::fs::remove_file(path);
6544 }
6545
6546 #[test]
6547 fn an_account_file_cannot_be_reopened_for_another_account() {
6548 let nonce = std::time::SystemTime::now()
6549 .duration_since(std::time::UNIX_EPOCH)
6550 .expect("clock")
6551 .as_nanos();
6552 let path = std::env::temp_dir().join(format!(
6553 "softchat-storage-{}-{nonce}.sqlite",
6554 std::process::id()
6555 ));
6556 let first = AccountDatabase::open(&path, "first".to_owned()).expect("first");
6557 drop(first);
6558 assert!(matches!(
6559 AccountDatabase::open(&path, "second".to_owned()),
6560 Err(SoftchatError::InvalidPersistenceResult)
6561 ));
6562 let _ = std::fs::remove_file(path);
6563 }
6564
6565 #[test]
6566 fn version_eight_media_operations_gain_exact_source_message_correlation() {
6567 let nonce = std::time::SystemTime::now()
6568 .duration_since(std::time::UNIX_EPOCH)
6569 .expect("clock")
6570 .as_nanos();
6571 let path = std::env::temp_dir().join(format!(
6572 "softchat-media-migration-{}-{nonce}.sqlite",
6573 std::process::id()
6574 ));
6575 drop(AccountDatabase::open(&path, "alice".to_owned()).expect("create"));
6576 {
6577 let legacy = Connection::open(&path).expect("legacy database");
6578 legacy
6579 .execute_batch(
6580 "DROP TABLE pending_asset_replacements;
6581 DROP TABLE conversation_subject_icons;
6582 ALTER TABLE media_operations DROP COLUMN operation_kind;
6583 ALTER TABLE media_operations DROP COLUMN protection;
6584 ALTER TABLE media_operations DROP COLUMN source_message_id;
6585 UPDATE softchat_meta
6586 SET integer_value = 8
6587 WHERE key = 'schema_version';",
6588 )
6589 .expect("downgrade fixture");
6590 }
6591
6592 let migrated = AccountDatabase::open(&path, "alice".to_owned()).expect("migrate");
6593 assert_eq!(migrated.info().expect("info").schema_version, 16);
6594 let columns = migrated
6595 .connection
6596 .prepare("PRAGMA table_info(media_operations)")
6597 .expect("table info")
6598 .query_map([], |row| row.get::<_, String>(1))
6599 .expect("columns")
6600 .filter_map(Result::ok)
6601 .collect::<Vec<_>>();
6602 assert!(columns.iter().any(|column| column == "source_message_id"));
6603 assert!(columns.iter().any(|column| column == "protection"));
6604 assert!(columns.iter().any(|column| column == "operation_kind"));
6605 drop(migrated);
6606 let _ = std::fs::remove_file(path);
6607 }
6608
6609 #[test]
6610 fn rejects_an_existing_database_not_owned_by_softchat() {
6611 let nonce = std::time::SystemTime::now()
6612 .duration_since(std::time::UNIX_EPOCH)
6613 .expect("clock")
6614 .as_nanos();
6615 let path = std::env::temp_dir().join(format!(
6616 "softchat-foreign-{}-{nonce}.sqlite",
6617 std::process::id()
6618 ));
6619 {
6620 let foreign = Connection::open(&path).expect("foreign database");
6621 foreign
6622 .execute_batch(
6623 "CREATE TABLE application_data (
6624 id TEXT PRIMARY KEY NOT NULL,
6625 value TEXT NOT NULL
6626 );",
6627 )
6628 .expect("foreign schema");
6629 }
6630
6631 assert!(matches!(
6632 AccountDatabase::open(&path, "alice".to_owned()),
6633 Err(SoftchatError::InvalidPersistenceResult)
6634 ));
6635 let _ = std::fs::remove_file(path);
6636 }
6637
6638 #[test]
6639 fn relay_catalog_is_committed_as_one_revisioned_state() {
6640 let mut database = database("alice");
6641 let reconciled = database
6642 .reconcile_relay_catalog(
6643 vec![
6644 DnsRelayRecord {
6645 target: "wss://relay-two.example".to_owned(),
6646 priority: 20,
6647 weight: 1,
6648 ttl_seconds: 300,
6649 },
6650 DnsRelayRecord {
6651 target: "wss://relay-one.example".to_owned(),
6652 priority: 10,
6653 weight: 2,
6654 ttl_seconds: 600,
6655 },
6656 ],
6657 1_700_000_000,
6658 "wss://fallback.example".to_owned(),
6659 true,
6660 )
6661 .expect("reconcile");
6662 assert_eq!(reconciled.revision, 1);
6663 assert_eq!(reconciled.plan.active_relay_url, "wss://relay-one.example/");
6664 assert_eq!(database.relay_catalog().expect("catalog").len(), 2);
6665
6666 let custom = database
6667 .add_custom_relay("wss://custom.example".to_owned(), 1_700_000_001)
6668 .expect("custom");
6669 assert_eq!(custom.revision, 2);
6670 let selected = database
6671 .set_active_relay("wss://custom.example".to_owned(), 1_700_000_002)
6672 .expect("active");
6673 assert_eq!(selected.revision, 3);
6674 assert_eq!(
6675 selected
6676 .entries
6677 .iter()
6678 .find(|entry| entry.is_active)
6679 .expect("one active relay")
6680 .url,
6681 "wss://custom.example/"
6682 );
6683 assert_eq!(
6684 database
6685 .relay_failover_candidates("wss://custom.example".to_owned())
6686 .expect("failover")
6687 .first()
6688 .map(String::as_str),
6689 Some("wss://relay-one.example/")
6690 );
6691 }
6692
6693 #[test]
6694 fn local_diagnostics_ignore_unchanged_writes_and_keep_values_private()
6695 -> Result<(), Box<dyn std::error::Error>> {
6696 let mut database = database("local-diagnostics");
6697 let logs = std::sync::Arc::new(crate::account_diagnostics::AccountLogBuffer::new(
6698 AccountLogLevel::Debug,
6699 ));
6700 database.diagnostics.buffer = Some(logs.clone());
6701 let contact = LocalContactRecord {
6702 public_key: LocalIdentity::from_secret_hex(BOB_SECRET)?
6703 .public_key()
6704 .to_hex(),
6705 name: Some("private-contact-name".to_owned()),
6706 updated_at_millis: 1_700_000_000_000,
6707 };
6708 database.put_local_contact(contact.clone())?;
6709 let first = logs.drain(64)?.events;
6710 assert_eq!(first.len(), 1);
6711 assert_eq!(first[0].operation, "storage.local.changed");
6712 assert_eq!(
6713 first[0].direction,
6714 Some(crate::account_diagnostics::AccountLogDirection::Local)
6715 );
6716 assert!(!format!("{first:?}").contains("private-contact-name"));
6717 database.put_local_contact(contact)?;
6718 logs.flush();
6719 assert!(logs.drain(64)?.events.is_empty());
6720 Ok(())
6721 }
6722
6723 #[test]
6724 fn use_case_caches_are_bounded_revisioned_and_clearable() {
6725 let mut database = database("alice");
6726 let bob = LocalIdentity::from_secret_hex(BOB_SECRET).expect("bob");
6727 let contact = LocalContactRecord {
6728 public_key: bob.public_key().to_hex(),
6729 name: Some("Bob".to_owned()),
6730 updated_at_millis: 1_700_000_000_000,
6731 };
6732 assert_eq!(
6733 database
6734 .put_local_contact(contact.clone())
6735 .expect("put contact")
6736 .revision,
6737 1
6738 );
6739 assert_eq!(
6740 database.local_contacts(20, 0).expect("contacts"),
6741 vec![contact.clone()]
6742 );
6743 assert!(
6744 database
6745 .list_known_public_keys(20, 0)
6746 .expect("known keys")
6747 .contains(&contact.public_key)
6748 );
6749 let sticker = StickerRecord {
6750 name: "wave".to_owned(),
6751 url: "https://cdn.example/wave.webp".to_owned(),
6752 associated_emojis: vec!["👋".to_owned()],
6753 };
6754 assert_eq!(
6755 database
6756 .replace_stickers(vec![sticker.clone()])
6757 .expect("replace stickers")
6758 .revision,
6759 2
6760 );
6761 assert_eq!(database.stickers().expect("stickers"), vec![sticker]);
6762
6763 let preview = LinkPreviewRecord {
6764 url: "https://example.com/article".to_owned(),
6765 title: Some("Article".to_owned()),
6766 description: Some("Description".to_owned()),
6767 image_url: Some("https://example.com/image.webp".to_owned()),
6768 updated_at_millis: 1_700_000_000_000,
6769 };
6770 assert_eq!(
6771 database
6772 .put_link_preview(preview.clone())
6773 .expect("put preview")
6774 .revision,
6775 3
6776 );
6777 assert_eq!(
6778 database
6779 .link_preview(preview.url.clone())
6780 .expect("link preview"),
6781 Some(preview)
6782 );
6783 assert!(database.clear().expect("clear").revision > 3);
6784 assert!(
6785 database
6786 .local_contacts(20, 0)
6787 .expect("cleared contacts")
6788 .is_empty()
6789 );
6790 assert!(database.stickers().expect("cleared stickers").is_empty());
6791 assert!(
6792 database
6793 .link_preview("https://example.com/article".to_owned())
6794 .expect("cleared preview")
6795 .is_none()
6796 );
6797 assert_eq!(database.info().expect("info").account_id, "alice");
6798 }
6799
6800 #[test]
6801 fn clear_inventory_covers_every_account_table_and_fts_index() {
6802 let mut database = database("alice");
6803 database
6804 .connection
6805 .execute_batch(
6806 "
6807 INSERT INTO signed_events VALUES
6808 ('outer', '{}', 'author', 1059, 1, 1);
6809 INSERT INTO authenticated_rumors VALUES
6810 ('rumor', 'outer', '{}', 'author', 14, 1);
6811 INSERT INTO authenticated_rumor_wrappers VALUES ('outer', 'rumor');
6812 INSERT INTO projections
6813 (logical_event_id, outer_event_id, kind, author_public_key, created_at,
6814 conversation_id, content, ephemeral)
6815 VALUES ('rumor', 'outer', 'chat_message', 'author', 1,
6816 'conversation', 'searchable retained text', 0);
6817 INSERT INTO projection_participants VALUES ('rumor', 'author');
6818 INSERT INTO projection_targets VALUES ('rumor', 'target', 0);
6819 INSERT INTO event_fingerprints VALUES ('outer', 1);
6820 INSERT INTO outgoing_operations VALUES ('operation', 'alice', 1, '', '');
6821 INSERT INTO outgoing_event_copies VALUES
6822 ('copy', 'operation', 'recipient', '{}');
6823 INSERT INTO relay_delivery_intents
6824 (intent_id, operation_id, event_copy_id, relay_url, payload_bytes,
6825 state, created_at)
6826 VALUES ('intent', 'operation', 'copy', 'wss://custom.example/', 2,
6827 'pending', 1);
6828 DELETE FROM relay_catalog;
6829 INSERT INTO relay_catalog VALUES
6830 ('wss://custom.example/', 1, 'custom', 1, 0, 0, -1, -1, -1, 1);
6831 INSERT INTO sync_checkpoints VALUES ('sync', 1, 1);
6832 INSERT INTO local_contacts VALUES ('contact', 'Contact', 1);
6833 INSERT INTO sticker_cache VALUES ('sticker', 'https://example.com/s', '[]');
6834 INSERT INTO link_preview_cache VALUES
6835 ('https://example.com', 'title', 'description', NULL, 1);
6836 INSERT INTO quarantine(source, source_id, category, observed_at)
6837 VALUES ('test', 'source', 'category', 1);
6838 INSERT INTO conversation_state
6839 (conversation_id, created_at, updated_at)
6840 VALUES ('conversation', 1, 1);
6841 INSERT INTO conversation_members VALUES ('conversation', 'author');
6842 INSERT INTO conversation_read_state
6843 (conversation_id, read_at, read_message_id, forced_unread,
6844 updated_at, update_id, unknown_json)
6845 VALUES ('conversation', 1, 'rumor', 0, 1, 'read', '{}');
6846 UPDATE account_settings
6847 SET schema_version = 9, canonical_json = '{\"retained\":true}',
6848 updated_at = 1 WHERE singleton = 1;
6849 INSERT INTO app_data_effective VALUES
6850 ('app-settings', '{\"retained\":true}', 1, 'rumor');
6851 INSERT INTO media_operations
6852 (operation_id, command_id, direction, state, source_fingerprint,
6853 created_at, updated_at)
6854 VALUES ('media-message', 'media-command', 'upload', 'prepared',
6855 'fingerprint-message', 1, 1);
6856 INSERT INTO media_operations
6857 (operation_id, command_id, direction, state, source_fingerprint,
6858 created_at, updated_at)
6859 VALUES ('media-asset', 'asset-command', 'upload', 'prepared',
6860 'fingerprint-asset', 1, 1);
6861 INSERT INTO pending_media_messages
6862 (command_id, conversation_id, text_content, emoji_tags_json,
6863 command_hash, state, created_at, updated_at)
6864 VALUES ('media-command', 'conversation', 'pending', '[]',
6865 'hash', 'waiting_for_uploads', 1, 1);
6866 INSERT INTO pending_message_attachments VALUES
6867 ('media-command', 0, 'media-message');
6868 INSERT INTO pending_asset_replacements
6869 (command_id, target, operation_id, command_hash, state,
6870 created_at, updated_at)
6871 VALUES ('asset-command', 'profile_picture', 'media-asset', 'hash',
6872 'waiting_for_upload', 1, 1);
6873 INSERT INTO message_local_extras
6874 (message_id, transcript, waveform, layout_width, layout_height,
6875 updated_at)
6876 VALUES ('rumor', 'transcript', X'0102', 100, 50, 1);
6877 INSERT INTO processed_transport_results VALUES
6878 ('run', 'action', 1,
6879 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', '{}');
6880 INSERT INTO message_fts(message_id, conversation_id, content)
6881 VALUES ('rumor', 'conversation', 'searchable retained text');
6882 ",
6883 )
6884 .expect("seed every account table");
6885
6886 let schema_tables = database
6887 .connection
6888 .prepare("SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name")
6889 .expect("schema query")
6890 .query_map([], |row| row.get::<_, String>(0))
6891 .expect("schema rows")
6892 .filter_map(Result::ok)
6893 .filter(|name| !name.starts_with("sqlite_") && !name.starts_with("message_fts_"))
6894 .collect::<BTreeSet<_>>();
6895 let expected_tables = ACCOUNT_CLEAR_TABLES
6896 .iter()
6897 .copied()
6898 .chain(["softchat_meta", "relay_catalog", "account_settings"])
6899 .map(str::to_owned)
6900 .collect::<BTreeSet<_>>();
6901 assert_eq!(schema_tables, expected_tables);
6902
6903 let cleared = database.clear().expect("clear complete account");
6904 assert!(cleared.revision > 0);
6905 for table in ACCOUNT_CLEAR_TABLES {
6906 let count = database
6907 .connection
6908 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| {
6909 row.get::<_, i64>(0)
6910 })
6911 .expect("cleared row count");
6912 assert_eq!(count, 0, "{table} retained account data");
6913 }
6914 assert_eq!(
6915 database
6916 .connection
6917 .query_row(
6918 "SELECT relay_url FROM relay_catalog WHERE active = 1",
6919 [],
6920 |row| row.get::<_, String>(0),
6921 )
6922 .expect("default relay"),
6923 DEFAULT_RELAY_URL
6924 );
6925 assert_eq!(
6926 database
6927 .connection
6928 .query_row(
6929 "SELECT schema_version, canonical_json, updated_at
6930 FROM account_settings WHERE singleton = 1",
6931 [],
6932 |row| {
6933 Ok((
6934 row.get::<_, i64>(0)?,
6935 row.get::<_, String>(1)?,
6936 row.get::<_, i64>(2)?,
6937 ))
6938 },
6939 )
6940 .expect("default settings"),
6941 (1, "{}".to_owned(), 0)
6942 );
6943 assert_eq!(
6944 database.info().expect("account binding").account_id,
6945 "alice"
6946 );
6947 }
6948}