Skip to main content

softchat/
storage.rs

1//! Rust-owned account persistence and bounded use-case queries.
2//!
3//! One [`AccountDatabase`] owns one SQLite connection for one opaque account.
4//! Protocol truth, authenticated rumors, projections, outgoing operations,
5//! delivery leases, relay state, and synchronization checkpoints are committed
6//! through Rust transactions. Platforms provide only the database path and
7//! execute network, file, lifecycle, and UI work.
8
9#![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
43// The single SQLite definition of effective edit eligibility and ordering.
44// Page/context/preview queries and the search index all consume this view.
45const 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/";
84// The account transport emits at most 128 actions. Delivery payloads are
85// bounded to 512 KiB in aggregate, sync frames to 60 KiB, typing state to
86// 1,024 entries, and relay URLs to 2 KiB. The factor of six is serde_json's
87// maximum string-byte expansion; the remaining term bounds action metadata.
88const 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
95/// Authoritative child-first inventory of account-owned rows removed by reset.
96///
97/// `softchat_meta` is deliberately absent because it binds the file to one
98/// account and owns the monotonic schema/revision metadata. `relay_catalog`
99/// and `account_settings` are reset to their safe defaults after this list is
100/// emptied.
101const 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/// Static database information returned after opening one account.
172#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
173#[serde(rename_all = "camelCase")]
174#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
175pub struct AccountDatabaseInfo {
176    /// Opaque non-secret account partition bound to the file.
177    pub account_id: String,
178    /// Rust-owned schema version.
179    pub schema_version: i64,
180    /// Monotonic revision incremented by every successful state mutation.
181    pub revision: i64,
182    /// Whether SQLite accepted WAL journal mode.
183    pub write_ahead_logging: bool,
184}
185
186/// Redacted bounded account health information for support and recovery UI.
187#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
188#[serde(rename_all = "camelCase")]
189#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
190pub struct AccountDiagnostics {
191    /// Rust-owned schema version.
192    pub schema_version: i64,
193    /// Monotonic durable account revision.
194    pub revision: i64,
195    /// Canonical active relay, empty when the catalog is not configured.
196    pub active_relay_url: String,
197    /// Number of retained relay candidates.
198    pub relay_count: u32,
199    /// Number of unfinished recipient delivery intents.
200    pub unfinished_delivery_count: u32,
201    /// Number of logical media operations that may still need platform work.
202    pub recoverable_media_count: u32,
203    /// Number of authenticated durable outer events.
204    pub stored_event_count: u32,
205    /// Number of redacted quarantined inputs.
206    pub quarantine_count: u32,
207    /// Main SQLite database allocation, excluding WAL and SHM sidecars.
208    pub database_bytes: u64,
209}
210
211/// Result of one atomic authenticated ingestion.
212#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
213#[serde(rename_all = "camelCase")]
214#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
215pub struct StoredIngestion {
216    /// Total classification of the supplied batch.
217    pub receipt: IngestionReceipt,
218    /// Database revision after commit.
219    pub revision: i64,
220    /// Every authenticated projection selected for the caller.
221    ///
222    /// Durable values have committed to SQLite before this result is returned.
223    /// Ephemeral values are authenticated but intentionally remain memory-only.
224    /// The bounded JSON push boundary also returns an authenticated projection
225    /// when a fresh wrapper proves a logical rumor that was already projected.
226    pub projections: Vec<AccountProjection>,
227    /// Authenticated non-durable values for immediate Android use cases.
228    pub ephemeral_projections: Vec<AccountProjection>,
229}
230
231#[derive(Clone, Copy, Debug, Eq, PartialEq)]
232enum ReplayProjectionPolicy {
233    SuppressKnownRumors,
234    IncludeKnownRumors,
235}
236
237/// Generic result for a transaction that changed account state.
238#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
239#[serde(rename_all = "camelCase")]
240#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
241pub struct AccountMutationResult {
242    /// Database revision after commit.
243    pub revision: i64,
244}
245
246/// Result of atomically selecting a failover relay and rebinding unfinished work.
247#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
248#[serde(rename_all = "camelCase")]
249#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
250pub struct RelayFailoverMutation {
251    /// Complete catalog after rotation.
252    pub entries: Vec<RelayCatalogEntry>,
253    /// Newly selected canonical relay URL.
254    pub active_relay_url: String,
255    /// Number of unfinished delivery intents rebound to the new relay.
256    pub rebound_intent_count: u32,
257    /// Durable account revision after the atomic transaction.
258    pub revision: i64,
259}
260
261/// Exact stored event payload selected by an authenticated ID.
262#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
263#[serde(rename_all = "camelCase")]
264#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
265pub struct StoredEventJson {
266    /// Canonical event ID.
267    pub event_id: String,
268    /// Exact canonical signed event JSON.
269    pub event_json: String,
270}
271
272/// Persisted relay catalog mutation.
273#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
274#[serde(rename_all = "camelCase")]
275#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
276pub struct RelayCatalogMutation {
277    /// Complete persisted relay rows.
278    pub entries: Vec<RelayCatalogEntry>,
279    /// Database revision after commit.
280    pub revision: i64,
281}
282
283/// Persisted authenticated relay-discovery reconciliation.
284#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
285#[serde(rename_all = "camelCase")]
286#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
287pub struct StoredRelayCatalogPlan {
288    /// Deterministic catalog and refresh plan.
289    pub plan: RelayCatalogPlan,
290    /// Database revision after commit.
291    pub revision: i64,
292}
293
294/// Result of an idempotent outgoing operation transaction.
295#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
296#[serde(rename_all = "camelCase")]
297#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
298pub struct AccountOperationResult {
299    /// Current aggregate operation state.
300    pub operation: OperationSnapshot,
301    /// Database revision after commit, or the current revision for a replay.
302    pub revision: i64,
303    /// Whether this call inserted the operation for the first time.
304    pub inserted: bool,
305}
306
307pub(crate) struct ProductAccountOperationResult {
308    pub operation: AccountOperationResult,
309    pub result_message_id: String,
310}
311
312/// Exact payload attached to one claimed relay-delivery intent.
313#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
314#[serde(rename_all = "camelCase")]
315#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
316pub struct DeliveryPayload {
317    /// Stable intent ID.
318    pub intent_id: String,
319    /// Canonical configured relay URL.
320    pub relay_url: String,
321    /// Exact canonical signed event JSON.
322    pub event_json: String,
323}
324
325/// One committed delivery lease and the exact bytes Android may send.
326#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
327#[serde(rename_all = "camelCase")]
328#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
329pub struct ClaimedDelivery {
330    /// Rust-validated lease snapshot.
331    pub claim: DeliveryClaim,
332    /// Payloads in the same order as `claim.intents`.
333    pub payloads: Vec<DeliveryPayload>,
334    /// Database revision after the lease transaction.
335    pub revision: i64,
336}
337
338/// Bounded conversation list item derived from authenticated projections.
339#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
340#[serde(rename_all = "camelCase")]
341#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
342pub struct ConversationSummary {
343    /// Stable conversation hash derived from the complete participant set.
344    pub conversation_id: String,
345    /// Sorted canonical participant public keys.
346    pub participants: Vec<String>,
347    /// Effective last-message text after valid edits.
348    pub last_message: String,
349    /// Timestamp of the newest durable message projection.
350    pub last_created_at: i64,
351    /// Number of non-deleted durable chat messages.
352    pub message_count: u32,
353}
354
355/// One complete conversation-list use case returned in a single native call.
356#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
357#[serde(rename_all = "camelCase")]
358#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
359pub struct ConversationView {
360    /// Aggregate ordering, participants, effective text, and count.
361    pub summary: ConversationSummary,
362    /// Newest non-deleted message with exact authenticated protocol truth.
363    pub last_message: AccountEventNode,
364    /// Newest authenticated subject update, when one exists.
365    pub subject: Option<AccountProjection>,
366}
367
368/// One bounded message view derived without replacing protocol truth.
369#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
370#[serde(rename_all = "camelCase")]
371#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
372pub struct AccountMessage {
373    /// Authenticated rumor ID.
374    pub event_id: String,
375    /// Stable conversation hash.
376    pub conversation_id: String,
377    /// Authenticated logical author.
378    pub author_public_key: String,
379    /// Original event timestamp.
380    pub created_at: i64,
381    /// Effective text after deterministic valid-edit ordering.
382    pub content: String,
383    /// Whether an authorized deletion takes precedence.
384    pub deleted: bool,
385    /// Whether `content` came from a later authorized edit.
386    pub edited: bool,
387    /// Marked reply target, or an empty string.
388    pub reply_to_event_id: String,
389}
390
391/// One authenticated projection with exact protocol truth attached.
392#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
393#[serde(rename_all = "camelCase")]
394#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
395pub struct AccountProjection {
396    /// Semantic projection family.
397    pub kind: ProjectionKind,
398    /// Authenticated logical rumor or event ID.
399    pub logical_event_id: String,
400    /// Exact verified outer signed event.
401    pub outer_event: SignedEvent,
402    /// Exact authenticated rumor for NIP-59 data, when present.
403    pub rumor: Option<RumorEvent>,
404    /// Authenticated logical author.
405    pub author_public_key: String,
406    /// Portable logical event timestamp.
407    pub created_at: i64,
408    /// Conversation hash, or an empty string for account-wide data.
409    pub conversation_id: String,
410    /// Sorted complete participant set.
411    pub participants: Vec<String>,
412    /// Ordered reply, edit, reaction, or deletion targets.
413    pub target_event_ids: Vec<String>,
414    /// Validated primary text or JSON payload.
415    pub content: String,
416}
417
418/// One use-case node derived from authenticated protocol truth.
419#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
420#[serde(rename_all = "camelCase")]
421#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
422pub struct AccountEventNode {
423    /// Exact authenticated projection and protocol values.
424    pub projection: AccountProjection,
425    /// Effective text after authorized deterministic edits.
426    pub effective_content: String,
427    /// Whether an authorized deletion takes precedence.
428    pub deleted: bool,
429    /// Whether `effective_content` came from an authorized edit.
430    pub edited: bool,
431    /// Number of non-deleted authenticated message children.
432    pub reply_count: u32,
433}
434
435/// One cached custom emoji or sticker descriptor.
436#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
437#[serde(rename_all = "camelCase")]
438#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
439pub struct StickerRecord {
440    /// Stable shortcode without surrounding colons.
441    pub name: String,
442    /// Validated HTTPS asset URL.
443    pub url: String,
444    /// Bounded emoji strings used for search and suggestions.
445    pub associated_emojis: Vec<String>,
446}
447
448/// One cached HTTP link-preview result.
449#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
450#[serde(rename_all = "camelCase")]
451#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
452pub struct LinkPreviewRecord {
453    /// Exact validated HTTP(S) request URL and cache key.
454    pub url: String,
455    /// Optional bounded page title.
456    pub title: Option<String>,
457    /// Optional bounded page description.
458    pub description: Option<String>,
459    /// Optional validated HTTP(S) preview-image URL.
460    pub image_url: Option<String>,
461    /// Android-supplied wall-clock observation time in milliseconds.
462    pub updated_at_millis: i64,
463}
464
465/// One Android-local address-book entry.
466///
467/// Local contacts are use-case state, not authenticated Nostr metadata. They
468/// are deliberately stored beside the account in Rust SQLite so Android does
469/// not need a second database or an in-memory persistence exception.
470#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
471#[serde(rename_all = "camelCase")]
472#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
473pub struct LocalContactRecord {
474    /// Canonical remote public key.
475    pub public_key: String,
476    /// Optional bounded local display name.
477    pub name: Option<String>,
478    /// Android-supplied wall-clock update time in milliseconds.
479    pub updated_at_millis: i64,
480}
481
482/// One account-scoped SQLite owner.
483///
484/// The type is intentionally synchronous. Native language facades serialize a
485/// database handle and Android invokes it on its I/O dispatcher. Rust never
486/// calls a platform database callback and never owns a background thread.
487pub 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    /// Open or create one account database and apply Rust-owned migrations.
575    ///
576    /// # Errors
577    ///
578    /// Returns a stable account or persistence error for an invalid path,
579    /// account mismatch, foreign schema, unsupported schema, or SQLite
580    /// failure.
581    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    // Optional diagnostic reads neither allocate payload copies nor affect writes.
643    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    /// Return schema, revision, and journal information.
667    ///
668    /// # Errors
669    ///
670    /// Returns a stable persistence error if metadata cannot be read.
671    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    /// Return the current monotonic database revision.
681    ///
682    /// # Errors
683    ///
684    /// Returns a stable persistence error if metadata cannot be read.
685    pub fn revision(&self) -> Result<i64, SoftchatError> {
686        meta_integer(&self.connection, "revision")
687    }
688
689    /// Return redacted bounded account health without exposing event or key data.
690    ///
691    /// # Errors
692    ///
693    /// Returns a stable persistence error if SQLite metadata is inconsistent.
694    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    /// Authenticate, unwrap, classify, and atomically persist incoming events.
740    ///
741    /// Ephemeral projections are authenticated and reported in the receipt but
742    /// never written to durable history.
743    ///
744    /// # Errors
745    ///
746    /// Returns stable event, envelope, account, projection, or persistence
747    /// errors. No partial transaction is committed.
748    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    /// Create and atomically persist one idempotent NIP-59 operation.
871    ///
872    /// The local sender copy is authenticated and projected in the same
873    /// transaction as event copies and delivery intents. Reusing an existing
874    /// operation ID returns its current state without generating new wrappers.
875    ///
876    /// # Errors
877    ///
878    /// Returns stable operation, identity, envelope, relay, or persistence
879    /// errors. No socket may send until this method succeeds.
880    #[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    /// Atomically persist already signed direct events and delivery intents.
916    ///
917    /// This is intended for authenticated direct protocol events such as
918    /// NIP-42 and NIP-98, not for bypassing private-chat wrapping.
919    ///
920    /// # Errors
921    ///
922    /// Returns stable event, operation, relay, or persistence errors.
923    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    /// Create one high-level command after checking its canonical payload hash.
949    ///
950    /// This is the product idempotency boundary. Repeating the same command and
951    /// payload returns the existing operation without creating fresh wrappers;
952    /// reusing the identifier for different intent fails deterministically.
953    #[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    /// Create one high-level direct signed command with payload idempotency.
1020    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    /// Atomically commit one complete settings value and its signed sync event.
1063    #[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    /// Atomically commit one read-state entry, complete snapshot, and signed sync event.
1150    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    /// Claim pending work, recover expired leases, and return exact payloads.
1268    ///
1269    /// # Errors
1270    ///
1271    /// Returns a stable delivery or persistence error for a malformed lease,
1272    /// state conflict, invalid timestamp, or SQLite failure.
1273    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        // Older account profiles admitted event JSON up to the whole relay
1307        // frame bound, without reserving the EVENT envelope. Preserve those
1308        // copies but fail their unfinished local intents before claiming so
1309        // one unpublishable legacy row cannot starve otherwise valid work.
1310        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    /// Commit that selected leased payloads were written to a socket.
1362    ///
1363    /// Socket write is not relay acceptance.
1364    ///
1365    /// # Errors
1366    ///
1367    /// Returns a stable delivery or persistence error for stale lease state.
1368    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    /// Commit correlated terminal or retryable relay results.
1378    ///
1379    /// # Errors
1380    ///
1381    /// Returns a stable delivery or persistence error for stale or
1382    /// contradictory state.
1383    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    /// Release unfinished leased work after cancellation or transport loss.
1393    ///
1394    /// # Errors
1395    ///
1396    /// Returns a stable delivery or persistence error for stale lease state.
1397    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    /// Return one operation's current durable aggregate state.
1406    ///
1407    /// # Errors
1408    ///
1409    /// Returns a stable account or persistence error when the operation is
1410    /// unknown or its durable rows are inconsistent.
1411    pub fn operation(&self, operation_id: &str) -> Result<OperationSnapshot, SoftchatError> {
1412        self.operation_optional(operation_id)?
1413            .ok_or(SoftchatError::InvalidAccountOperation)
1414    }
1415
1416    /// Return a bounded newest-first page of durable outgoing operations.
1417    ///
1418    /// # Errors
1419    ///
1420    /// Returns a stable bound, operation, or persistence error.
1421    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    /// Requeue terminally rejected intents for an explicit user retry.
1526    ///
1527    /// Accepted copies are never duplicated. Repeating retry while work is
1528    /// already queued is idempotent.
1529    ///
1530    /// # Errors
1531    ///
1532    /// Returns a stable account or persistence error for an unknown operation
1533    /// or an operation with active leased work.
1534    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    /// Cancel every unclaimed delivery intent that is still safe to cancel.
1578    ///
1579    /// Accepted or rejected terminal rows remain exact history. Active leases
1580    /// must first be released by the transport run.
1581    ///
1582    /// # Errors
1583    ///
1584    /// Returns a stable account or delivery-state error for unknown or active
1585    /// work.
1586    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    /// Return the complete persisted relay catalog.
1630    ///
1631    /// # Errors
1632    ///
1633    /// Returns a stable relay-catalog or persistence error.
1634    pub fn relay_catalog(&self) -> Result<Vec<RelayCatalogEntry>, SoftchatError> {
1635        load_relay_catalog(&self.connection)
1636    }
1637
1638    /// Reconcile authenticated discovery and persist the complete result.
1639    ///
1640    /// # Errors
1641    ///
1642    /// Returns a stable relay-catalog or persistence error.
1643    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    /// Add or reactivate one custom relay and persist the catalog.
1662    ///
1663    /// # Errors
1664    ///
1665    /// Returns a stable relay-catalog or persistence error.
1666    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    /// Exclude or remove one relay and persist the catalog.
1678    ///
1679    /// # Errors
1680    ///
1681    /// Returns a stable relay-catalog or persistence error.
1682    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    /// Restore automatic relays and persist the catalog.
1694    ///
1695    /// # Errors
1696    ///
1697    /// Returns a stable relay-catalog or persistence error.
1698    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    /// Select and persist one active relay.
1709    ///
1710    /// # Errors
1711    ///
1712    /// Returns a stable relay-catalog or persistence error.
1713    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    /// Return effective failover candidates from durable catalog state.
1725    ///
1726    /// # Errors
1727    ///
1728    /// Returns a stable relay-catalog or persistence error.
1729    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    /// Atomically rotate away from a failed relay and rebind unfinished intent.
1737    ///
1738    /// # Errors
1739    ///
1740    /// Returns a stable catalog, delivery-state, or persistence error.
1741    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    /// Return a newest-first bounded conversation page.
1807    ///
1808    /// # Errors
1809    ///
1810    /// Returns a stable account or persistence error for an invalid limit or
1811    /// inconsistent projection data.
1812    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    /// Return complete newest-first conversation-list rows in one bounded call.
1826    ///
1827    /// This avoids one Android-to-Rust transition per conversation while
1828    /// keeping exact event values and effective edit/deletion state in Rust.
1829    ///
1830    /// # Errors
1831    ///
1832    /// Returns a stable account or persistence error for invalid bounds or
1833    /// inconsistent projection data.
1834    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    /// Return a newest-first bounded message page for one conversation.
2054    ///
2055    /// Passing `before_created_at` excludes messages at or after that timestamp.
2056    /// Callers that need lossless same-timestamp paging should use the returned
2057    /// event ID as an application cursor in a later SDK revision.
2058    ///
2059    /// # Errors
2060    ///
2061    /// Returns a stable account or persistence error for malformed input.
2062    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    /// Return a bounded newest-first authenticated projection page.
2155    ///
2156    /// Empty string filters are ignored. This one query surface supports
2157    /// subjects, reactions, deletions, edits, profiles, contacts, application
2158    /// data, and detail screens without duplicating protocol interpretation in
2159    /// Android.
2160    ///
2161    /// # Errors
2162    ///
2163    /// Returns a stable account, event, or persistence error for invalid
2164    /// filters, limits, or stored truth.
2165    #[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    /// Return a bounded page of authenticated projections with use-case state.
2196    ///
2197    /// Filtering and edit/deletion/reply semantics execute in the same Rust
2198    /// database owner as ingestion. Android receives complete immutable values
2199    /// and never issues SQL or reinterprets protocol tags.
2200    ///
2201    /// # Errors
2202    ///
2203    /// Returns a stable account, event, or persistence error for invalid
2204    /// filters, limits, or stored truth.
2205    #[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    /// Return bounded canonical public keys known to account state.
2375    ///
2376    /// # Errors
2377    ///
2378    /// Returns a stable account or persistence error for an invalid limit or
2379    /// inconsistent stored truth.
2380    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    /// Insert or replace one bounded Android-local contact.
2414    ///
2415    /// This does not create or impersonate authenticated kind-0 metadata.
2416    ///
2417    /// # Errors
2418    ///
2419    /// Returns a stable account or persistence error for an invalid key, name,
2420    /// timestamp, or SQLite failure.
2421    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    /// Remove one Android-local contact idempotently.
2452    ///
2453    /// # Errors
2454    ///
2455    /// Returns a stable account or persistence error for an invalid key or
2456    /// SQLite failure.
2457    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    /// Return a bounded stable page of Android-local contacts.
2483    ///
2484    /// # Errors
2485    ///
2486    /// Returns a stable account or persistence error for invalid bounds or
2487    /// malformed stored state.
2488    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    /// Atomically replace the complete bounded custom-emoji/sticker cache.
2523    ///
2524    /// The cache is use-case state rather than protocol truth, but remains in
2525    /// the Rust-owned account database so Android never owns a second SQLite
2526    /// schema.
2527    ///
2528    /// # Errors
2529    ///
2530    /// Returns a stable account or persistence error for invalid names, URLs,
2531    /// emoji bounds, duplicates, or SQLite failure.
2532    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    /// Return the complete bounded sticker cache in stable name order.
2575    ///
2576    /// # Errors
2577    ///
2578    /// Returns a stable persistence error for malformed stored state.
2579    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    /// Insert or replace one bounded link-preview cache result.
2613    ///
2614    /// # Errors
2615    ///
2616    /// Returns a stable account or persistence error for invalid URLs, text
2617    /// bounds, timestamps, or SQLite failure.
2618    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    /// Return one cached link preview by its exact validated URL.
2665    ///
2666    /// # Errors
2667    ///
2668    /// Returns a stable account or persistence error for an invalid URL or
2669    /// malformed stored result.
2670    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    /// Clear every account-owned row while retaining binding and revision.
2697    ///
2698    /// Relay catalog and account settings are recreated at safe defaults in
2699    /// the same transaction. The authoritative table inventory includes the
2700    /// standalone FTS table and transport-result journal.
2701    ///
2702    /// # Errors
2703    ///
2704    /// Returns a stable persistence error if the atomic clear fails.
2705    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    /// Return exact canonical signed-event JSON by outer event ID.
2741    ///
2742    /// # Errors
2743    ///
2744    /// Returns a stable event or persistence error when the ID is malformed,
2745    /// unknown, or unreadable.
2746    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    /// Return exact event JSON for a bounded unique ID set.
2760    ///
2761    /// This executes synchronization resend actions without exposing a
2762    /// database adapter to Android.
2763    ///
2764    /// # Errors
2765    ///
2766    /// Returns a stable event, account, or persistence error for malformed,
2767    /// duplicate, excessive, or unknown IDs.
2768    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    /// Load one bounded authenticated fingerprint snapshot for synchronization.
2791    ///
2792    /// # Errors
2793    ///
2794    /// Returns a stable sync or persistence error for invalid ranges, limits,
2795    /// or database state.
2796    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    /// Return a previously committed synchronization checkpoint.
2847    ///
2848    /// # Errors
2849    ///
2850    /// Returns a stable sync or persistence error for an invalid ID or
2851    /// database state.
2852    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    /// Atomically persist one synchronization checkpoint.
2865    ///
2866    /// # Errors
2867    ///
2868    /// Returns a stable sync or persistence error for invalid input or a failed
2869    /// transaction.
2870    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    /// Remove every durable synchronization checkpoint and wake observers.
2896    ///
2897    /// Authenticated event truth is retained. The next synchronization run
2898    /// therefore reconciles the complete retained history instead of
2899    /// downloading an unverified replacement database.
2900    ///
2901    /// # Errors
2902    ///
2903    /// Returns a stable timestamp or persistence error.
2904    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                // Diagnostic-only reads must never make a valid state transition fail.
3223                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
3268// Optional, indexed diagnostic reads never change the success of a delivery write.
3269fn 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    // Ephemeral sender copies deliberately have no durable product projection.
3293    // Their authenticated outer kind still proves that routine ACKs are DEBUG.
3294    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        // Profile version 1 inherits the last explicit icon.
4221        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    // A fixed single-message predicate lets SQLite use its primary-key index;
4608    // an optional OR predicate would scan the history on every ingestion.
4609    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        // Insert the canonical shape accepted by the previous account profile.
5263        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        // A cleanup-only claim must also publish a new durable revision.
5346        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(&notification_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}